Help us shape the future of Go! Take the 2026 Go Developer Survey

scope

package module
v0.10.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 24, 2026 License: MIT Imports: 7 Imported by: 0

README

scope

CI Go Reference Go Report Card codecov

Scope-bound structured concurrency for Go.

scope binds the lifetime of every goroutine you spawn to a lexical block. When the block exits, every goroutine has either returned or been cancelled — no leaks, no panics that crash the process.

Install

go get github.com/kakkky/scope

Requires Go 1.27 or later. If you are on an older Go version, use v0.8.0.

Quick Start

err := scope.Run(context.Background(), func(s *scope.Scope) error {
    s.Go(func(ctx context.Context) error {
        return doA(ctx)
    })
    s.Go(func(ctx context.Context) error {
        return doB(ctx)
    })
    return nil
})
// By the time we get here, both goroutines have finished.

scope.Run does not return until every goroutine spawned via s.Go has finished. When the first non-nil error occurs, the scope's context is cancelled so sibling goroutines can observe ctx.Done() and exit. Panics are recovered into errors and returned through the same path.

API Reference

For full documentation, see pkg.go.dev/github.com/kakkky/scope.

Run
func Run(ctx context.Context, body func(s *Scope) error, opts ...Option) error

Establishes a scope, executes body, and waits for all goroutines spawned via s.Go to finish before returning. The scope's context is derived from ctx and cancelled when any goroutine or body returns a non-nil error, or when a panic is recovered.

Scope.Go
func (s *Scope) Go(fn func(ctx context.Context) error)

Spawns fn in a new goroutine bound to the scope. fn receives the scope's context so it can participate in cancellation. Go may be called from within body, from another goroutine spawned by the same scope, or recursively — all goroutines remain bound to the enclosing Run.

Scope.GoFuture[T]
func (s *Scope) GoFuture[T any](fn func(ctx context.Context) (T, error)) Future[T]

Spawns fn in a new goroutine and returns a Future[T] to retrieve the result. future.Wait() blocks until fn has finished and returns the value and error returned by fn (or an error describing a panic). It can be called any number of times, from any goroutine, including after Run has returned.

Scope.Scope
func (s *Scope) Scope(body func(child *Scope) error, opts ...Option)

Creates a child scope nested under s and runs body synchronously within it. The call blocks until body and all goroutines spawned in the child have finished. Cancellation propagates from parent to child; errors in the child propagate back and cancel the parent's context.

Options

Options are passed to Run or Scope.Scope to configure the scope. Each scope opts in independently; options are not inherited by child scopes.

WithSupervisor
func WithSupervisor() Option

In supervisor mode, a failure in one goroutine or child scope does not cancel the scope's context, so siblings keep running. Whether a failed child scope cancels its parent is determined by the parent's mode only. Each goroutine runs to completion regardless of others' results.

WithErrAggregation
func WithErrAggregation() Option

By default, only the first error is returned (first-error-wins). With this option, all errors from goroutines and body are collected and returned as a single error via errors.Join. Most useful in combination with WithSupervisor.

WithMaxConcurrency
func WithMaxConcurrency(max int) Option

Limits the number of goroutines that may execute concurrently within the scope. Go never blocks: when the limit is reached, the spawned goroutine waits for a running goroutine to complete before running fn. If the scope's context is cancelled while waiting, fn is never run.

WithTimeout
func WithTimeout(d time.Duration) Option

Cancels the scope's context after duration d. When the timeout fires, Run returns context.DeadlineExceeded. If an earlier deadline is already set on the parent context, that deadline takes precedence.

If the context passed to Run has an earlier deadline, this timeout has no effect, and that deadline's error is not returned by Run unless something in the scope returns it. When the timeout fires, context.Cause of the scope's context is an error wrapping context.DeadlineExceeded (ctx.Err() is still context.DeadlineExceeded).

WithCancelOnSuccess
func WithCancelOnSuccess() Option

Cancels the scope's context as soon as any goroutine or child scope returns a nil error, allowing sibling goroutines to observe ctx.Done() and exit early. Errors returned by goroutines or child scopes after this cancellation (typically ctx.Err()) are discarded; errors that occurred before it and errors returned by body are still returned by Run, and panics are never discarded by this option.

Errors and panics

