diff --git a/.github/workflows/Go.yaml b/.github/workflows/Go.yaml index e8e538e..80888cb 100644 --- a/.github/workflows/Go.yaml +++ b/.github/workflows/Go.yaml @@ -8,6 +8,9 @@ on: # Allows you to run this workflow manually from the Actions tab workflow_dispatch: +permissions: + contents: write # needed for updating badge with coverage + jobs: build: @@ -15,30 +18,54 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] - go: [1.18] + go: [1.18, 1.26] steps: - - uses: actions/checkout@v2 + - name: Check out PR branch + uses: actions/checkout@v5 with: - token: ${{ secrets.PAT }} + ref: ${{ github.head_ref || github.ref_name }} + fetch-depth: 0 - - name: Set up Go - uses: actions/setup-go@v2 + - name: Set up Go ${{ matrix.go }} + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go }} + - name: Build + run: go build -v ./... + - name: Test run: | go test -v -cover ./... -coverprofile coverage.out -coverpkg ./... go tool cover -func coverage.out -o coverage.out # Replaces coverage.out with the analysis of coverage.out - name: Go Coverage Badge - uses: tj-actions/coverage-badge-go@v1 + uses: tj-actions/coverage-badge-go@v3 if: ${{ runner.os == 'Linux' && matrix.go == '1.18' }} # Runs this on only one of the ci builds. with: green: 80 filename: coverage.out - # - uses: stefanzweifel/git-auto-commit-action@v4 + - name: Verify Changed files + uses: tj-actions/verify-changed-files@v16 + id: verify-changed-files + with: + files: README.md + + - name: Commit changes + if: steps.verify-changed-files.outputs.files_changed == 'true' + run: | + git config --local user.email "action@github.com" + git config --local user.name "GitHub Action" + git add README.md + git commit -m "chore: Updated coverage badge." + + - name: Push changes + if: steps.verify-changed-files.outputs.files_changed == 'true' + uses: ad-m/github-push-action@master + with: + github_token: ${{ github.token }} + branch: ${{ github.head_ref || github.ref_name }} # - uses: stefanzweifel/git-auto-commit-action@v4 # id: auto-commit-action # with: # commit_message: Apply Code Coverage Badge diff --git a/.github/workflows/codecov.yaml b/.github/workflows/codecov.yaml index 97fc464..55bd59b 100644 --- a/.github/workflows/codecov.yaml +++ b/.github/workflows/codecov.yaml @@ -7,15 +7,15 @@ jobs: name: codecov runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v5 with: fetch-depth: 2 - name: Set up Go 1.18 - uses: actions/setup-go@v2 + uses: actions/setup-go@v6 with: go-version: 1.18 id: go - name: Run coverage run: go test -race -coverprofile=coverage.out -covermode=atomic ./... - name: Upload coverage to Codecov - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v5 diff --git a/.github/workflows/codeql-analysis.yaml b/.github/workflows/codeql-analysis.yaml index 6d238c8..95a415f 100644 --- a/.github/workflows/codeql-analysis.yaml +++ b/.github/workflows/codeql-analysis.yaml @@ -28,18 +28,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v5 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v4 with: languages: go # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below). - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -53,4 +53,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v4 diff --git a/README.md b/README.md index 63da878..c7b65c6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # Implementation of IO, Stream, Fiber using go1.18 generics -![Coverage](https://img.shields.io/badge/Coverage-86.9%25-brightgreen) +![Coverage](https://img.shields.io/badge/Coverage-90.2%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) @@ -118,9 +118,11 @@ The composition of two consequtive calculations is fundamental to programming. T From error handling perspective `IO[A]` provides the following features: - encapsulates a calculation that may return `A` or might fail; -- it never panics, all panics are wrapped into `error`s and presented for handling; +- execution through `io.UnsafeRunSync`, `io.RunSync`, or `io.ObtainResult` recovers panics that cross that run boundary and returns them as errors; - provides convenient mechanisms for composing consequtive calculations (`io.Map`, `io.FlatMap`). +`IO[A]` is an exported Go function type, so directly calling an `IO` value is an ordinary function call and does not establish a recovering run boundary. Code that starts its own goroutines is likewise responsible for recovering or publishing panics from those goroutines. Library-started fibers execute their work through `io.UnsafeRunSync`, so a work panic is observable as an error from `Fiber.Join`. + ### Interaction with outer world vs simple (pure) functions/calculations. From compiler's perspective things that happen in the program can be either ordinary pure computations or modification of some state outside of the function. Pure computation is special, because it has the following benefits: @@ -132,17 +134,17 @@ The ability to understand and reason about programs is crucial to the ability of Unfortunately all these nice and desired properties break when there are so called "side effects" - change of state, outer world interaction, ... - all things that make the computation to produce a different effect (and probably return different function results) even being called with the same arguments. -`IO[A]` provides a mechanism to arrange these side-effectful computations in such a way that it's easier to predict what is happening in the program. The main feature is the delay of actual effect execution until the late moment possible. A typical IO-based program does not perform any action until it is executed. It's often possible to construct the whole large computation for a complex program and only after that perform the execution. +`IO[A]` provides a mechanism to arrange these side-effectful computations in such a way that it's easier to predict what is happening in the program. The main feature is delaying actual effect execution until an explicit run boundary. `io.Eval`, `io.Delay`, `io.Pure`, `io.LiftFunc`, `io.Map`, `io.FlatMap`, `io.ForEach`, and `io.Async` registration defer their user functions until the returned IO is executed. It's often possible to construct a whole large computation and only then execute it. ### Construction To construct an IO one may use the following functions: - `io.Lift[A any](a A) IO[A]` - lifts a plain value to IO -- `io.LiftFunc[A any, B any](f func(A) B) func(A) IO[B]` - LiftFunc wraps the result of function into IO. +- `io.LiftFunc[A any, B any](f func(A) B) func(A) IO[B]` - LiftFunc converts `f` into a function whose application constructs a lazy IO. The original function is invoked only when that IO is executed. A panic from `f` is returned as an error when execution uses a recovering run boundary such as `io.UnsafeRunSync`. - `io.Fail[A any](err error) IO[A]` - lifts an error to IO - `io.FromConstantGoResult[A any](gr GoResult[A]) IO[A]` - FromConstantGoResult converts an existing GoResult value into an IO. Important! This is not for normal delayed IO execution. It cannot provide any guarantee for the moment when this go result was evaluated in the first place. This is just a combination of Lift and Fail. -- `io.Eval[A any](func () (A, error)) IO[A]` - lifts an arbitrary computation. Panics are handled and represented as errors. +- `io.Eval[A any](func () (A, error)) IO[A]` - lifts and delays an arbitrary computation. Panics crossing a recovering run boundary are represented as errors. - `io.FromPureEffect(f func())IO[fun.Unit]` - FromPureEffect constructs IO from the simplest function signature. - `io.Delay[A any](f func()IO[A]) IO[A]` - represents a function as a plain IO - `io.Fold[A any, B any](io IO[A], f func(a A)IO[B], recover func (error)IO[B]) IO[B]` - handles both happy and sad paths. @@ -184,11 +186,13 @@ type Consumer[A any] func(A) IOUnit ### Execution -To finally run all constructed computations one may use `UnsafeRunSync` or `ForEach`: +To run a constructed computation synchronously, use `UnsafeRunSync` or `RunSync`: + +- `io.UnsafeRunSync[A any](ioa IO[A])` - executes the IO and returns its value and error; panics crossing this boundary are recovered as errors. +- `io.RunSync[A any](io IO[A]) GoResult[A]` - executes through `UnsafeRunSync` and returns the outcome as `GoResult`. +- `io.ObtainResult[A any](c Continuation[A]) (A, error)` - lower-level interpreter boundary for explicit continuation chains; it also recovers panics crossing the boundary. -- `io.UnsafeRunSync[A any](ioa IO[A])` -- `io.ForEach[A any](io IO[A], cb func(a A))IO[fun.Unit]` - ForEach calls the provided callback after IO is completed. -- `io.RunSync[A any](io IO[A]) GoResult[A]` - RunSync is the same as UnsafeRunSync but returns GoResult. +`io.ForEach[A any](io IO[A], cb func(a A)) IO[fun.Unit]` is not an execution boundary. It constructs another lazy IO that invokes the callback only after the source IO succeeds and the returned IO is executed. ### Auxiliary functions @@ -196,11 +200,16 @@ To finally run all constructed computations one may use `UnsafeRunSync` or `ForE ### Implementation details -IO might be implemented in various ways. Here we implement IO using continuations. A simple step in the constructed IO program might either complete (returning a result or an error), or return a continuation - another execution of the same kind. In order to obtain result we should execute the returned function. -Continuations help avoiding deeply nested stack traces. It's a universal way to do "trampolining". +IO might be implemented in various ways. Here we implement IO using continuations. A simple step in the constructed IO program might either complete (returning a result or an error), or return a continuation—another execution of the same kind. In order to obtain a result, the continuation is executed by the run boundary. + +`ObtainResult` evaluates explicit continuation chains iteratively. The current composition combinators are not a Cats-style single bind interpreter: left-associated `FlatMap`, `Map`, and `Sequence` programs can enter nested `ObtainResult` calls. This project supports and tests these composition forms at a bounded depth of 10000, including failure propagation. This is a practical compatibility guarantee, not a claim of unlimited stack safety; callers should avoid assuming arbitrarily deep composition is safe. - `type Continuation[A any] func() ResultOrContinuation[A]` - Continuation represents some multistep computation. Here `ResultOrContinuation[A]` is either a final result (value or error) or another continuation. -- `io.ObtainResult[A any](c Continuation[A]) (res A, err error)` - ObtainResult executes continuation until final result is obtained. There is `io.MaxContinuationDepth` variable that allows to limit the depth of continuation executions. Default value is 1000000000000. +- `io.ObtainResult[A any](c Continuation[A]) (res A, err error)` - ObtainResult executes an explicit continuation chain until a final result is obtained. The mutable `io.MaxContinuationDepth` variable limits continuation-function invocations; its current default is 1,000,000 and each execution snapshots it once. A final result on the last allowed invocation succeeds. Zero or negative values execute no continuation functions and return a limit error. Nil initial or intermediate continuations return an error. + +`io.MaxContinuationDepth` remains an exported mutable variable for compatibility. Configure it before starting concurrent IO execution and do not mutate it concurrently: external writes are unsynchronized and can race with the snapshot read at a run boundary. The limit is a safety ceiling, not fairness, yielding, or cancellation. + +In the current composition architecture, `MapErr`, `FlatMap`, and `Fold` may start nested `ObtainResult` interpreter executions. Each such interpreter execution has one stable limit snapshot, but a concurrent external write may affect a later nested execution. Eliminating nested interpreters requires the larger instruction-tree runtime redesign. ## Resources @@ -227,28 +236,25 @@ type ClosableIO interface { ## Parallel computing -Go routine is represented using the `Fiber[A]` interface: +Running work is observed using the `Fiber[A]` interface. A fiber publishes one terminal observation: either the underlying work result or observation shutdown, whichever wins first. ```go type Fiber[A any] interface { // Join waits for results of the fiber. - // When fiber completes, this IO will complete and return the result. - // After this fiber is closed, all join IOs fail immediately. + // When work completes before observation is closed, Join returns its result. + // When Close wins first, current and future joins fail with ErrorFiberClosed. Join() IO[A] - // Closes the fiber and stops sending callbacks. - // After closing, the respective go routine may complete - // This is not Cancel, it does not send any signals to the fiber. - // The work will still be done. + // Close shuts down observation of an incomplete fiber. + // It wakes current joiners, affects future joins, and is idempotent. + // Close does not cancel or stop the underlying work. Close() IO[fun.Unit] - // Cancel sends cancellation signal to the Fiber. - // If the fiber respects the signal, it'll stop. - // Yet to be implemented. - // Cancel() IO[Unit] } ``` -- `io.Start[A any](io IO[A]) IO[Fiber[A]]` - Start will start the IO in a separate go-routine. It'll establish a channel with callbacks, so that any number of listeners could join the returned fiber. When completed it'll start sending the results to the callbacks. The same value will be delivered to all listeners. -- `io.FireAndForget[A any](ioa IO[A]) IO[fun.Unit]` - FireAndForget runs the given IO in a go routine and ignores the result. It uses Fiber underneath. +`Close` is observation shutdown, not cancellation. If `Close` wins before work completion, all current and future joins fail with `io.ErrorFiberClosed`; the underlying goroutine continues independently and may still perform side effects. A late work result cannot replace the closed observation. If work completes first, later `Close` calls preserve that completed result. + +- `io.Start[A any](io IO[A]) IO[Fiber[A]]` - Start runs the IO in a separate go-routine and returns a handle through which any number of listeners can join the first terminal observation. +- `io.FireAndForget[A any](ioa IO[A]) IO[fun.Unit]` - FireAndForget starts the IO and closes observation of its result. It does not cancel the underlying work. - `io.FailedFiber[A any](err error) Fiber[A]` - FailedFiber creates a fiber that will fail on Join or Close with the given error. - `io.JoinWithTimeout[A any](f Fiber[A], d time.Duration) IO[A]` - JoinWithTimeout joins the given fiber and waits no more than the given duration. @@ -286,7 +292,7 @@ There are two kinds of execution contexts - `UnboundedExecutionContext` and `Bou - `io.Parallel[A any](ios []IO[A]) IO[[]A]` - Parallel starts the given IOs in Go routines and waits for all results. - `io.ParallelInExecutionContext[A any](ec ExecutionContext) func(ios []IO[A]) IO[[]A]` - ParallelInExecutionContext starts the given IOs in the provided `ExecutionContext` and waits for all results. -- `io.ConcurrentlyFirst[A any](ios []IO[A]) IO[A]` - ConcurrentlyFirst - runs all IOs in parallel. Returns the very first result. +- `io.ConcurrentlyFirst[A any](ios []IO[A]) IO[A]` - Runs all IOs in parallel and returns the first success or failure. Losing computations are not canceled; they continue independently, and their result publication does not block after the winner returns. - `io.PairSequentially[A any, B any](ioa IO[A], iob IO[B]) IO[fun.Pair[A, B]]` - PairSequentially runs two IOs sequentially and returns both results. - `io.PairParallel[A any, B any](ioa IO[A], iob IO[B]) IO[fun.Pair[A, B]]` - PairParallel runs two IOs in parallel and returns both results. - `io.RunAlso[A any](ioa IO[A], other IOUnit) IO[A]` - RunAlso runs the other IO in parallel, but returns only the result of the first IO. @@ -297,7 +303,7 @@ There are two kinds of execution contexts - `UnboundedExecutionContext` and `Bou - `io.Sleep(d time.Duration)IO[fun.Unit]` - Sleep makes the IO sleep the specified time. - `io.SleepA[A any](d time.Duration, value A)IO[A]` - SleepA sleeps and then returns the constant value - `var ErrorTimeout` - an error that will be returned in case of timeout -- `io.WithTimeout[A any](d time.Duration) func(ioa IO[A]) IO[A]` - WithTimeout waits IO for completion for no longer than the provided duration. If there are no results, the IO will fail with timeout error. +- `io.WithTimeout[A any](d time.Duration) func(ioa IO[A]) IO[A]` - Returns the IO result if it wins before the duration, otherwise fails with `io.ErrorTimeout`. Timeout stops waiting but does not cancel the losing IO, which may continue side effects independently. - `io.Never[A any]() IO[A]` - Never is a simple IO that never returns. - `io.Notify[A any](d time.Duration, value A, cb Callback[A]) IO[fun.Unit]` - Notify starts a separate thread that will call the given callback after the specified time. - `io.NotifyToChannel[A any](d time.Duration, value A, ch chan A) IO[fun.Unit]` - NotifyToChannel sends message to channel after specified duration. diff --git a/io/async.go b/io/async.go index 9407c46..7ac3a09 100644 --- a/io/async.go +++ b/io/async.go @@ -1,19 +1,27 @@ package io +import "sync" + // Callback[A] is a function that takes A and error. A is only valid if error is nil. type Callback[A any] func(A, error) // Async[A] constructs an IO given a function that will eventually call a callback. -// Internally this function creates a channel and blocks on it until the function calls it. +// Internally this function blocks the executing goroutine until the callback is called. +// Only the first callback invocation is observed; later invocations return without blocking. +// Async does not provide a cancellation token. +// Registration is delayed until execution. A registration panic becomes an +// error when execution occurs through a recovering run boundary. func Async[A any](k func(Callback[A])) IO[A] { return func() ResultOrContinuation[A] { - ch := make(chan ResultOrContinuation[A]) + ch := make(chan ResultOrContinuation[A], 1) + var once sync.Once cb := func(a A, err error) { - ch <- ResultOrContinuation[A]{ - Value: a, - Error: err, - } - close(ch) + once.Do(func() { + ch <- ResultOrContinuation[A]{ + Value: a, + Error: err, + } + }) } k(cb) res := <-ch diff --git a/io/async_test.go b/io/async_test.go new file mode 100644 index 0000000..07bc78b --- /dev/null +++ b/io/async_test.go @@ -0,0 +1,134 @@ +package io_test + +import ( + "errors" + "sync" + "testing" + "time" + + "github.com/primetalk/goio/io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const asyncTestTimeout = time.Second + +func runAsyncWithTimeout[A any](t *testing.T, ioa io.IO[A]) io.GoResult[A] { + t.Helper() + + result := make(chan io.GoResult[A], 1) + go func() { + value, err := io.UnsafeRunSync(ioa) + result <- io.GoResult[A]{Value: value, Error: err} + }() + + select { + case res := <-result: + return res + case <-time.After(asyncTestTimeout): + t.Fatal("timed out waiting for Async completion") + return io.GoResult[A]{} + } +} + +func TestAsyncSynchronousCallback(t *testing.T) { + ioa := io.Async(func(cb io.Callback[int]) { + cb(42, nil) + }) + + res := runAsyncWithTimeout(t, ioa) + require.NoError(t, res.Error) + assert.Equal(t, 42, res.Value) +} + +func TestAsyncAsynchronousCallback(t *testing.T) { + registered := make(chan struct{}) + release := make(chan struct{}) + ioa := io.Async(func(cb io.Callback[int]) { + go func() { + close(registered) + <-release + cb(42, nil) + }() + }) + + result := make(chan io.GoResult[int], 1) + go func() { + value, err := io.UnsafeRunSync(ioa) + result <- io.GoResult[int]{Value: value, Error: err} + }() + + select { + case <-registered: + case <-time.After(asyncTestTimeout): + t.Fatal("timed out waiting for Async registration") + } + close(release) + + select { + case res := <-result: + require.NoError(t, res.Error) + assert.Equal(t, 42, res.Value) + case <-time.After(asyncTestTimeout): + t.Fatal("timed out waiting for asynchronous callback") + } +} + +func TestAsyncSequentialDuplicateCallbacksUseFirstResult(t *testing.T) { + secondReturned := false + ioa := io.Async(func(cb io.Callback[int]) { + cb(1, nil) + cb(2, errors.New("ignored")) + secondReturned = true + }) + + res := runAsyncWithTimeout(t, ioa) + require.NoError(t, res.Error) + assert.Equal(t, 1, res.Value) + assert.True(t, secondReturned) +} + +func TestAsyncConcurrentDuplicateCallbacksCompleteOnce(t *testing.T) { + const callbackCount = 32 + + callbacksReturned := make(chan struct{}) + ioa := io.Async(func(cb io.Callback[int]) { + start := make(chan struct{}) + var callbacks sync.WaitGroup + callbacks.Add(callbackCount) + for value := 0; value < callbackCount; value++ { + value := value + go func() { + defer callbacks.Done() + <-start + cb(value, nil) + }() + } + close(start) + go func() { + callbacks.Wait() + close(callbacksReturned) + }() + }) + + res := runAsyncWithTimeout(t, ioa) + require.NoError(t, res.Error) + assert.GreaterOrEqual(t, res.Value, 0) + assert.Less(t, res.Value, callbackCount) + + select { + case <-callbacksReturned: + case <-time.After(asyncTestTimeout): + t.Fatal("duplicate callbacks did not return") + } +} + +func TestAsyncRegistrationPanicBecomesRunError(t *testing.T) { + ioa := io.Async(func(io.Callback[int]) { + panic("registration failed") + }) + + res := runAsyncWithTimeout(t, ioa) + require.Error(t, res.Error) + assert.Contains(t, res.Error.Error(), "registration failed") +} diff --git a/io/composition_depth_test.go b/io/composition_depth_test.go new file mode 100644 index 0000000..5564ac1 --- /dev/null +++ b/io/composition_depth_test.go @@ -0,0 +1,72 @@ +package io_test + +import ( + "errors" + "testing" + + "github.com/primetalk/goio/io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const supportedCompositionDepth = 10_000 + +func TestFlatMapSupportsBoundedLeftAssociatedDepth(t *testing.T) { + program := io.Lift(0) + for step := 0; step < supportedCompositionDepth; step++ { + program = io.FlatMap(program, func(value int) io.IO[int] { + return io.Lift(value + 1) + }) + } + + result, err := io.UnsafeRunSync(program) + + require.NoError(t, err) + assert.Equal(t, supportedCompositionDepth, result) +} + +func TestMapSupportsBoundedLeftAssociatedDepth(t *testing.T) { + program := io.Lift(0) + for step := 0; step < supportedCompositionDepth; step++ { + program = io.Map(program, func(value int) int { + return value + 1 + }) + } + + result, err := io.UnsafeRunSync(program) + + require.NoError(t, err) + assert.Equal(t, supportedCompositionDepth, result) +} + +func TestSequenceSupportsBoundedDepth(t *testing.T) { + programs := make([]io.IO[int], supportedCompositionDepth) + for index := range programs { + programs[index] = io.Lift(index) + } + + result, err := io.UnsafeRunSync(io.Sequence(programs)) + + require.NoError(t, err) + require.Len(t, result, supportedCompositionDepth) + for index, value := range result { + assert.Equal(t, index, value) + } +} + +func TestMapPropagatesFailureAtBoundedDepth(t *testing.T) { + expectedErr := errors.New("deep composition failed") + mappingCalls := 0 + program := io.Fail[int](expectedErr) + for step := 0; step < supportedCompositionDepth; step++ { + program = io.Map(program, func(value int) int { + mappingCalls++ + return value + 1 + }) + } + + _, err := io.UnsafeRunSync(program) + + require.ErrorIs(t, err, expectedErr) + assert.Zero(t, mappingCalls) +} diff --git a/io/continuation.go b/io/continuation.go index 11fd44e..e0ecb9b 100644 --- a/io/continuation.go +++ b/io/continuation.go @@ -7,8 +7,12 @@ import ( "github.com/primetalk/goio/fun" ) -// Continuation represents some multistep computation. -// It is being used to avoid stack overflow. It's a universal way to do "trampolining". +// Continuation represents one step of a multistep computation. +// ObtainResult evaluates explicit continuation chains iteratively. Composition +// combinators may still enter nested ObtainResult calls, so this representation +// is not an unbounded, single-interpreter stack-safety guarantee. +// It is being used to reduce risk of stack overflow. +// It's a universal way to do "trampolining" within goio. type Continuation[A any] func() ResultOrContinuation[A] // ResultOrContinuation is either a final result (value or error) or another continuation. @@ -18,17 +22,33 @@ type ResultOrContinuation[A any] struct { Continuation *Continuation[A] } -// MaxContinuationDepth is equal to 1000000000. It's the maximum depth we run continuation before giving up. -var MaxContinuationDepth = 1000000000 +// MaxContinuationDepth is the default maximum number of continuation functions +// that ObtainResult invokes before giving up. Its initial value is 1,000,000. +// ObtainResult snapshots this value once at the start of each execution. +// +// MaxContinuationDepth remains mutable for compatibility. Callers must +// configure it before concurrent execution starts; reads and external writes +// are not synchronized and concurrent mutation is a data race. +var MaxContinuationDepth = 1_000_000 -// ObtainResult executes continuation until final result is obtained. +var nilContinuationIsBeingEnforced = errors.New("nil continuation is being enforced") + +// ObtainResult executes continuation functions until a final result is obtained. +// The current MaxContinuationDepth value is captured once per execution. Zero +// and negative limits execute no continuation functions and return a limit +// error. A nil initial or intermediate continuation returns an error. func ObtainResult[A any](c Continuation[A]) (res A, err error) { defer fun.RecoverToErrorVar("ObtainResult", &err) if c == nil { - err = errors.New("nil continuation is being enforced") + err = nilContinuationIsBeingEnforced } else { + limit := MaxContinuationDepth cont := c - for i := 0; i < MaxContinuationDepth; i++ { + for i := 0; i < limit; i++ { + if cont == nil { + err = nilContinuationIsBeingEnforced + return + } contResult := cont() if contResult.Continuation == nil { res = contResult.Value @@ -36,9 +56,13 @@ func ObtainResult[A any](c Continuation[A]) (res A, err error) { return } else { cont = *contResult.Continuation + if cont == nil { + err = nilContinuationIsBeingEnforced + return + } } } - err = fmt.Errorf("couldn't enforce continuation in %d iterations", MaxContinuationDepth) + err = fmt.Errorf("couldn't enforce continuation in %d iterations", limit) } return } diff --git a/io/continuation_limit_test.go b/io/continuation_limit_test.go new file mode 100644 index 0000000..efb0957 --- /dev/null +++ b/io/continuation_limit_test.go @@ -0,0 +1,131 @@ +package io + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func withContinuationLimit(t *testing.T, limit int) { + t.Helper() + + previous := MaxContinuationDepth + MaxContinuationDepth = limit + t.Cleanup(func() { + MaxContinuationDepth = previous + }) +} + +func twoStepContinuation(value int) Continuation[int] { + final := Continuation[int](func() ResultOrContinuation[int] { + return ResultOrContinuation[int]{Value: value} + }) + return func() ResultOrContinuation[int] { + return ResultOrContinuation[int]{Continuation: &final} + } +} + +func TestObtainResultSucceedsAtExactContinuationLimit(t *testing.T) { + withContinuationLimit(t, 2) + + result, err := ObtainResult(twoStepContinuation(42)) + + require.NoError(t, err) + assert.Equal(t, 42, result) +} + +func TestObtainResultFailsWhenContinuationLimitIsExceeded(t *testing.T) { + withContinuationLimit(t, 1) + + _, err := ObtainResult(twoStepContinuation(42)) + + require.Error(t, err) + assert.Equal(t, "couldn't enforce continuation in 1 iterations", err.Error()) +} + +func TestObtainResultRejectsNilInitialContinuation(t *testing.T) { + withContinuationLimit(t, 1) + + _, err := ObtainResult[int](nil) + + require.Error(t, err) + assert.Equal(t, "nil continuation is being enforced", err.Error()) +} + +func TestObtainResultRejectsNilIntermediateContinuation(t *testing.T) { + withContinuationLimit(t, 2) + var nilContinuation Continuation[int] + initial := Continuation[int](func() ResultOrContinuation[int] { + return ResultOrContinuation[int]{Continuation: &nilContinuation} + }) + + _, err := ObtainResult(initial) + + require.Error(t, err) + assert.Equal(t, "nil continuation is being enforced", err.Error()) +} + +func TestObtainResultZeroLimitDoesNotInvokeContinuation(t *testing.T) { + withContinuationLimit(t, 0) + invocations := 0 + continuation := Continuation[int](func() ResultOrContinuation[int] { + invocations++ + return ResultOrContinuation[int]{Value: 42} + }) + + _, err := ObtainResult(continuation) + + require.Error(t, err) + assert.Contains(t, err.Error(), "0 iterations") + assert.Zero(t, invocations) +} + +func TestObtainResultNegativeLimitDoesNotInvokeContinuation(t *testing.T) { + withContinuationLimit(t, -1) + invocations := 0 + continuation := Continuation[int](func() ResultOrContinuation[int] { + invocations++ + return ResultOrContinuation[int]{Value: 42} + }) + + _, err := ObtainResult(continuation) + + require.Error(t, err) + assert.Contains(t, err.Error(), "-1 iterations") + assert.Zero(t, invocations) +} + +func TestObtainResultSnapshotsContinuationLimit(t *testing.T) { + withContinuationLimit(t, 2) + final := Continuation[int](func() ResultOrContinuation[int] { + return ResultOrContinuation[int]{Value: 42} + }) + initial := Continuation[int](func() ResultOrContinuation[int] { + MaxContinuationDepth = 0 + return ResultOrContinuation[int]{Continuation: &final} + }) + + result, err := ObtainResult(initial) + + require.NoError(t, err) + assert.Equal(t, 42, result) + assert.Zero(t, MaxContinuationDepth) +} + +func TestObtainResultLimitErrorUsesSnapshot(t *testing.T) { + withContinuationLimit(t, 1) + next := Continuation[int](func() ResultOrContinuation[int] { + return ResultOrContinuation[int]{Value: 42} + }) + initial := Continuation[int](func() ResultOrContinuation[int] { + MaxContinuationDepth = 99 + return ResultOrContinuation[int]{Continuation: &next} + }) + + _, err := ObtainResult(initial) + + require.Error(t, err) + assert.True(t, strings.HasSuffix(err.Error(), "1 iterations"), err.Error()) +} diff --git a/io/fiber.go b/io/fiber.go index 5178abf..b71242c 100644 --- a/io/fiber.go +++ b/io/fiber.go @@ -8,18 +8,26 @@ import ( "github.com/primetalk/goio/fun" ) -// Fiber[A] is a type safe representation of Go routine. -// One might Join() and receive the result of the go routine. -// After Close() subsequent joins will fail. +// ErrorFiberClosed indicates that observation of a fiber was closed before its work completed. +// Closing observation does not cancel or stop the underlying work. +var ErrorFiberClosed = errors.New("fiber observation is closed") + +// Fiber[A] is a type-safe handle for observing work running in a Go routine. +// Join returns the first terminal observation published by work completion or Close. type Fiber[A any] interface { // Join waits for results of the fiber. - // When fiber completes, this IO will complete and return the result. - // After this fiber is closed, all join IOs fail immediately. + // When work completes before observation is closed, Join returns its result. + // When Close wins first, current and future joins fail with ErrorFiberClosed. Join() IO[A] - // Closes the fiber and stops sending callbacks. - // After closing, the respective go routine may complete - // This is not Cancel, it does not send any signals to the fiber. - // The work will still be done. + // Close shuts down observation of an incomplete fiber. + // + // Close publishes ErrorFiberClosed to all current joiners and makes future + // joins fail with the same error. Close is idempotent. If work completed + // first, Close preserves the completed result. If Close completed first, + // later work completion is ignored by this observation handle. + // + // Close is not cancellation: it sends no signal to the underlying work, + // which continues independently and may still perform side effects. Close() IO[fun.Unit] // Cancel sends cancellation signal to the Fiber. // If the fiber respects the signal, it'll stop. @@ -35,28 +43,45 @@ type fiberImpl[A any] struct { callbacks []Callback[A] } +var _ Fiber[any] = (*fiberImpl[any])(nil) + +func (f *fiberImpl[A]) registerJoiner(cb Callback[A]) { + f.mu.Lock() + if f.result == nil { + f.callbacks = append(f.callbacks, cb) + f.mu.Unlock() + return + } + result := *f.result + f.mu.Unlock() + + cb(result.Value, result.Error) +} + +func (f *fiberImpl[A]) publishResult(result GoResult[A]) bool { + f.mu.Lock() + if f.result != nil { + f.mu.Unlock() + return false + } + f.result = &result + callbacks := f.callbacks + f.callbacks = nil + f.mu.Unlock() + + for _, cb := range callbacks { + cb(result.Value, result.Error) + } + return true +} + func (f *fiberImpl[A]) Join() IO[A] { - return Async(func(cb Callback[A]) { - f.mu.Lock() - defer f.mu.Unlock() - if f.result == nil { - f.callbacks = append(f.callbacks, cb) - } else { - // we run external function in a go routine just to make sure we are not locked forever - go cb(f.result.Value, f.result.Error) - } - }) + return Async(f.registerJoiner) } func (f *fiberImpl[A]) Close() IO[fun.Unit] { return FromPureEffect(func() { - f.mu.Lock() - defer f.mu.Unlock() - if f.result == nil { - f.result = &GoResult[A]{ - Error: errors.New("fiber is closed"), - } - } + f.publishResult(GoResult[A]{Error: ErrorFiberClosed}) }) } @@ -75,31 +100,23 @@ func StartInExecutionContext[A any](ec ExecutionContext) func(io IO[A]) IO[Fiber goRoutine := func() { defer fun.RecoverToLog("StartInExecutionContext.goRoutine") a, err1 := UnsafeRunSync(io) - fiber.mu.Lock() - fiber.result = &GoResult[A]{a, err1} - callbacks := fiber.callbacks - fiber.callbacks = []Callback[A]{} - fiber.mu.Unlock() - for _, cb := range callbacks { - cb(a, err1) - } + fiber.publishResult(GoResult[A]{Value: a, Error: err1}) } return Map(ec.Start(goRoutine), fun.ConstUnit[Fiber[A]](fiber)) }) } } -// Start will start the IO in a separate go-routine (actually in the global unbounded execution context). -// It'll establish a channel with callbacks, so that -// any number of listeners could join the returned fiber. -// When completed it'll start sending the results to the callbacks. -// The same value will be delivered to all listeners. +// Start executes the IO in a separate Go routine using the global unbounded +// execution context. Work panics are recovered by the fiber's UnsafeRunSync +// boundary and are observable as errors from Join. Any number of listeners can +// join the same first terminal observation. func Start[A any](io IO[A]) IO[Fiber[A]] { return StartInExecutionContext[A](globalUnboundedExecutionContext)(io) } -// FireAndForget runs the given IO in a go routine and ignores the result -// It uses Fiber underneath. +// FireAndForget starts the given IO and closes observation of its result. +// The underlying work continues independently; FireAndForget does not cancel it. func FireAndForget[A any](ioa IO[A]) IO[fun.Unit] { return FlatMap(Start(ioa), func(fiber Fiber[A]) IO[fun.Unit] { return fiber.Close() diff --git a/io/fiber_close_test.go b/io/fiber_close_test.go new file mode 100644 index 0000000..a4841a1 --- /dev/null +++ b/io/fiber_close_test.go @@ -0,0 +1,259 @@ +package io + +import ( + "runtime" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const fiberTestTimeout = time.Second + +func newPendingFiber[A any]() *fiberImpl[A] { + return &fiberImpl[A]{ + mu: &sync.Mutex{}, + callbacks: []Callback[A]{}, + } +} + +func runFiberJoin[A any](fiber Fiber[A]) <-chan GoResult[A] { + result := make(chan GoResult[A], 1) + go func() { + result <- RunSync(fiber.Join()) + }() + return result +} + +func awaitFiberResult[A any](t *testing.T, result <-chan GoResult[A]) GoResult[A] { + t.Helper() + + select { + case res := <-result: + return res + case <-time.After(fiberTestTimeout): + t.Fatal("timed out waiting for fiber result") + return GoResult[A]{} + } +} + +func awaitRegisteredJoiners[A any](t *testing.T, fiber *fiberImpl[A], count int) { + t.Helper() + + deadline := time.Now().Add(fiberTestTimeout) + for { + fiber.mu.Lock() + registered := len(fiber.callbacks) + fiber.mu.Unlock() + if registered == count { + return + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for %d registered joiners; got %d", count, registered) + } + runtime.Gosched() + } +} + +func closeFiber[A any](t *testing.T, fiber Fiber[A]) { + t.Helper() + + result := RunSync(fiber.Close()) + require.NoError(t, result.Error) +} + +func assertFiberClosed[A any](t *testing.T, result GoResult[A]) { + t.Helper() + + assert.ErrorIs(t, result.Error, ErrorFiberClosed) +} + +func TestFiberCloseBeforeCompletionWithNoCurrentJoiners(t *testing.T) { + fiber := newPendingFiber[int]() + + closeFiber(t, Fiber[int](fiber)) + + assertFiberClosed(t, awaitFiberResult(t, runFiberJoin[int](Fiber[int](fiber)))) +} + +func TestFiberCloseWakesOneCurrentJoiner(t *testing.T) { + fiber := newPendingFiber[int]() + join := runFiberJoin[int](Fiber[int](fiber)) + awaitRegisteredJoiners(t, fiber, 1) + + closeFiber(t, Fiber[int](fiber)) + + assertFiberClosed(t, awaitFiberResult(t, join)) + assertFiberClosed(t, awaitFiberResult(t, runFiberJoin[int](Fiber[int](fiber)))) +} + +func TestFiberCloseWakesMultipleCurrentJoiners(t *testing.T) { + const joinerCount = 8 + + fiber := newPendingFiber[int]() + joins := make([]<-chan GoResult[int], 0, joinerCount) + for i := 0; i < joinerCount; i++ { + joins = append(joins, runFiberJoin[int](fiber)) + } + awaitRegisteredJoiners(t, fiber, joinerCount) + + closeFiber[int](t, fiber) + + for _, join := range joins { + assertFiberClosed(t, awaitFiberResult(t, join)) + } + assertFiberClosed(t, awaitFiberResult(t, runFiberJoin[int](fiber))) +} + +func TestFiberCloseIsIdempotent(t *testing.T) { + fiber := newPendingFiber[int]() + + closeFiber[int](t, fiber) + closeFiber[int](t, fiber) + + assertFiberClosed(t, awaitFiberResult(t, runFiberJoin[int](fiber))) +} + +func TestFiberCompletionBeforeClosePreservesCompletedResult(t *testing.T) { + fiber := newPendingFiber[int]() + join := runFiberJoin[int](fiber) + awaitRegisteredJoiners(t, fiber, 1) + + require.True(t, fiber.publishResult(GoResult[int]{Value: 42})) + closeFiber[int](t, fiber) + + first := awaitFiberResult(t, join) + require.NoError(t, first.Error) + assert.Equal(t, 42, first.Value) + late := awaitFiberResult(t, runFiberJoin[int](fiber)) + require.NoError(t, late.Error) + assert.Equal(t, 42, late.Value) +} + +func TestFiberCloseBeforeCompletionIgnoresLateResult(t *testing.T) { + fiber := newPendingFiber[int]() + join := runFiberJoin[int](fiber) + awaitRegisteredJoiners(t, fiber, 1) + + closeFiber[int](t, fiber) + require.False(t, fiber.publishResult(GoResult[int]{Value: 42})) + + assertFiberClosed(t, awaitFiberResult(t, join)) + assertFiberClosed(t, awaitFiberResult(t, runFiberJoin[int](fiber))) +} + +func TestFiberCompletionAndCloseHaveOneTerminalWinner(t *testing.T) { + testCases := []struct { + name string + closeWins bool + expectedValue int + expectedClose bool + }{ + {name: "close wins", closeWins: true, expectedClose: true}, + {name: "completion wins", closeWins: false, expectedValue: 42}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + fiber := newPendingFiber[int]() + join := runFiberJoin[int](fiber) + awaitRegisteredJoiners(t, fiber, 1) + + closeRelease := make(chan struct{}) + completionRelease := make(chan struct{}) + closeDone := make(chan struct{}) + completionDone := make(chan bool, 1) + go func() { + <-closeRelease + RunSync(fiber.Close()) + close(closeDone) + }() + go func() { + <-completionRelease + completionDone <- fiber.publishResult(GoResult[int]{Value: 42}) + }() + + if testCase.closeWins { + close(closeRelease) + select { + case <-closeDone: + case <-time.After(fiberTestTimeout): + t.Fatal("timed out waiting for close") + } + close(completionRelease) + require.False(t, <-completionDone) + } else { + close(completionRelease) + require.True(t, <-completionDone) + close(closeRelease) + select { + case <-closeDone: + case <-time.After(fiberTestTimeout): + t.Fatal("timed out waiting for close") + } + } + + results := []GoResult[int]{ + awaitFiberResult(t, join), + awaitFiberResult(t, runFiberJoin[int](fiber)), + } + for _, result := range results { + if testCase.expectedClose { + assertFiberClosed(t, result) + } else { + require.NoError(t, result.Error) + assert.Equal(t, testCase.expectedValue, result.Value) + } + } + }) + } +} + +func TestFiberCallbacksRunOutsideMutex(t *testing.T) { + fiber := newPendingFiber[int]() + callbackDone := make(chan struct{}) + fiber.registerJoiner(func(int, error) { + RunSync(fiber.Close()) + close(callbackDone) + }) + + require.True(t, fiber.publishResult(GoResult[int]{Value: 42})) + + select { + case <-callbackDone: + case <-time.After(fiberTestTimeout): + t.Fatal("callback blocked while re-entering fiber") + } +} + +func TestFiberCloseDoesNotStopUnderlyingWork(t *testing.T) { + started := make(chan struct{}) + release := make(chan struct{}) + finished := make(chan struct{}) + work := Eval(func() (int, error) { + close(started) + <-release + close(finished) + return 42, nil + }) + fiberResult := RunSync(Start(work)) + require.NoError(t, fiberResult.Error) + fiber := fiberResult.Value + + select { + case <-started: + case <-time.After(fiberTestTimeout): + t.Fatal("timed out waiting for fiber work to start") + } + closeFiber(t, fiber) + close(release) + select { + case <-finished: + case <-time.After(fiberTestTimeout): + t.Fatal("underlying work did not continue after close") + } + + assertFiberClosed(t, awaitFiberResult(t, runFiberJoin[int](fiber))) +} diff --git a/io/goresult.go b/io/goresult.go index 859e685..5475c8e 100644 --- a/io/goresult.go +++ b/io/goresult.go @@ -21,10 +21,19 @@ func NewFailedGoResult[A any](err error) GoResult[A] { } } -// RunSync is the same as UnsafeRunSync but returns GoResult[A]. +// MakeGoResult constructs a GoResult from a value and an error. +// If there's an error, the value is ignored. +func MakeGoResult[A any](value A, err error) GoResult[A] { + if err != nil { + return NewFailedGoResult[A](err) + } + return NewGoResult(value) +} + +// RunSync executes io through the UnsafeRunSync panic-recovering boundary and +// returns its value or error as GoResult[A]. func RunSync[A any](io IO[A]) GoResult[A] { - a, err := UnsafeRunSync(io) - return GoResult[A]{Value: a, Error: err} + return MakeGoResult(UnsafeRunSync(io)) } // FromConstantGoResult converts an existing GoResult value into a fake IO. @@ -33,8 +42,10 @@ func FromConstantGoResult[A any](gr GoResult[A]) IO[A] { return Eval(func() (A, error) { return gr.Value, gr.Error }) } -// IOFuncToGoResult converts a function that returns IO -// to a function that will return GoResult. +// IOFuncToGoResult converts a function that returns IO to a function that runs +// that IO through RunSync and returns GoResult. Calling the returned function +// invokes f before the RunSync boundary; a panic from f itself is therefore not +// recovered by that boundary. func IOFuncToGoResult[A any, B any](f func(a A) IO[B]) func(A) GoResult[B] { return func(a A) GoResult[B] { return RunSync(f(a)) diff --git a/io/io.go b/io/io.go index f3970b4..a70bf35 100644 --- a/io/io.go +++ b/io/io.go @@ -1,4 +1,16 @@ -// Package io implements IO tools similar to what is available in Scala cats library (and Haskell IO). +// Package io provides lazy, composable effects inspired by Scala Cats and +// Haskell IO. +// +// Constructors and combinators normally describe work without executing user +// functions. UnsafeRunSync, RunSync, and ObtainResult are synchronous execution +// boundaries; panics that cross those boundaries are recovered and returned as +// errors. Directly invoking an IO function value is an ordinary Go call and is +// not protected by that recovery contract. +// +// The package does not currently provide cancellation or structured +// concurrency. Timeouts and first-result races stop waiting but do not stop +// losing work. Fiber.Close closes observation of a fiber; it does not cancel the +// underlying computation. package io import ( @@ -10,7 +22,10 @@ import ( // IO[A] represents a calculation that will yield a value of type A once executed. // The calculation might as well fail. -// It is designed to not panic ever. +// Constructors and combinators normally delay user work until an execution +// boundary. UnsafeRunSync and ObtainResult recover panics that cross those +// boundaries and return them as errors. Direct invocation of an IO function is +// an ordinary Go call and does not provide that recovery boundary. type IO[A any] Continuation[A] // LiftPair[A] constructs an IO from constant values. @@ -25,12 +40,14 @@ func LiftPair[A any](a A, err error) IO[A] { } // UnsafeRunSync runs the given IO[A] synchronously and returns the result. +// Panics that cross this execution boundary are recovered and returned as errors. func UnsafeRunSync[A any](io IO[A]) (res A, err error) { defer fun.RecoverToErrorVar("UnsafeRunSync", &err) return ObtainResult(Continuation[A](io)) } -// Delay[A] wraps a function that will then return an IO. +// Delay[A] defers invoking f until the returned IO is executed. +// Panics from f become errors when execution occurs through a recovering run boundary. func Delay[A any](f func() IO[A]) IO[A] { return func() ResultOrContinuation[A] { return f()() @@ -38,7 +55,8 @@ func Delay[A any](f func() IO[A]) IO[A] { } // Eval[A] constructs an IO[A] from a simple function that might fail. -// If there is panic in the function, it's recovered from and represented as an error. +// Eval does not invoke f during construction. Panics from f become errors when +// execution occurs through a recovering run boundary such as UnsafeRunSync. func Eval[A any](f func() (A, error)) IO[A] { return func() ResultOrContinuation[A] { a, err := f() @@ -50,6 +68,7 @@ func Eval[A any](f func() (A, error)) IO[A] { } // FromPureEffect constructs IO from the simplest function signature. +// It does not invoke f until the returned IO is executed. func FromPureEffect(f func()) IO[fun.Unit] { return func() ResultOrContinuation[fun.Unit] { f() @@ -57,7 +76,8 @@ func FromPureEffect(f func()) IO[fun.Unit] { } } -// FromUnit consturcts IO[fun.Unit] from a simple function that might fail. +// FromUnit constructs IO[fun.Unit] from a simple function that might fail. +// It does not invoke f until the returned IO is executed. func FromUnit(f func() error) IO[fun.Unit] { return func() ResultOrContinuation[fun.Unit] { return ResultOrContinuation[fun.Unit]{ @@ -67,6 +87,8 @@ func FromUnit(f func() error) IO[fun.Unit] { } // Pure[A] constructs an IO[A] from a function that cannot fail. +// It does not invoke f until the returned IO is executed. Panics from f become +// errors when execution occurs through a recovering run boundary. func Pure[A any](f func() A) IO[A] { return Eval(func() (A, error) { return f(), nil @@ -97,6 +119,8 @@ func MapErr[A any, B any](ioA IO[A], f func(a A) (B, error)) IO[B] { } // Map converts the IO[A] result using the provided function that cannot fail. +// It does not invoke f until ioA is executed successfully. Panics from f become +// errors when execution occurs through a recovering run boundary. func Map[A any, B any](ioA IO[A], f func(a A) B) IO[B] { return MapErr(ioA, func(a A) (B, error) { return f(a), nil }) } @@ -108,6 +132,8 @@ func MapConst[A any, B any](ioA IO[A], b B) IO[B] { // FlatMap converts the result of IO[A] using a function that itself returns an IO[B]. // It'll fail if any of IO[A] or IO[B] fail. +// It does not invoke f until ioA is executed successfully. Panics from f become +// errors when execution occurs through a recovering run boundary. func FlatMap[A any, B any](ioA IO[A], f func(a A) IO[B]) IO[B] { return func() ResultOrContinuation[B] { a, err := ObtainResult(Continuation[A](ioA)) @@ -145,10 +171,15 @@ func Lift[A any](a A) IO[A] { return LiftPair(a, nil) } -// LiftFunc wraps the result of function into IO. +// LiftFunc converts a function into one whose application constructs a lazy IO. +// Applying the returned function does not invoke f. The application is deferred +// until the resulting IO is executed, so panics from f become errors when +// execution occurs through a recovering run boundary such as UnsafeRunSync. func LiftFunc[A any, B any](f func(A) B) func(A) IO[B] { return func(a A) IO[B] { - return Lift(f(a)) + return Eval(func() (B, error) { + return f(a), nil + }) } } @@ -238,7 +269,8 @@ var IOUnit1 = Lift(fun.Unit1) // IOUnit is IO[Unit] type IOUnit = IO[fun.Unit] -// ForEach calls the provided callback after IO is completed. +// ForEach constructs an IO that calls cb after io completes successfully. +// It does not execute io or invoke cb until the returned IO is executed. func ForEach[A any](io IO[A], cb func(a A)) IO[fun.Unit] { return Map(io, func(a A) fun.Unit { cb(a) diff --git a/io/panic_boundaries_test.go b/io/panic_boundaries_test.go new file mode 100644 index 0000000..cd11fc1 --- /dev/null +++ b/io/panic_boundaries_test.go @@ -0,0 +1,141 @@ +package io_test + +import ( + "testing" + + "github.com/primetalk/goio/io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func requireRecoveredPanic(t *testing.T, err error, panicMessage string) { + t.Helper() + + require.Error(t, err) + assert.Contains(t, err.Error(), panicMessage) +} + +func TestLiftFuncApplicationIsLazy(t *testing.T) { + applications := 0 + lifted := io.LiftFunc(func(value int) int { + applications++ + return value + 1 + }) + require.Zero(t, applications) + + program := lifted(41) + require.Zero(t, applications) + + result, err := io.UnsafeRunSync(program) + require.NoError(t, err) + assert.Equal(t, 42, result) + assert.Equal(t, 1, applications) +} + +func TestLiftFuncRunBoundaryRecoversApplicationPanic(t *testing.T) { + executed := false + lifted := io.LiftFunc(func(int) int { + executed = true + panic("LiftFunc application panic") + }) + + program := lifted(1) + require.False(t, executed) + + _, err := io.UnsafeRunSync(program) + + assert.True(t, executed) + requireRecoveredPanic(t, err, "LiftFunc application panic") +} + +func TestEvalIsLazyAndRunBoundaryRecoversPanic(t *testing.T) { + executed := false + program := io.Eval(func() (int, error) { + executed = true + panic("Eval panic") + }) + require.False(t, executed) + + _, err := io.UnsafeRunSync(program) + + assert.True(t, executed) + requireRecoveredPanic(t, err, "Eval panic") +} + +func TestDelayIsLazyAndRunBoundaryRecoversPanic(t *testing.T) { + executed := false + program := io.Delay(func() io.IO[int] { + executed = true + panic("Delay panic") + }) + require.False(t, executed) + + _, err := io.UnsafeRunSync(program) + + assert.True(t, executed) + requireRecoveredPanic(t, err, "Delay panic") +} + +func TestMapIsLazyAndRunBoundaryRecoversMappingPanic(t *testing.T) { + executed := false + program := io.Map(io.Lift(1), func(int) int { + executed = true + panic("Map panic") + }) + require.False(t, executed) + + _, err := io.UnsafeRunSync(program) + + assert.True(t, executed) + requireRecoveredPanic(t, err, "Map panic") +} + +func TestFlatMapIsLazyAndRunBoundaryRecoversBindingPanic(t *testing.T) { + executed := false + program := io.FlatMap(io.Lift(1), func(int) io.IO[int] { + executed = true + panic("FlatMap panic") + }) + require.False(t, executed) + + _, err := io.UnsafeRunSync(program) + + assert.True(t, executed) + requireRecoveredPanic(t, err, "FlatMap panic") +} + +func TestAsyncRegistrationIsLazyAndRunBoundaryRecoversPanic(t *testing.T) { + executed := false + program := io.Async[int](func(io.Callback[int]) { + executed = true + panic("Async registration panic") + }) + require.False(t, executed) + + result := runAsyncWithTimeout(t, program) + + assert.True(t, executed) + requireRecoveredPanic(t, result.Error, "Async registration panic") +} + +func TestStartedFiberPublishesRecoveredWorkPanic(t *testing.T) { + work := io.Eval(func() (int, error) { + panic("started fiber panic") + }) + fiber, err := io.UnsafeRunSync(io.Start(work)) + require.NoError(t, err) + + result := runAsyncWithTimeout(t, fiber.Join()) + + requireRecoveredPanic(t, result.Error, "started fiber panic") +} + +func TestDirectIOInvocationRemainsOrdinaryGoCall(t *testing.T) { + program := io.Eval(func() (int, error) { + panic("direct invocation panic") + }) + + assert.PanicsWithValue(t, "direct invocation panic", func() { + _ = program() + }) +} diff --git a/io/parallel.go b/io/parallel.go index cb3ffcd..75eac19 100644 --- a/io/parallel.go +++ b/io/parallel.go @@ -25,13 +25,17 @@ func Parallel[A any](ios ...IO[A]) IO[[]A] { return ParallelInExecutionContext[A](globalUnboundedExecutionContext)(ios) } -// ConcurrentlyFirst - runs all IOs in parallel. -// returns the very first result. -// TODO: after obtaining result - cancel the other IOs. +// ConcurrentlyFirst runs all IOs in parallel and returns the first result, +// whether that result is successful or failed. +// +// ConcurrentlyFirst does not cancel losing computations. They continue +// independently after the winner is returned and may still perform side +// effects. Result publication is buffered for every competitor so losers do +// not block while publishing completion after the caller has received the +// winner. func ConcurrentlyFirst[A any](ios []IO[A]) IO[A] { channelIO := Pure(func() chan GoResult[A] { return make(chan GoResult[A], len(ios)) - // we will only read the very first response. Hence the other go routines could hang if sending to unbuffered channel }) return FlatMap(channelIO, func(channel chan GoResult[A]) IO[A] { ioSendToChannel := slice.Map(ios, func(ioa IO[A]) IO[fun.Unit] { diff --git a/io/race_semantics_test.go b/io/race_semantics_test.go new file mode 100644 index 0000000..0d8ac5c --- /dev/null +++ b/io/race_semantics_test.go @@ -0,0 +1,172 @@ +package io_test + +import ( + "errors" + "testing" + "time" + + "github.com/primetalk/goio/io" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const raceSemanticsTestTimeout = 2 * time.Second + +func awaitSignal(t *testing.T, signal <-chan struct{}, description string) { + t.Helper() + + select { + case <-signal: + case <-time.After(raceSemanticsTestTimeout): + t.Fatalf("timed out waiting for %s", description) + } +} + +func awaitRunResult[A any](t *testing.T, result <-chan io.GoResult[A], description string) io.GoResult[A] { + t.Helper() + + select { + case res := <-result: + return res + case <-time.After(raceSemanticsTestTimeout): + t.Fatalf("timed out waiting for %s", description) + return io.GoResult[A]{} + } +} + +func runIO[A any](ioa io.IO[A]) <-chan io.GoResult[A] { + result := make(chan io.GoResult[A], 1) + go func() { + result <- io.RunSync(ioa) + }() + return result +} + +func releaseSignal(signal chan struct{}) { + select { + case <-signal: + default: + close(signal) + } +} + +func controlledResult[A any](started chan<- struct{}, release <-chan struct{}, finished chan<- struct{}, value A, err error) io.IO[A] { + return io.Eval(func() (A, error) { + close(started) + <-release + close(finished) + return value, err + }) +} + +func TestWithTimeoutReturnsWhileLosingWorkContinues(t *testing.T) { + loserStarted := make(chan struct{}) + loserRelease := make(chan struct{}) + defer releaseSignal(loserRelease) + loserFinished := make(chan struct{}) + loser := controlledResult(loserStarted, loserRelease, loserFinished, "late", nil) + timed := io.WithTimeout[string](100 * time.Millisecond)(loser) + + result := runIO(timed) + awaitSignal(t, loserStarted, "timed work to start") + timedResult := awaitRunResult(t, result, "timeout result") + require.ErrorIs(t, timedResult.Error, io.ErrorTimeout) + + select { + case <-loserFinished: + t.Fatal("losing work finished before it was released") + default: + } + releaseSignal(loserRelease) + awaitSignal(t, loserFinished, "losing work to continue after timeout") +} + +func TestConcurrentlyFirstReturnsFirstSuccess(t *testing.T) { + winnerStarted := make(chan struct{}) + winnerRelease := make(chan struct{}) + defer releaseSignal(winnerRelease) + winnerFinished := make(chan struct{}) + loserStarted := make(chan struct{}) + loserRelease := make(chan struct{}) + defer releaseSignal(loserRelease) + loserFinished := make(chan struct{}) + winner := controlledResult(winnerStarted, winnerRelease, winnerFinished, 42, nil) + loser := controlledResult(loserStarted, loserRelease, loserFinished, 7, nil) + + result := runIO(io.ConcurrentlyFirst([]io.IO[int]{winner, loser})) + awaitSignal(t, winnerStarted, "winning computation to start") + awaitSignal(t, loserStarted, "losing computation to start") + releaseSignal(winnerRelease) + winnerResult := awaitRunResult(t, result, "first successful result") + require.NoError(t, winnerResult.Error) + assert.Equal(t, 42, winnerResult.Value) + + releaseSignal(loserRelease) + awaitSignal(t, loserFinished, "losing computation to finish") +} + +func TestConcurrentlyFirstReturnsFirstFailure(t *testing.T) { + expectedErr := errors.New("first failure") + winnerStarted := make(chan struct{}) + winnerRelease := make(chan struct{}) + defer releaseSignal(winnerRelease) + winnerFinished := make(chan struct{}) + loserStarted := make(chan struct{}) + loserRelease := make(chan struct{}) + defer releaseSignal(loserRelease) + loserFinished := make(chan struct{}) + winner := controlledResult(winnerStarted, winnerRelease, winnerFinished, 0, expectedErr) + loser := controlledResult(loserStarted, loserRelease, loserFinished, 7, nil) + + result := runIO(io.ConcurrentlyFirst([]io.IO[int]{winner, loser})) + awaitSignal(t, winnerStarted, "failing computation to start") + awaitSignal(t, loserStarted, "losing computation to start") + releaseSignal(winnerRelease) + winnerResult := awaitRunResult(t, result, "first failed result") + require.ErrorIs(t, winnerResult.Error, expectedErr) + + releaseSignal(loserRelease) + awaitSignal(t, loserFinished, "losing computation to finish") +} + +func TestConcurrentlyFirstLosersCanCompleteAfterWinnerReturns(t *testing.T) { + const loserCount = 32 + + winnerStarted := make(chan struct{}) + winnerRelease := make(chan struct{}) + defer releaseSignal(winnerRelease) + winnerFinished := make(chan struct{}) + winner := controlledResult(winnerStarted, winnerRelease, winnerFinished, -1, nil) + loserRelease := make(chan struct{}) + defer releaseSignal(loserRelease) + loserStarted := make([]chan struct{}, loserCount) + loserFinished := make([]chan struct{}, loserCount) + competitors := make([]io.IO[int], 0, loserCount+1) + competitors = append(competitors, winner) + for index := 0; index < loserCount; index++ { + loserStarted[index] = make(chan struct{}) + loserFinished[index] = make(chan struct{}) + competitors = append(competitors, controlledResult( + loserStarted[index], + loserRelease, + loserFinished[index], + index, + nil, + )) + } + + result := runIO(io.ConcurrentlyFirst(competitors)) + awaitSignal(t, winnerStarted, "winning computation to start") + for _, started := range loserStarted { + awaitSignal(t, started, "losing computation to start") + } + releaseSignal(winnerRelease) + winnerResult := awaitRunResult(t, result, "winning result") + require.NoError(t, winnerResult.Error) + assert.Equal(t, -1, winnerResult.Value) + + releaseSignal(loserRelease) + for _, finished := range loserFinished { + awaitSignal(t, finished, "losing computation to finish after winner returned") + } +} diff --git a/io/time.go b/io/time.go index bb02134..2371bd8 100644 --- a/io/time.go +++ b/io/time.go @@ -22,8 +22,11 @@ func SleepA[A any](d time.Duration, value A) IO[A] { // ErrorTimeout is an error that will be returned in case of timeout. var ErrorTimeout = errors.New("timeout") -// WithTimeout waits IO for completion for no longer than the provided duration. -// If there are no results, the IO will fail with timeout error. +// WithTimeout returns the IO result if it completes within the provided +// duration, or fails with ErrorTimeout when the timeout wins. +// +// WithTimeout only stops waiting. It does not cancel the input IO, which +// continues independently and may still perform side effects after timeout. func WithTimeout[A any](d time.Duration) func(ioa IO[A]) IO[A] { return func(ioa IO[A]) IO[A] { first := ConcurrentlyFirst([]IO[GoResult[A]]{ diff --git a/io/time_test.go b/io/time_test.go index abfc81a..a9c86e5 100644 --- a/io/time_test.go +++ b/io/time_test.go @@ -5,30 +5,35 @@ import ( "time" "github.com/primetalk/goio/io" - "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestTimeout(t *testing.T) { - start := time.Now() - sleep1000ms := io.SleepA(1000*time.Millisecond, "a") - atMost100ms := io.WithTimeout[string](100 * time.Millisecond)(sleep1000ms) - _, err := io.UnsafeRunSync(atMost100ms) - assert.Equal(t, io.ErrorTimeout, err) - end := time.Now() - assert.WithinDuration(t, end, start, 200*time.Millisecond) + started := make(chan struct{}) + release := make(chan struct{}) + defer releaseSignal(release) + finished := make(chan struct{}) + work := controlledResult(started, release, finished, "late", nil) + + result := runIO(io.WithTimeout[string](10 * time.Millisecond)(work)) + awaitSignal(t, started, "timed work to start") + timedResult := awaitRunResult(t, result, "timeout result") + require.ErrorIs(t, timedResult.Error, io.ErrorTimeout) + + releaseSignal(release) + awaitSignal(t, finished, "timed work to finish after release") } func TestNotify(t *testing.T) { - start := time.Now() - notificationMoment := make(chan time.Time, 1) + notification := make(chan io.GoResult[string], 1) ion := io.Notify(100*time.Millisecond, "a", func(str string, err error) { - assert.Equal(t, nil, err) - - notificationMoment <- time.Now() + notification <- io.GoResult[string]{Value: str, Error: err} }) - _, err := io.UnsafeRunSync(ion) - assert.Equal(t, nil, err) - assert.WithinDuration(t, time.Now(), start, 10*time.Millisecond) - time.Sleep(200 * time.Millisecond) - assert.WithinDuration(t, <-notificationMoment, start, 200*time.Millisecond) + + runResult := awaitRunResult(t, runIO(ion), "Notify setup") + require.NoError(t, runResult.Error) + + notificationResult := awaitRunResult(t, notification, "Notify callback") + require.NoError(t, notificationResult.Error) + require.Equal(t, "a", notificationResult.Value) } diff --git a/resource/resource.go b/resource/resource.go index f79b176..3055dd2 100644 --- a/resource/resource.go +++ b/resource/resource.go @@ -108,14 +108,20 @@ func FlatMap[A any, B any](ra Resource[A], f func(a A) Resource[B]) Resource[B] // ClosableIOTransform transforms a closable of io closable to just io closable. func ClosableIOTransform[A any](cioca Closable[io.IO[Closable[A]]]) (ioca io.IO[Closable[A]]) { - return io.Eval(func() (ca Closable[A], err error) { - defer fun.RecoverToErrorVar("resource.ClosableIOTransform", &err) - ca = ClosableFlatMap(cioca, func(ioca io.IO[Closable[A]]) (ca1 Closable[A]) { - ca1, err = io.UnsafeRunSync(ioca) - return - }) - return - }) + return io.Fold(cioca.Value, + func(ca Closable[A]) io.IO[Closable[A]] { + return io.Lift(ClosableFlatMap(cioca, func(io.IO[Closable[A]]) Closable[A] { + return ca + })) + }, + func(acquireErr error) io.IO[Closable[A]] { + closeOuter := io.Recover(cioca.Close(), func(releaseErr error) io.IO[fun.Unit] { + log.Printf("error during outer resource release after inner acquisition failure: %+v", releaseErr) + return io.IOUnit1 + }) + return io.AndThen(closeOuter, io.Fail[Closable[A]](acquireErr)) + }, + ) } // UnbufferedChannel returns a resource that manages a channel. diff --git a/resource/resource_test.go b/resource/resource_test.go index 7604d2b..c29352f 100644 --- a/resource/resource_test.go +++ b/resource/resource_test.go @@ -97,3 +97,84 @@ func TestResourceInResource(t *testing.T) { assert.Equal(t, err, nil) assert.Equal(t, res18, 18) } + +func TestFlatMapReleasesOuterResourceWhenInnerAcquisitionFails(t *testing.T) { + innerAcquireErr := errors.New("inner acquisition failed") + outerReleaseCount := 0 + outer := resource.NewResource( + io.Lift("outer"), + func(string) io.IO[fun.Unit] { + return io.FromPureEffect(func() { + outerReleaseCount++ + }) + }, + ) + composed := resource.FlatMap(outer, func(string) resource.Resource[int] { + return resource.Fail[int](innerAcquireErr) + }) + useCalled := false + + _, err := io.UnsafeRunSync(resource.Use(composed, func(int) io.IO[int] { + useCalled = true + return io.Lift(1) + })) + + assert.Equal(t, innerAcquireErr, err) + assert.False(t, useCalled) + assert.Equal(t, 1, outerReleaseCount) +} + +func TestFlatMapPreservesInnerAcquisitionErrorWhenOuterReleaseFails(t *testing.T) { + innerAcquireErr := errors.New("inner acquisition failed") + outerReleaseErr := errors.New("outer release failed") + outerReleaseCount := 0 + outer := resource.NewResource( + io.Lift("outer"), + func(string) io.IO[fun.Unit] { + return io.AndThen( + io.FromPureEffect(func() { + outerReleaseCount++ + }), + io.Fail[fun.Unit](outerReleaseErr), + ) + }, + ) + composed := resource.FlatMap(outer, func(string) resource.Resource[int] { + return resource.Fail[int](innerAcquireErr) + }) + + _, err := io.UnsafeRunSync(resource.Use(composed, func(value int) io.IO[int] { + return io.Lift(value) + })) + + assert.Equal(t, innerAcquireErr, err) + assert.Equal(t, 1, outerReleaseCount) +} + +func TestFlatMapReleasesSuccessfullyAcquiredResourcesInReverseOrder(t *testing.T) { + releaseOrder := []string{} + outer := resource.NewResource( + io.Lift("outer"), + func(value string) io.IO[fun.Unit] { + return io.FromPureEffect(func() { + releaseOrder = append(releaseOrder, value) + }) + }, + ) + composed := resource.FlatMap(outer, func(string) resource.Resource[string] { + return resource.NewResource( + io.Lift("inner"), + func(value string) io.IO[fun.Unit] { + return io.FromPureEffect(func() { + releaseOrder = append(releaseOrder, value) + }) + }, + ) + }) + + value, err := io.UnsafeRunSync(resource.Use(composed, io.Lift[string])) + + assert.NoError(t, err) + assert.Equal(t, "inner", value) + assert.Equal(t, []string{"inner", "outer"}, releaseOrder) +}