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

scope

package module
v0.2.0 Latest Latest
Warning

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

Go to latest
Published: Jun 16, 2026 License: MIT Imports: 4 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. The first non-nil error cancels the others, panics are recovered into errors, and the derived context propagates cancellation 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 sibling cancellation. When any goroutine returns a non-nil error, the scope's 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.
  • Context propagation. Every goroutine receives the scope's context as an argument, so missing the cancellation signal becomes a misuse you can spot in code review (or with a linter).
  • 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.

The opts parameter is reserved for future use; currently no options are defined and any provided values are ignored.

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

ExampleRun_errorPropagation shows how the first non-nil error cancels the scope and is returned to the caller.

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()
			return ctx.Err()
		})
		return nil
	})

	fmt.Println("err:", err)

}
Output:
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"

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

	// err contains "scope: panic recovered: something bad" followed by a stack trace.
	fmt.Println(err != nil)

}
Output:
true

Types

type Option added in v0.2.0

type Option func(*options)

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

No options are currently defined. The Option type exists so that future configuration (such as timeouts or error policies) can be added without breaking existing callers.

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 i := 0; i < 3; i++ {
				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

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.

The opts parameter is reserved for future use; currently no options are defined and any provided values are ignored.

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>

Jump to

Keyboard shortcuts

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