By default, Run returns only the first error (first-error-wins). With WithErrAggregation, it returns all errors joined via errors.Join. A recovered panic takes precedence over ordinary errors.

Situation Effect on the scope Returned by Run
A goroutine returns an error Cancels the scope (not with WithSupervisor) Yes
A goroutine panics Recovers it into an error scope: panic recovered: <value>\n<stack> and cancels the scope (not with WithSupervisor) Yes, in preference to ordinary errors. For GoFuture, Future.Wait also returns it
The scope's body returns an error Cancels the scope, even with WithSupervisor Yes
The scope's body panics Not recovered. Cancels the scope and waits for all its goroutines No. The panic propagates to the caller of Run
A goroutine in a child scope returns an error The child scope records the error, cancels itself (not with WithSupervisor on the child scope), and returns the error. The parent scope records it and cancels itself (not with WithSupervisor on the parent scope) Yes
A goroutine in a child scope panics The child scope recovers it into an error, records it, cancels itself (not with WithSupervisor on the child scope), and returns the error. The parent scope records it as a panic and cancels itself (not with WithSupervisor on the parent scope) Yes, in preference to ordinary errors
A child scope's body returns an error The child scope records the error, cancels itself (even with WithSupervisor on the child scope), and returns the error. The parent scope records it and cancels itself (not with WithSupervisor on the parent scope) Yes
A child scope's body panics Not recovered. The child scope cancels itself and waits for all its goroutines. The panic propagates to the parent scope's body, and the parent scope likewise cancels itself and waits for all its goroutines without recovering it No. The panic propagates to the caller of Run
A goroutine or child scope returns an error after a success-triggered cancellation by WithCancelOnSuccess The error is not recorded, since it is typically ctx.Err() (context.Canceled) returned by a goroutine that stopped because of the cancellation No, except for panics, which are recorded and returned
The scope's own WithTimeout fires Cancels the scope regardless of WithSupervisor context.DeadlineExceeded, unless a panic was recovered
The deadline of the context passed to Run comes first The scope's context is cancelled as well The deadline's error is not returned unless something in the scope returns it (a goroutine returning ctx.Err() makes Run return context.DeadlineExceeded)
Options and child scopes

Options apply only to the scope they are passed to and are not inherited by child scopes. From the parent scope's point of view, a child scope created via Scope.Scope is handled like a single goroutine (except that a panic in the child scope's body is not recovered and propagates to the parent scope's body). How the parent scope reacts to the child scope's result is determined by the parent scope's options; the child scope's options only determine what the child scope returns.

Option Passed to the parent scope Passed to the child scope
WithSupervisor A child scope returning an error does not cancel the parent scope Sibling goroutines in the child scope are not cancelled. The parent scope is still cancelled unless it is a supervisor itself
WithErrAggregation The child scope's error is collected together with other errors The child scope returns all of its errors joined
WithCancelOnSuccess A child scope returning without error counts as a success and cancels the parent scope A success inside the child scope cancels only the child scope
WithTimeout Bounds the deadline of all descendants Can only shorten the deadline within the parent scope's bound. When it fires, the child scope returns context.DeadlineExceeded as an error
WithMaxConcurrency A child scope does not occupy a slot of the parent scope Limits only goroutines spawned via Go on the child scope

Background

This package is inspired by the structured concurrency concept:

License

MIT — see LICENSE.

Documentation

Overview

Package scope provides scope-bound structured concurrency primitives for Go.

Structured concurrency brings the essence of structured programming to concurrent code: child tasks must not outlive the scope of their parent. This constraint prevents goroutine leaks, which are among the most common concurrency bugs in Go.

While sync.WaitGroup and golang.org/x/sync/errgroup can achieve similar guarantees via Wait(), scope takes a different approach by binding goroutine lifetimes to lexical scopes. Because the scope boundary is visible in the code structure itself, goroutine lifetimes become easier to reason about at a glance.

The primary entry point is Run. Run establishes a scope and invokes a body function. Inside the body, Scope.Go spawns goroutines. Run does not return until all spawned goroutines have completed. Child scopes can be created with Scope.Scope.

Errors and panics from spawned goroutines propagate to the caller of Run. The first non-nil error cancels the scope's context, which is passed to every goroutine and should be monitored for cooperative cancellation.

Errors and panics

