Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ below says plainly whether an upgrade can break a caller.

## [Unreleased]

`Binding.Worker` is now `Binding.Go`, after `errgroup.Group.Go` and
`sync.WaitGroup.Go`, whose contract it has always had: run a function in a
goroutine the group tracks, cancel it when the group winds down, wait for it,
and let its error take the group down. `go doc -all` against 0.15.1 removes
`Binding.Worker` and adds `Binding.Go` with the same signature. **An upgrade
breaks a caller that registers a worker**: rename the call from `Worker` to
`Go`, and nothing else changes.

### Changed

- `Binding.Worker` is renamed `Binding.Go`, with no alias left behind. The
word "worker" still names what the method registers, in the docs and in the
one error `Stop` reports about it, which now reads "worker did not return"
rather than "Worker hook did not return".

## [0.15.1] - 2026-09-10

A code-organisation release: the library is six files rather than one, and
Expand Down
10 changes: 5 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -424,7 +424,7 @@ binding and cannot protect the inner scope.
- Every user hook is called through `callHook`, which turns a panic into that
hook's error, and every step is reported through `state.report`, so a
hook that panicked is observed like one that failed. A cancelled
`Worker`'s return is dropped only when it says nothing beyond
worker's return is dropped only when it says nothing beyond
`context.Canceled` (`onlyCancellation` walks the error tree); `errors.Is`
matched `errors.Join(ctx.Err(), failure)` and dropped the failure with it
(#35). A hook can panic by resolving something whose registration is rejected
Expand All @@ -434,10 +434,10 @@ binding and cannot protect the inner scope.
behind it.
- Nothing in the teardown path may run a user hook against a value another hook
still holds. That is one rule with three instances: `OnStop` after `OnDrain`,
`OnStop` after a `Worker` hook (deferred to `releaseAfterWorker` when `ctx`
`OnStop` after a `Go` worker (deferred to `releaseAfterWorker` when `ctx`
expires rather than run alongside it), and a parent's hooks after a child's.
- `Start`'s rollback goes through `Stop` with `context.WithoutCancel`, so it
stops child scopes and waits for `Worker` hooks.
stops child scopes and waits for workers.
- Whichever `Stop` call queues a handoff owns that teardown's context; a later
`Stop` must not clobber it.

Expand Down Expand Up @@ -574,7 +574,7 @@ measured itself.

**Where the defects came from.** Five reviews in September 2026, preceded by
seven narrower passes. The first found eleven defects plus a gap it did not
count; the second six plus the `Worker`-hook overlap, and then two more the
count; the second six plus the worker overlap, and then two more the
tightened driver found on its own; the third six that were all cross-phase or
cross-branch -- a drain hook stopping a sibling scope, a release dropped with a
missed deadline, a shutdown cause published after `Run` had read it, a false
Expand Down Expand Up @@ -676,7 +676,7 @@ reverse is caught by the fuzzer in 0.06s and *not* by the 400 seeded sequences.
this harness loses a backgrounded server's startup output when redirected,
which once produced a false failure report.
- **A teardown finishes after `Stop` returns only when `Stop`'s context
expired** -- waiting for a `Worker` hook, a start step or a drain hook -- plus
expired** -- waiting for a worker, a start step or a drain hook -- plus
the one that undoes a build completing after the scope stopped, which no
`Stop` issued. The deadline bounds how long `Stop` waits, never whether the
release is owed, and `Stop` has already taken the instance off its scope's
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@ app.Wire[*Repo](NewRepo) // func NewRepo(*DB) *Repo
repo := app.Get[*Repo]() // builds Config, then DB, then Repo, each once
```

A registration takes `OnStart`, `OnStop` and `Worker` hooks typed on the
service, and `app.Run(ctx)` starts everything in dependency order, waits for
A registration takes `OnStart` and `OnStop` hooks and a `Go` worker, typed on
the service, and `app.Run(ctx)` starts everything in dependency order, waits for
a signal, and stops it in reverse. Child scopes hold what belongs to one
request or one test; a second registration of a key is rejected unless it
says `Override()`; and `Explain`, `Graph` and `Modules` show what was built,
Expand Down Expand Up @@ -132,7 +132,7 @@ registration and must be called before the scope is first resolved.
| `.Override()` | Replace an earlier registration of `T` in this scope; a second one without it is rejected. |
| `.OnStart(f)`, `.OnStop(f)` | Lifecycle hooks, `f` is `func(context.Context, T) error`. |
| `.OnDrain(f)` | Runs before anything is stopped, while the scope still resolves. |
| `.Worker(f)` | A long-running function, cancelled on stop. |
| `.Go(f)` | A worker: a long-running function in a goroutine of its own, cancelled on stop. |

To get a service back, call the scope, from a constructor or from outside:

Expand Down Expand Up @@ -398,11 +398,11 @@ keeps the handlers' scopes alive until they return.

#### Workers

`Worker` is for anything that loops until told to stop: consumers, pollers,
schedulers.
`Go` registers a worker, for anything that loops until told to stop:
consumers, pollers, schedulers. It is errgroup's `Go`, applied to a service.

```go
app.Wire[*Mailer](newMailer).Eager().Worker(func(ctx context.Context, m *Mailer) error {
app.Wire[*Mailer](newMailer).Eager().Go(func(ctx context.Context, m *Mailer) error {
return m.Loop(ctx) // returns when ctx is cancelled
})
```
Expand Down
18 changes: 10 additions & 8 deletions binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -378,19 +378,21 @@ func (b Binding[T]) OnStop(f func(context.Context, T) error) Binding[T] {
return b.edit(func(b *binding) { b.onStop = func(ctx context.Context, v any) error { return f(ctx, as[T](v)) } })
}

// Worker registers a long-running function for T, such as a consumer loop. It is
// started in its own goroutine once the service starts and its context is
// cancelled when the service stops; Stop waits for it to return, bounded by
// its own context. A hook that outlasts that deadline is reported by Stop,
// and OnStop then waits for it rather than releasing the value underneath a
// Go registers a worker for T: a long-running function, such as a consumer
// loop, that runs in a goroutine of its own for as long as the service does.
// It is the contract of errgroup's Go, applied to a service. The worker
// starts once the service has started; its context is cancelled when the
// service stops, and Stop waits for it to return, bounded by Stop's own
// context. A worker that outlasts that deadline is reported by Stop, and
// OnStop then waits for it rather than releasing the value underneath a
// worker still reading it.
//
// Returning a non-nil error calls Shutdown with it, stopping the application,
// even if the scope was already stopping: a worker may fail, flush while the
// scope winds down, and only then report. The exception is context.Canceled
// from a hook that was already cancelled, which is a worker reporting the
// cancellation and nothing else. A hook that wants to stay quiet during
// from a worker that was already cancelled, which is a worker reporting the
// cancellation and nothing else. A worker that wants to stay quiet during
// shutdown should return nil.
func (b Binding[T]) Worker(f func(context.Context, T) error) Binding[T] {
func (b Binding[T]) Go(f func(context.Context, T) error) Binding[T] {
return b.edit(func(b *binding) { b.worker = func(ctx context.Context, v any) error { return f(ctx, as[T](v)) } })
}
26 changes: 13 additions & 13 deletions concurrent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ package di_test
//
// C9 and C10 exist because the third review found two more the same way. The
// coverage the generators reached was 78% against the suite's 97%, and the
// whole gap was the lifecycle: no Worker hook, no Shutdown, no context expiring
// whole gap was the lifecycle: no Go hook, no Shutdown, no context expiring
// mid-Stop, and starts kept strictly before stops. Everything the reviews
// found lived in that gap. It is closed here.

Expand Down Expand Up @@ -414,7 +414,7 @@ func (m *cmachine) register(s *di.Scope, o op) {
}
}

// errWorker is a Worker hook failing of its own accord rather than because it
// errWorker is a Go hook failing of its own accord rather than because it
// was cancelled, which is what makes the container hand it to Shutdown. A
// worker that only ever returns nil leaves that whole path unreached.
var errWorker = errors.New("worker failed")
Expand Down Expand Up @@ -519,16 +519,16 @@ func reg[T any](m *cmachine, s *di.Scope, o op, stop func(context.Context, any)
switch o.reg % 6 {
case 0:
if o.wire {
b = s.Wire[T](wired).Worker(work).OnStop(down)
b = s.Wire[T](wired).Go(work).OnStop(down)
} else {
b = s.Provide(build).Worker(work).OnStop(down)
b = s.Provide(build).Go(work).OnStop(down)
}
case 1:
// Scoped through Wire puts a reflect.Call under every child build.
if o.wire {
b = s.Wire[T](wired).Scoped().Worker(work).OnStop(down)
b = s.Wire[T](wired).Scoped().Go(work).OnStop(down)
} else {
b = s.Provide(build).Scoped().Worker(work).OnStop(down)
b = s.Provide(build).Scoped().Go(work).OnStop(down)
}
case 2:
build := func(sc *di.Scope) T { return own(sc, dep(sc)) }
Expand All @@ -542,7 +542,7 @@ func reg[T any](m *cmachine, s *di.Scope, o op, stop func(context.Context, any)
wrapped = s.Provide(build)
}
b = wrapped.
Worker(work).
Go(work).
OnStop(func(ctx context.Context, v T) error {
// Slow enough that an impatient Stop misses its deadline
// here, which is the only way this driver reaches the
Expand All @@ -561,11 +561,11 @@ func reg[T any](m *cmachine, s *di.Scope, o op, stop func(context.Context, any)
// survived this driver.
b = s.Provide(func(sc *di.Scope) T { return owe(sc, build(sc)) }).
OnDrain(drainHook).
Worker(work).
Go(work).
OnStop(down)
case 4:
// The one shape with an OnStart, so its stop step is owed only when
// the start step succeeded. Its Worker hook is what puts a worker under
// the start step succeeded. Its Go hook is what puts a worker under
// a Stop that has to cancel it and wait.
b = s.Provide(build).
OnStart(func(_ context.Context, v T) error {
Expand All @@ -574,7 +574,7 @@ func reg[T any](m *cmachine, s *di.Scope, o op, stop func(context.Context, any)
m.owed.Store(any(v), m.holderOf(any(v)))
return nil
}).
Worker(work).
Go(work).
OnStop(down)
default:
// Scoped *and* draining. Without this shape the driver could not put
Expand All @@ -584,7 +584,7 @@ func reg[T any](m *cmachine, s *di.Scope, o op, stop func(context.Context, any)
// drain defect was unreachable for want of one registration.
b = s.Provide(func(sc *di.Scope) T { return owe(sc, build(sc)) }).Scoped().
OnDrain(drainHook).
Worker(work).
Go(work).
OnStop(down)
}
if o.override {
Expand Down Expand Up @@ -871,7 +871,7 @@ func (m *cmachine) run() {
// release it still owed has happened.
//
// The second half cannot be a WaitGroup. A release deferred past a missed
// deadline is issued by a goroutine that first waits for the Worker hook to
// deadline is issued by a goroutine that first waits for the Go hook to
// return, so between that hook finishing and the release starting there is a
// moment when no hook is running and the work is still owed. Polling for the
// owed set to empty is what closes that gap, and it keeps C9 to the property
Expand Down Expand Up @@ -1022,7 +1022,7 @@ func TestMachineConcurrentSeeds(t *testing.T) {
seeds := [][]byte{
{0, 1, 0, 1, 0, 1, 3, 0, 0, 0, 6, 1, 0, 0, 0, 6, 0, 0, 0, 0}, // scoped in gc, then stop c1 and root
{0, 0, 0, 2, 1, 5, 0, 0, 0, 0, 1, 1, 0, 0, 0, 6, 0, 0, 0, 0}, // start racing a resolve from a child
{0, 0, 0, 3, 1, 5, 0, 0, 0, 0, 6, 1, 0, 0, 0, 6, 0, 0, 0, 0}, // a Worker hook, then overlapping stops
{0, 0, 0, 3, 1, 5, 0, 0, 0, 0, 6, 1, 0, 0, 0, 6, 0, 0, 0, 0}, // a Go hook, then overlapping stops
{0, 0, 0, 4, 1, 1, 3, 0, 0, 0, 1, 1, 0, 0, 0, 5, 0, 0, 0, 0}, // late build racing Start's hook phase
{0, 3, 0, 0, 0, 1, 3, 0, 0, 0, 6, 3, 0, 0, 0, 6, 1, 0, 0, 0, 6}, // stop the grandchild and its ancestors
{0, 1, 0, 3, 0, 1, 1, 0, 0, 0, 6, 1, 0, 0, 0, 6, 0, 0, 0, 0}, // a drain hook in c1, then c1 and root stopped at once
Expand Down
5 changes: 3 additions & 2 deletions di.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@
// built. [Scope.Stop] first drains, which lets work already in flight finish
// while the scope still resolves, then stops child scopes, then services in
// reverse build order, and afterwards the scope refuses to resolve anything.
// [Binding.Worker] runs a long-lived function that is cancelled on stop.
// [Binding.Go] runs a worker, a long-lived function in a goroutine of its
// own, cancelled on stop.
// [Scope.Run] ties it together
// for a main function: start, wait for a signal or [Scope.Shutdown], stop
// with a deadline. [Scope.Observe] reports every step for logging and
Expand Down Expand Up @@ -144,7 +145,7 @@ const (
EventBuild EventKind = "build" // a constructor ran
EventStart EventKind = "start" // an OnStart hook ran
EventDrain EventKind = "drain" // an OnDrain hook ran
EventStop EventKind = "stop" // a Worker hook was cancelled and/or an OnStop hook ran
EventStop EventKind = "stop" // a worker was cancelled and/or an OnStop hook ran
EventShutdown EventKind = "shutdown" // Shutdown was called
)

Expand Down
11 changes: 6 additions & 5 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ stateDiagram-v2
Built --> Starting: the scope is running, so OnStart runs
Starting --> Started: OnStart returned
Starting --> Failed: OnStart failed or panicked
Started --> Stopped: Stop, after OnDrain and any Worker
Started --> Stopped: Stop, after OnDrain and any worker
Built --> Stopped: Stop, no OnStart was owed
Failed --> [*]: served as an error to every later resolution
Stopped --> [*]
Expand Down Expand Up @@ -254,8 +254,8 @@ nothing is torn down while something that depends on it is still alive. And
uses to finish in-flight requests: those requests still hold their scopes and
everything under them, which would not be true from `OnStop`.

`Stop` is synchronous. It waits for start steps, drain hooks and `Worker`
functions it has cancelled. The single exception is its own context expiring,
`Stop` is synchronous. It waits for start steps, drain hooks and the workers
it has cancelled. The single exception is its own context expiring,
in which case the missed deadline is reported to the caller and the release
finishes on its own goroutine, reaching observers either way. A second
`Stop`, concurrent or later, does not run a second teardown: it waits for the
Expand All @@ -277,14 +277,15 @@ cancels the stop context, so a hung hook cannot keep the process alive.
```
Run(ctx) ── Start ──► running ──┬── SIGINT / SIGTERM ──┐
├── s.Shutdown(cause) ─┼──► Stop(timeout) ──► return cause
└── a Worker returned ─┘ and every stop error
└── a worker returned ─┘ and every stop error
```

`Shutdown(cause)` never blocks, may be called from any goroutine, and
propagates to ancestor scopes, so a service in a child can stop the
application. The first cause wins and is what `Run` returns.

A `Worker` is a function that runs for as long as its service does. It is
A worker, registered with `Go`, is a function that runs for as long as its
service does, which is errgroup's `Go` applied to a service. It is
started in its own goroutine as part of the start step, its context is
cancelled by `Stop`, and `Stop` waits for it to return before `OnStop` runs
and before anything it depends on is released. A worker that returns its own
Expand Down
2 changes: 1 addition & 1 deletion drain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -485,7 +485,7 @@ func TestReview3DrainHookCanStopASiblingScope(t *testing.T) {
// A Stop whose context runs out while another Stop's drain hook holds the
// instance still owes the release: it took the instance off the scope's list,
// so nothing else will reach it. The release is finished off the hook's own
// return, as it is for a Worker hook that outlasts the same deadline.
// return, as it is for a Go hook that outlasts the same deadline.
// (review 3, 2)
func TestReview3LostDrainWaitStillReleases(t *testing.T) {
root := di.New()
Expand Down
4 changes: 2 additions & 2 deletions example_features_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@ func ExampleBinding_Scoped() {

type Queue struct{ jobs chan string }

func ExampleBinding_Worker() {
func ExampleBinding_Go() {
app := di.New()
done := make(chan string, 1)
app.Provide(func(*di.Scope) *Queue { return &Queue{jobs: make(chan string, 1)} }).Eager().
Worker(func(ctx context.Context, q *Queue) error {
Go(func(ctx context.Context, q *Queue) error {
for {
select {
case job := <-q.jobs:
Expand Down
4 changes: 2 additions & 2 deletions examples/app/main.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// A complete service: request scopes through middleware, a background
// worker with a Worker hook, a health endpoint, and graceful shutdown.
// worker with a Go hook, a health endpoint, and graceful shutdown.
package main

import (
Expand Down Expand Up @@ -49,7 +49,7 @@ func main() {
// DB it depends on is closed. Returning an error stops the application.
app.Wire[*Mailer](func(*DB) *Mailer { return &Mailer{queue: make(chan string, 16)} }).
Eager().
Worker(func(ctx context.Context, m *Mailer) error {
Go(func(ctx context.Context, m *Mailer) error {
for {
select {
case msg := <-m.queue:
Expand Down
2 changes: 1 addition & 1 deletion examples/guide/internal/mail/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,5 @@ func (m *Mailer) Run(ctx context.Context) error {
func Module(s *di.Scope) {
s.Wire[*Mailer](New).
Eager().
Worker(func(ctx context.Context, m *Mailer) error { return m.Run(ctx) })
Go(func(ctx context.Context, m *Mailer) error { return m.Run(ctx) })
}
Loading
Loading