Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# goio: IO, Stream, and Fiber for Go

![Coverage](https://img.shields.io/badge/Coverage-90.2%25-brightgreen)
# Implementation of IO, Stream, Fiber using go1.18 generics
![Coverage](https://img.shields.io/badge/Coverage-90.4%25-brightgreen)
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/56db71f0cf6d4c76b796af26a1d7ef41)](https://app.codacy.com/gh/Primetalk/goio?utm_source=github.com&utm_medium=referral&utm_content=Primetalk/goio&utm_campaign=Badge_Grade_Settings)
[![Go Reference](https://pkg.go.dev/badge/github.com/primetalk/goio.svg)](https://pkg.go.dev/github.com/primetalk/goio)
[![GoDoc](https://godoc.org/github.com/primetalk/goio?status.svg)](https://godoc.org/github.com/primetalk/goio)
Expand Down
211 changes: 211 additions & 0 deletions experimental/contextio/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
# contextio

`contextio` is an experimental Go 1.18 package for lazy, context-aware effects.
It provides synchronous and asynchronous effects, cooperative cancellation,
fibers, structured timeout and race operations, and cancellation-masked resource
finalization.

The package is an isolated prototype. Its API and behavior are not
production-supported, and existing production packages do not depend on it.

## Effect model

An `Effect[A]` is an opaque value with a private representation equivalent to:

```go
func(context.Context) (A, error)
```

Constructing or composing an effect does not execute user work. `Run` is the
public synchronous execution boundary:

```go
effect := contextio.Map(
contextio.Eval(func(ctx context.Context) (int, error) {
return 21, ctx.Err()
}),
func(value int) int {
return value * 2
},
)

value, err := contextio.Run(context.Background(), effect)
```

All execution passes through cancellation and panic-recovery boundaries. A nil
context and a zero `Effect` fail with shared deterministic errors. When an error
is returned, the result is always the zero value of its type.

Cancellation is checked before invoking a subsequent effect or user
transformation. Cancellation is cooperative: synchronous user work must observe
its context and return before execution can terminate.

## Core operations

The package provides:

- `Succeed`, `Fail`, and `Eval` for constructing effects;
- `Defer` for lazy effect construction;
- `Map` and `MapErr` for transforming successful values;
- `FlatMap` for sequencing dependent effects;
- `Fold` for handling success and failure with effects; and
- `Sleep` for a context-aware delay.

Operations that change the result type are top-level generic functions because
Go 1.18 does not support methods with additional type parameters.

Panics crossing an effect boundary become `PanicError` values. `PanicError`
retains the recovered value but does not capture a stack trace.

## Asynchronous effects

`Async` adapts callback-based work:

```go
effect := contextio.Async(func(
ctx context.Context,
callback contextio.Callback[int],
) contextio.Canceler {
go func() {
callback(42, nil)
}()
return func() {
// Request cancellation of the registered operation.
}
})
```

Registration is lazy and runs when the effect is executed. The callback may
complete synchronously or asynchronously. Only the first callback invocation is
observed; duplicate callbacks return without blocking.

If context cancellation wins, `Async` invokes the canceler once and waits for
the callback to publish terminal completion. The context error remains the
result even if the callback later reports success. A nil canceler is a no-op.

Registration must return promptly. Registration that blocks, or asynchronous
work that never publishes completion after cancellation, can block execution
indefinitely.

## Fibers

`Start` runs an effect in one goroutine under a child context and returns a
`Fiber[A]`:

```go
fiber, err := contextio.Run(ctx, contextio.Start(effect))
if err != nil {
return err
}

value, err := contextio.Run(ctx, fiber.Join())
```

`Join` may be executed by any number of current or future joiners. Every joiner
observes the same immutable terminal outcome.

`Cancel` is idempotent. It requests child cancellation and waits for the work
goroutine to publish its terminal outcome. If cancellation wins before normal
completion, joins return the child context error. Canceling the context used to
run `Join` stops only that join operation; it does not cancel the fiber.

## Timeout and race

`Timeout` runs an effect with a derived deadline. `Race` runs a slice of effects
and returns the first observed terminal result, whether success or failure.

Both operations provide strict structured cleanup:

1. select a terminal winner;
2. cancel every loser; and
3. wait for every loser to publish terminal completion before returning.

No result-publishing goroutine is silently detached. Consequently, a loser that
ignores cancellation can delay timeout or race completion indefinitely.

An empty race fails with `ErrEmptyRace`. No fairness is guaranteed between
competitors that become ready at the same time; winner selection follows
terminal publication observed by the shared result channel.

## Resource finalization

`Bracket` combines acquisition, use, and release:

```go
effect := contextio.Bracket(
acquire,
func(resource Resource) contextio.Effect[Result] {
return use(resource)
},
func(resource Resource, exit contextio.ExitCase) contextio.Effect[contextio.Unit] {
return release(resource, exit)
},
)
```

Release does not run if acquisition fails before producing a resource. After a
successful acquisition, release runs exactly once after use success, failure,
panic, or cancellation. Nested brackets release resources in reverse
acquisition order.

Release runs with cancellation masked: context values remain available, but the
release context has no deadline, done channel, or cancellation error. This
allows cleanup to finish after use cancellation, but a blocked release can also
delay completion indefinitely.

`ExitCase` reports successful, errored, or canceled use. If use and release both
fail, `CombinedError` keeps the use failure as the primary error and exposes the
release failure as `Secondary`. Its `Unwrap` method returns the primary error.

## Current IO adapters

`FromIO` and `ToIO` provide minimal lazy adapters for the repository's current
`io.IO[A]` type:

- `FromIO` runs the current IO through its synchronous execution boundary. The
wrapped IO does not receive a context, so cancellation cannot interrupt it and
waits until it returns.
- `ToIO` captures an explicit context and runs the context-aware effect when the
returned current IO is executed.

The package does not provide implicit background-context, fiber, resource,
stream, channel, pool, or execution-context adapters.

## Known limitations

- Composition uses ordinary recursive Go calls. Map and flat-map chains have
been tested at bounded depths of 10,000 and 50,000, but the package does not
guarantee unbounded stack safety.
- Cancellation cannot interrupt context-ignoring synchronous work.
- Strict cancellation waits can block indefinitely when asynchronous work,
fiber work, race losers, timeout work, or release logic is not cooperative.
- Race scheduling does not provide fairness among simultaneously ready
competitors.
- `PanicError` does not capture a stack trace.
- Adapting current IO preserves laziness and error boundaries but cannot add
cooperative cancellation to context-free work.
- Linux and Windows behavior depends on the repository CI matrix; local
development validation alone does not provide execution evidence for those
systems.

## Tests and benchmarks

The package includes deterministic behavioral and race-enabled tests for core
effects, asynchronous completion, fibers, timeout, race, bracket finalization,
adapters, bounded composition depth, and manually released non-cooperative work.

Run the package tests with:

```sh
go test ./experimental/contextio
go test -race ./experimental/contextio
```

Comparative microbenchmarks are defined in `effect_bench_test.go`:

```sh
go test -run '^$' -bench . -benchmem ./experimental/contextio
```

Benchmark results are machine- and toolchain-dependent and are not used as
fixed performance thresholds.
31 changes: 31 additions & 0 deletions experimental/contextio/adapter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package contextio

import (
"context"

currentio "github.com/primetalk/goio/io"
)

// FromIO lazily adapts a current IO to an Effect. Current IO does not accept a
// context, so cancellation remains blocked until the wrapped IO returns.
func FromIO[A any](effect currentio.IO[A]) Effect[A] {
return Effect[A]{run: func(ctx context.Context) (A, error) {
value, err := currentio.UnsafeRunSync(effect)
if err != nil {
var zero A
return zero, err
}
if err := ctx.Err(); err != nil {
var zero A
return zero, err
}
return value, nil
}}
}

// ToIO lazily adapts an Effect to current IO using an explicit captured context.
func ToIO[A any](ctx context.Context, effect Effect[A]) currentio.IO[A] {
return currentio.Eval(func() (A, error) {
return Run(ctx, effect)
})
}
80 changes: 80 additions & 0 deletions experimental/contextio/adapter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package contextio_test

import (
"context"
"errors"
"testing"

"github.com/primetalk/goio/experimental/contextio"
currentio "github.com/primetalk/goio/io"
)

func TestAdaptersAreLazyAndPropagateResults(t *testing.T) {
var fromCalls atomicInt32
from := contextio.FromIO(currentio.Eval(func() (int, error) {
fromCalls.Add(1)
return 42, nil
}))
if fromCalls.Load() != 0 {
t.Fatal("FromIO executed eagerly")
}
value, err := contextio.Run(context.Background(), from)
if err != nil || value != 42 || fromCalls.Load() != 1 {
t.Fatalf("FromIO = (%d, %v), calls %d", value, err, fromCalls.Load())
}

type key struct{}
ctx := context.WithValue(context.Background(), key{}, 7)
var toCalls atomicInt32
to := contextio.ToIO(ctx, contextio.Eval(func(ctx context.Context) (int, error) {
toCalls.Add(1)
return ctx.Value(key{}).(int), nil
}))
if toCalls.Load() != 0 {
t.Fatal("ToIO executed eagerly")
}
value, err = currentio.UnsafeRunSync(to)
if err != nil || value != 7 || toCalls.Load() != 1 {
t.Fatalf("ToIO = (%d, %v), calls %d", value, err, toCalls.Load())
}
}

func TestAdaptersPropagateErrorsAndPanics(t *testing.T) {
wantErr := errors.New("adapter")
value, err := contextio.Run(context.Background(), contextio.FromIO(currentio.Fail[int](wantErr)))
if value != 0 || !errors.Is(err, wantErr) {
t.Fatalf("FromIO failure = (%d, %v)", value, err)
}

_, err = contextio.Run(context.Background(), contextio.FromIO(currentio.Eval(func() (int, error) {
panic("current IO")
})))
if err == nil {
t.Fatal("FromIO panic was not converted to an error")
}

_, err = currentio.UnsafeRunSync(contextio.ToIO(context.Background(), contextio.Eval(func(context.Context) (int, error) {
panic("context effect")
})))
assertPanicError(t, err)
}

func TestFromIONonCooperativeCancellationWaitsForCompletion(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
current := currentio.Eval(func() (int, error) {
close(started)
<-release
return 42, nil
})
ctx, cancel := context.WithCancel(context.Background())
result := runIntAsyncContext(ctx, contextio.FromIO(current))
receiveSignal(t, started)
cancel()
assertNoResult(t, result)
close(release)
got := receive(t, result)
if got.value != 0 || !errors.Is(got.err, context.Canceled) {
t.Fatalf("canceled FromIO = (%d, %v)", got.value, got.err)
}
}
Loading