By default, Run returns only the first error (first-error-wins); with WithErrAggregation, it returns all errors joined via errors.Join. A panic in a goroutine spawned via Go or GoFuture is recovered into an error and handled like any other error, but it takes precedence over ordinary errors when Run picks the error to return, and WithCancelOnSuccess never discards it. A panic in the body passed to Run or Scope.Scope is not recovered: the scope is canceled, its goroutines are waited for, and the panic propagates to the caller.

When the scope's own WithTimeout fires, Run returns context.DeadlineExceeded. A deadline of the context passed to Run is not treated as the scope's own timeout; its error is returned only if something in the scope returns it.

Options and child scopes

Options passed to Run or Scope.Scope apply only to the scope they are passed to. They are not inherited by child scopes, and the options of a child scope never change how its parent behaves.

From the parent's point of view, a child scope created via Scope.Scope is handled like a single goroutine spawned via Scope.Go (except that a panic in the child's body is not recovered and propagates to the parent's body), and the parent reacts to the child's result according to the parent's own options: WithSupervisor decides whether a failed child cancels the parent's context, WithErrAggregation decides whether the child's error is collected together with other errors, and WithCancelOnSuccess treats a child that returns without error as a success. The options of the child only determine what the child returns, such as a joined error under WithErrAggregation or context.DeadlineExceeded under WithTimeout.

Cancellation flows from parent to child only: canceling a scope cancels all of its descendants through the context, while a child affects its parent only through the result it returns. In particular, WithTimeout on the parent bounds all of its descendants; WithTimeout on a child can only shorten the deadline within that bound.

The limit set by WithMaxConcurrency counts only goroutines spawned via Go on that scope. A child scope runs synchronously in the caller's goroutine and does not occupy a slot of its parent's limit.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Run

func Run(ctx context.Context, body func(s *Scope) error, opts ...Option) error

Run executes body inside a new Scope derived from parent and waits for all goroutines spawned via Scope.Go to finish before returning.

Run guarantees that every goroutine spawned through the Scope has returned by the time Run returns, regardless of whether body returns normally, returns an error, or panics. A panic in body is not recovered: the scope's context is canceled, Run waits for all spawned goroutines, and then the panic continues to propagate to the caller.

If body returns a non-nil error, the scope's context is canceled and the error is returned. If any spawned goroutine returns a non-nil error or panics, the scope's context is canceled and the first such error is returned. Panics from spawned goroutines are recovered and converted into errors; they do not crash the process. A recovered panic takes precedence over ordinary errors: if any goroutine panicked, Run returns the first panic instead of the first error.

The context passed to body and to each spawned goroutine is derived from parent and is canceled as described above. Callers should observe this context (typically via ctx.Done()) to participate in cancellation.

Example

ExampleRun shows a minimal usage: spawn two goroutines and wait for both.

package main

import (
	"context"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	ctx := context.Background()

	err := scope.Run(ctx, func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error {
			fmt.Println("task A")
			return nil
		})
		s.Go(func(ctx context.Context) error {
			fmt.Println("task B")
			return nil
		})
		return nil
	})

	if err != nil {
		fmt.Println("err:", err)
	}

}
Output:
task A
task B
Example (ContextCause)

ExampleRun_contextCause shows that context.Cause returns the error that triggered cancellation, rather than the generic context.Canceled.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	scope.Run(context.Background(), func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error {
			return errors.New("task A failed")
		})
		s.Go(func(ctx context.Context) error {
			<-ctx.Done()
			fmt.Println("cause:", context.Cause(ctx))
			return ctx.Err()
		})
		return nil
	})

}
Output:
cause: task A failed
Example (ErrorCancelsContext)

ExampleRun_errorCancelsContext shows how the first non-nil error cancels the scope's context so sibling goroutines can observe ctx.Done() and exit.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	ctx := context.Background()

	err := scope.Run(ctx, func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error {
			return errors.New("task A failed")
		})
		s.Go(func(ctx context.Context) error {
			<-ctx.Done()
			fmt.Println("task B: context cancelled")
			return ctx.Err()
		})
		return nil
	})

	fmt.Println("err:", err)

}
Output:
task B: context cancelled
err: task A failed
Example (PanicRecovery)

ExampleRun_panicRecovery shows that a panic inside a spawned goroutine is recovered and surfaced as an error rather than crashing the process.

package main

import (
	"context"
	"fmt"
	"strings"

	"github.com/kakkky/scope"
)

