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

scope

package module
v0.4.0 Latest Latest
Warning

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

Go to latest
Published: Jun 19, 2026 License: MIT Imports: 5 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 forgotten Wait() calls, no panics that crash the process.

Install

go get github.com/kakkky/scope

Requires Go 1.25 or later.

Quick Start

package main

import (
    "context"
    "fmt"

    "github.com/kakkky/scope"
)

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

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

The scope.Run block is a hard boundary: it 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 cancellation propagates downstream.

What it gives you

  • Lifetime bound to a block. Goroutines spawned with s.Go cannot outlive the enclosing scope.Run call. There is no Wait() to forget — the boundary is the closing brace.
  • First-error-wins, with context cancellation. Every goroutine receives the scope's context as an argument. When any goroutine returns a non-nil error, the context is cancelled so siblings can observe ctx.Done() and exit.
  • Panic safety. A panic in any spawned goroutine is recovered, formatted with its stack trace, and returned through the same error path. Your process does not crash.
  • Nested scopes. s.Scope creates a child scope whose context is derived from the parent's. The call is synchronous — it blocks until the child's body and all goroutines within it finish. Cancellation propagates from parent to child; errors in the child propagate back and cancel the parent's context.
  • Dynamic spawning. s.Go may be called from inside other goroutines, recursively, or after the body has returned — all of them are still bound to the same scope.

Why not errgroup or raw goroutines?

golang.org/x/sync/errgroup covers most of the same ground, but you have to remember to call Wait(), you have to pass ctx to children yourself, and an unhandled panic still tears the process down. scope removes those failure modes by tying the wait to the closure, forcing ctx into the signature of every spawned function, and recovering panics by default.

Raw go func() { ... }() calls do none of this. They are also the single biggest source of goroutine leaks in real Go codebases.

Documentation

Full API reference: pkg.go.dev/github.com/kakkky/scope

License

MIT — see LICENSE.

Documentation

Overview

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

A Scope binds the lifetime of spawned goroutines to a lexical block, so that the goroutines are guaranteed to complete before the block exits. This avoids goroutine leaks caused by missing wg.Wait() calls, unobserved panics, and errors that escape unhandled.

The primary entry point is Run, which establishes a scope and invokes a body function. Inside the body, goroutines are spawned via Scope.Go. Run does not return until every spawned goroutine has finished.

Errors and panics from any spawned goroutine are propagated back to the caller of Run. The first non-nil error cancels the scope's context, which is passed to every spawned goroutine and should be observed by callers for cooperative cancellation.

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.

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.

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 (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 (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 (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

Types

type Option added in v0.2.0

type Option func(*options)

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

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 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 context of sibling goroutines or the parent scope.

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.

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) 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)

In supervisor mode, goroutine failures within a child scope do not cancel sibling goroutines inside that scope, nor goroutines in the parent scope.

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: false
err: task failed

Jump to

Keyboard shortcuts

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