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

scope

package module
v0.1.0 Latest Latest
Warning

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

Go to latest
Published: Jun 13, 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.

Status

This is an early release. The core API (Run, Go) is stable enough to use, but additional features are planned:

  • scope.Defer for cleanup that runs after children complete
  • Spawn origin tracking for leak diagnostics
  • A go/analysis linter to flag raw go statements inside scope.Run
  • Integration with Go 1.26's goroutineleak profile

The version is v0.x.x while the surface settles. Breaking changes between minor versions are possible until v1.0.0.

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

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

Jump to

Keyboard shortcuts

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