func main() {
	ctx := context.Background()

	err := scope.Run(ctx, func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error {
			panic("something bad")
		})
		return nil
	})

	fmt.Println(strings.Contains(err.Error(), "scope: panic recovered: something bad"))

}
Output:
true
Example (WithCancelOnSuccess)

ExampleRun_withCancelOnSuccess shows a hedged request pattern: send the same request to multiple backends and use the first successful response, canceling the rest.

package main

import (
	"context"
	"fmt"
	"sync"

	"github.com/kakkky/scope"
)

func main() {
	ctx := context.Background()

	var (
		once   sync.Once
		result string
	)

	err := scope.Run(ctx, func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error {
			// fast backend: responds immediately
			once.Do(func() { result = "response from A" })
			return nil
		})
		s.Go(func(ctx context.Context) error {
			// slow backend: canceled before responding
			<-ctx.Done()
			once.Do(func() { result = "response from B" })
			return ctx.Err()
		})
		return nil
	}, scope.WithCancelOnSuccess())

	fmt.Println(result)
	fmt.Println("err:", err)

}
Output:
response from A
err: <nil>
Example (WithErrAggregation)

ExampleRun_withErrAggregation shows that all goroutine errors are collected when WithErrAggregation is enabled. Without it, only the first error is returned.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	errA := errors.New("task A failed")
	errB := errors.New("task B failed")

	err := scope.Run(context.Background(), func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error { return errA })
		s.Go(func(ctx context.Context) error { return errB })
		return nil
	}, scope.WithErrAggregation())

	fmt.Println(errors.Is(err, errA))
	fmt.Println(errors.Is(err, errB))

}
Output:
true
true
Example (WithMaxConcurrency)

ExampleRun_withMaxConcurrency shows that WithMaxConcurrency limits the number of goroutines executing concurrently within a scope. At most 3 goroutines run at the same time regardless of how many are spawned.

package main

import (
	"context"
	"fmt"
	"sync/atomic"

	"github.com/kakkky/scope"
)

func main() {
	var count atomic.Int64

	scope.Run(context.Background(), func(s *scope.Scope) error {
		for range 10 {
			s.Go(func(ctx context.Context) error {
				count.Add(1)
				return nil
			})
		}
		return nil
	}, scope.WithMaxConcurrency(3))

	fmt.Println("all tasks completed:", count.Load() == 10)

}
Output:
all tasks completed: true
Example (WithSupervisor)
package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	ctx := context.Background()
	ch := make(chan struct{})

	scope.Run(ctx, func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error {
			close(ch)
			return errors.New("task failed")
		})
		s.Go(func(ctx context.Context) error {
			<-ch
			fmt.Println("ctx cancelled:", ctx.Err() != nil)
			return nil
		})
		return nil
	}, scope.WithSupervisor())

}
Output:
ctx cancelled: false
Example (WithSupervisorAndErrAggregation)

ExampleRun_withSupervisorAndErrAggregation shows that combining WithSupervisor and WithErrAggregation allows collecting all errors from goroutines that run to completion without being cancelled by sibling failures.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	errA := errors.New("task A failed")
	errB := errors.New("task B failed")

	err := scope.Run(context.Background(), func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error { return errA })
		s.Go(func(ctx context.Context) error { return errB })
		return nil
	}, scope.WithSupervisor(), scope.WithErrAggregation())

	fmt.Println(errors.Is(err, errA))
	fmt.Println(errors.Is(err, errB))

}
Output:
true
true
Example (WithTimeout)

ExampleRun_withTimeout shows that WithTimeout cancels the scope's context after the given duration, causing all goroutines to observe ctx.Done().

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/kakkky/scope"
)

func main() {
	err := scope.Run(context.Background(), func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error {
			<-ctx.Done()
			return ctx.Err()
		})
		return nil
	}, scope.WithTimeout(10*time.Millisecond))

	fmt.Println(errors.Is(err, context.DeadlineExceeded))

}
Output:
true

Types

type Future added in v0.7.0

type Future[T any] struct {
	// contains filtered or unexported fields
}

Future represents a value that will be produced by a goroutine started with Scope.GoFuture. The zero value is not usable; obtain one via Scope.GoFuture.

func (Future[T]) Wait added in v0.7.0

func (f Future[T]) Wait() (T, error)

Wait blocks until the goroutine started by Scope.GoFuture has finished and returns the value and error returned by fn.

If fn panics, Wait returns the zero value of T and an error describing the panic. If fn was never started because the scope's context was canceled while waiting for a concurrency slot (see WithMaxConcurrency), Wait returns the zero value of T and the cancellation cause of the scope.

Wait may be called any number of times, from any goroutine, and also after Run has returned; every call returns the same result.

type Option added in v0.2.0

type Option func(*options)

Option configures a Scope created by Run or Scope.Scope.

func WithCancelOnSuccess added in v0.8.0

func WithCancelOnSuccess() Option

WithCancelOnSuccess returns an Option that cancels the scope's context as soon as any spawned goroutine returns a nil error.

A child scope created via Scope.Scope counts as a success when it returns without error, in the same way as a goroutine spawned via Go.

By default, the scope's context is only canceled when a goroutine returns a non-nil error. With this option, a successful return also triggers cancellation, allowing sibling goroutines to observe ctx.Done() and exit early.

Errors returned by goroutines or child scopes after this option has triggered the cancellation are discarded, since they are typically ctx.Err(). Errors recorded before the cancellation and errors returned by the body function are still returned by Run, and panics are never discarded by this option.

This option is not inherited by child scopes created via Scope.Scope; each scope must opt in independently. Cancellation, however, propagates to child scopes as with any context cancellation.

func WithErrAggregation added in v0.4.0

func WithErrAggregation() Option

WithErrAggregation returns an Option that enables error aggregation for the scope.

By default, only the first non-nil error is recorded (first-error-wins). With this option, all errors from goroutines and the body are collected and returned as a single error via errors.Join when Run returns.

This option is not inherited by child scopes created via Scope.Scope; each scope must opt in independently.

WithErrAggregation is most useful in combination with WithSupervisor, where goroutines continue running after a sibling fails and multiple errors can accumulate.

func WithMaxConcurrency added in v0.5.0

func WithMaxConcurrency(max int) Option

WithMaxConcurrency returns an Option that limits the number of goroutines that may execute concurrently within the scope.

Go never blocks: when the limit is reached, the spawned goroutine waits for a running goroutine to complete before running fn. If the scope's context is canceled while waiting, fn is never run. The limit applies to fn executions, not to goroutines waiting for a slot.

This option is not inherited by child scopes created via Scope.Scope; each scope must opt in independently.

func WithSupervisor added in v0.3.0

func WithSupervisor() Option

WithSupervisor returns an Option that enables supervisor mode for the scope.

In supervisor mode, a failure in one goroutine or child scope does not cancel the scope's context, so sibling goroutines and child scopes keep running.

Whether a failed child scope created via Scope.Scope cancels its parent is determined by the parent's mode only. WithSupervisor passed to the child affects only goroutines within the child; a failure in it still cancels a parent that is not in supervisor mode.

Note that a non-nil error returned directly from the body function still cancels the scope's context regardless of this option.

Goroutines spawned via Go from within another goroutine are registered on the same scope and treated as siblings in terms of context cancellation.

This option is not inherited by child scopes created via Scope.Scope; each scope must opt in independently.

func WithTimeout added in v0.6.0

func WithTimeout(d time.Duration) Option

WithTimeout returns an Option that cancels the scope's context after duration d, causing all goroutines in the scope to observe ctx.Done().

When the timeout fires, Run returns context.DeadlineExceeded. The deadline is visible via ctx.Deadline() within the scope's goroutines, allowing downstream context-aware operations (e.g. HTTP clients) to observe the remaining time.

The cause of the scope's context, as reported by context.Cause, is an error wrapping context.DeadlineExceeded.

If an earlier deadline is already set on the context, that deadline takes precedence and this timeout has no additional effect: Run returns the errors recorded in the scope, as if this option were not given.

A timeout always cancels the scope's context regardless of WithSupervisor.

The timeout setting itself is not inherited by child scopes created via Scope.Scope; each scope must opt in independently. Cancellation, however, propagates to child scopes as with any context cancellation.

type Scope

type Scope struct {
	// contains filtered or unexported fields
}

Scope represents a structured concurrency scope.

A Scope owns a derived context.Context that is canceled when any spawned goroutine returns a non-nil error, when a panic is recovered, when the body function passed to Run returns a non-nil error, or when Run returns.

A Scope must be obtained via Run; callers should not construct one directly. The zero value of Scope is not usable.

func (*Scope) Go

func (s *Scope) Go(fn func(ctx context.Context) error)

Go starts fn in a new goroutine bound to the scope.

The goroutine receives the scope's context as its argument; fn is expected to observe ctx.Done() or to pass ctx to downstream context-aware operations in order to participate in cancellation.

If fn returns a non-nil error, the scope's context is canceled and the error becomes the result of Run (unless an earlier error has already been recorded). If fn panics, the panic is recovered, formatted as an error with a stack trace, and propagated through the same mechanism.

Go may be called from inside body, from inside another goroutine spawned by the same scope, or recursively, as long as Run has not yet returned.

Calling Go after Run has returned panics. Goroutines must be spawned while Run is executing.

Example (DynamicSpawn)

ExampleScope_Go_dynamicSpawn shows that goroutines can be spawned dynamically from within other goroutines, as long as Run has not yet returned.

package main

import (
	"context"
	"fmt"
	"sync/atomic"

	"github.com/kakkky/scope"
)

func main() {
	ctx := context.Background()
	var count atomic.Int64

	err := scope.Run(ctx, func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error {
			for range 3 {
				s.Go(func(ctx context.Context) error {
					count.Add(1)
					return nil
				})
			}
			return nil
		})
		return nil
	})

	fmt.Println("err:", err)
	fmt.Println("count:", count.Load())

}
Output:
err: <nil>
count: 3
Example (ErrorOccuredFromInnerGoRunWithSupervisor)

In supervisor mode, a goroutine spawned from within another goroutine is treated as a sibling at the scope level in terms of context cancellation. Even if the spawning goroutine fails, neither the inner goroutine nor sibling goroutines have their context cancelled.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	ctx := context.Background()
	ch1 := make(chan struct{})
	ch2 := make(chan struct{})

	scope.Run(ctx, func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error {
			s.Go(func(ctx context.Context) error {
				<-ch1
				fmt.Println("inner ctx cancelled:", ctx.Err() != nil)
				close(ch2)
				return nil
			})
			close(ch1)
			return errors.New("task failed")
		})
		s.Go(func(ctx context.Context) error {
			<-ch2
			fmt.Println("sibling ctx cancelled:", ctx.Err() != nil)
			return nil
		})
		return nil
	}, scope.WithSupervisor())

}
Output:
inner ctx cancelled: false
sibling ctx cancelled: false

func (*Scope) GoFuture added in v0.9.0

func (s *Scope) GoFuture[T any](fn func(ctx context.Context) (T, error)) Future[T]

GoFuture starts fn in a new goroutine bound to the scope and returns a Future that can be used to retrieve the result via Future.Wait.

If fn returns a non-nil error or panics, the error is propagated through the scope's error handling mechanism (same as Go) and is also returned by Wait.

Example (Dag)

ExampleScope_GoFuture_dag shows how Scope.GoFuture enables DAG-style computation. A and B are computed in parallel; C depends on A and waits inside its goroutine. The final goroutine combines B and C once both are ready.

package main

import (
	"context"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	scope.Run(context.Background(), func(s *scope.Scope) error {
		aF := s.GoFuture(func(ctx context.Context) (int, error) {
			return 3 * 3, nil // A = 9
		})
		bF := s.GoFuture(func(ctx context.Context) (int, error) {
			return 2 * 2, nil // B = 4, runs in parallel with A
		})

		// C waits on A inside the goroutine
		cF := s.GoFuture(func(ctx context.Context) (int, error) {
			a, err := aF.Wait()
			if err != nil {
				return 0, err
			}
			return a + 1, nil // C = A + 1 = 10
		})

		// final result waits on B and C inside the goroutine
		s.Go(func(ctx context.Context) error {
			b, err := bF.Wait()
			if err != nil {
				return err
			}
			c, err := cF.Wait()
			if err != nil {
				return err
			}
			fmt.Println(b + c) // B + C = 14
			return nil
		})

		return nil
	})

}
Output:
14
Example (WaitAfterRun)

ExampleScope_GoFuture_waitAfterRun shows how to collect results after Run returns. With WithSupervisor, a failure in one task does not cancel the others, so each Future can be inspected individually to handle partial success.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	var userF, ordersF scope.Future[string]
	err := scope.Run(context.Background(), func(s *scope.Scope) error {
		userF = s.GoFuture(func(ctx context.Context) (string, error) {
			return "alice", nil
		})
		ordersF = s.GoFuture(func(ctx context.Context) (string, error) {
			return "", errors.New("orders: service unavailable")
		})
		return nil
	}, scope.WithSupervisor(), scope.WithErrAggregation())

	fmt.Println("Run:", err)

	// Run has already waited for every goroutine, so Wait returns immediately.
	if user, err := userF.Wait(); err == nil {
		fmt.Println("user:", user)
	}
	if _, err := ordersF.Wait(); err != nil {
		fmt.Println("orders failed:", err)
	}

}
Output:
Run: orders: service unavailable
user: alice
orders failed: orders: service unavailable

func (*Scope) Scope added in v0.2.0

func (s *Scope) Scope(body func(child *Scope) error, opts ...Option)

Scope creates a child scope nested under s and runs body within it.

Scope is synchronous: it does not return until body has returned and every goroutine spawned in the child scope has finished. The child scope's context is derived from s's context, so cancellation of s propagates to the child and to all goroutines spawned within it.

Errors and panics originating inside the child scope (from body itself or from any goroutine spawned via the child's Go) are propagated to s through the same mechanism as Scope.Go: the first such error becomes the result of the enclosing Run (unless an earlier error has already been recorded), and s's context is canceled so sibling goroutines spawned via s.Go can observe cancellation.

Scope makes the nested structure of a scope tree explicit but does not itself introduce concurrency.

Scope is intended to be called from the body of Run or from another Scope's body, not from a goroutine spawned via Scope.Go.

Calling Scope after Run has returned panics, matching the behavior of Scope.Go.

Example

ExampleScope_Scope shows how a child scope groups work and blocks until every goroutine spawned inside it has finished.

package main

import (
	"context"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	ctx := context.Background()

	err := scope.Run(ctx, func(s *scope.Scope) error {
		s.Scope(func(child *scope.Scope) error {
			child.Go(func(ctx context.Context) error {
				fmt.Println("inside child scope")
				return nil
			})
			return nil
		})
		fmt.Println("after child scope")
		return nil
	})

	fmt.Println("err:", err)

}
Output:
inside child scope
after child scope
err: <nil>
Example (ErrAggregationChildOnly)

ExampleScope_Scope_errAggregationChildOnly shows that when only the child scope has WithErrAggregation, the child aggregates its errors into a single joined error, which the root records under first-error-wins policy. Since Scope is synchronous, the child's joined error is always recorded first; the root's Go error arrives later and is discarded.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	errA := errors.New("task A failed")
	errB := errors.New("task B failed")
	errC := errors.New("task C failed")

	err := scope.Run(context.Background(), func(s *scope.Scope) error {
		s.Scope(func(child *scope.Scope) error {
			child.Go(func(ctx context.Context) error { return errA })
			child.Go(func(ctx context.Context) error { return errB })
			return nil
		}, scope.WithErrAggregation())
		s.Go(func(ctx context.Context) error { return errC })
		return nil
	})

	fmt.Println(errors.Is(err, errA))
	fmt.Println(errors.Is(err, errB))
	fmt.Println(errors.Is(err, errC))

}
Output:
true
true
false
Example (ErrAggregationParentAndChild)

ExampleScope_Scope_errAggregationParentAndChild shows that each scope must opt in to WithErrAggregation independently — it is not inherited. When both parent and child have WithErrAggregation, all errors from both levels are visible in the result.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	errA := errors.New("task A failed")
	errB := errors.New("task B failed")
	errC := errors.New("task C failed")

	err := scope.Run(context.Background(), func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error { return errA })
		s.Scope(func(child *scope.Scope) error {
			child.Go(func(ctx context.Context) error { return errB })
			child.Go(func(ctx context.Context) error { return errC })
			return nil
		}, scope.WithErrAggregation())
		return nil
	}, scope.WithErrAggregation())

	fmt.Println(errors.Is(err, errA))
	fmt.Println(errors.Is(err, errB))
	fmt.Println(errors.Is(err, errC))

}
Output:
true
true
true
Example (ErrAggregationParentOnly)

ExampleScope_Scope_errAggregationParentOnly shows that when only the parent has WithErrAggregation, the child uses first-error-wins and propagates only one error to the parent. The root also has its own Go, so the root aggregates both: the single error from the child and the error from s.Go.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	errA := errors.New("task A failed")
	errB := errors.New("task B failed")
	errC := errors.New("task C failed")

	err := scope.Run(context.Background(), func(s *scope.Scope) error {
		s.Scope(func(child *scope.Scope) error {
			child.Go(func(ctx context.Context) error { return errA })
			child.Go(func(ctx context.Context) error { return errB })
			return nil
		})
		s.Go(func(ctx context.Context) error { return errC })
		return nil
	}, scope.WithErrAggregation())

	// root aggregates: one of errA/errB (child's first-error-wins) and errC
	fmt.Println((errors.Is(err, errA) || errors.Is(err, errB)) && errors.Is(err, errC))

}
Output:
true
Example (ErrorCancelsContext)

ExampleScope_Scope_errorCancelsContext shows that an error in a child scope cancels the parent's context so sibling goroutines can observe ctx.Done() and exit.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	ctx := context.Background()

	err := scope.Run(ctx, func(s *scope.Scope) error {
		s.Go(func(ctx context.Context) error {
			<-ctx.Done()
			fmt.Println("sibling: context cancelled")
			return ctx.Err()
		})
		s.Scope(func(child *scope.Scope) error {
			return errors.New("child scope failed")
		})
		return nil
	})

	fmt.Println("err:", err)

}
Output:
sibling: context cancelled
err: child scope failed
Example (ErrorOccuredFromGoWithSupervisor)

WithSupervisor on a child scope only affects goroutines within that scope: sibling goroutines inside it are not canceled, but the failure still cancels a parent that is not in supervisor mode.

package main

import (
	"context"
	"errors"
	"fmt"

	"github.com/kakkky/scope"
)

func main() {
	ctx := context.Background()
	ch1 := make(chan struct{})

	err := scope.Run(ctx, func(s *scope.Scope) error {
		s.Scope(func(child *scope.Scope) error {
			child.Go(func(ctx context.Context) error {
				close(ch1)
				return errors.New("task failed")
			})
			child.Go(func(ctx context.Context) error {
				<-ch1
				fmt.Println("sibling ctx cancelled:", ctx.Err() != nil)
				return nil
			})
			return nil
		}, scope.WithSupervisor())
		s.Go(func(ctx context.Context) error {
			fmt.Println("parent level goroutine ctx cancelled:", ctx.Err() != nil)
			return nil
		})
		return nil
	})

	fmt.Println("err:", err)

}
Output:
sibling ctx cancelled: false
parent level goroutine ctx cancelled: true
err: task failed
Example (WithMaxConcurrency)

ExampleScope_Scope_withMaxConcurrency shows that WithMaxConcurrency is independent per scope and is not inherited by child scopes.

package main

import (
	"context"
	"fmt"
	"sync/atomic"

	"github.com/kakkky/scope"
)

func main() {
	var rootCount, childCount atomic.Int64

	scope.Run(context.Background(), func(s *scope.Scope) error {
		for range 5 {
			s.Go(func(ctx context.Context) error {
				rootCount.Add(1)
				return nil
			})
		}
		s.Scope(func(child *scope.Scope) error {
			for range 5 {
				child.Go(func(ctx context.Context) error {
					childCount.Add(1)
					return nil
				})
			}
			return nil
		}, scope.WithMaxConcurrency(2))
		return nil
	}, scope.WithMaxConcurrency(3))

	fmt.Println("root tasks completed:", rootCount.Load() == 5)
	fmt.Println("child tasks completed:", childCount.Load() == 5)

}
Output:
root tasks completed: true
child tasks completed: true
Example (WithTimeout)

ExampleScope_Scope_withTimeout shows that a child scope can have its own timeout independent of the parent. When the child times out, the error propagates to the parent scope.

package main

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/kakkky/scope"
)

func main() {
	err := scope.Run(context.Background(), func(s *scope.Scope) error {
		s.Scope(func(child *scope.Scope) error {
			child.Go(func(ctx context.Context) error {
				<-ctx.Done()
				return ctx.Err()
			})
			return nil
		}, scope.WithTimeout(10*time.Millisecond))
		return nil
	})

	fmt.Println(errors.Is(err, context.DeadlineExceeded))

}
Output:
true

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL