Files
bdbda89f3e fix(swarm): close lifecycle admission on shutdown (#776)
* fix(swarm): close lifecycle admission on shutdown

* fix(swarm): admit lifecycle work without holding the lock across Launch

Addresses the P1 on #776 plus the two documentation P3s.

[P1] beginLifecycleAdmission returned with lifecycleMu.RLock() held for the
caller's entire duration — including the call into the injected
MemberLauncher.Launch. Close needs the write lock before it can call s.cancel(),
so a launcher that blocks in Launch waiting on its context (the ordinary shape of
a launcher that waits for a slot, a daemon connection, or a sandbox handshake)
deadlocked every Close caller: the launch held the read lock while Close waited
for the write lock to reach the very cancellation the launch needed to unblock.

beginLifecycleAdmission is now a counted ticket: it holds lifecycleMu only across
the closed check and a lifecycleWork.Add(1), then returns a release func
(lifecycleWork.Done) the caller defers. Close flips closed and releases the lock
BEFORE calling cancel, then waits out lifecycleWork (admitted-but-unfinished
work) followed by watchers (goroutines admitted work may still be starting) — so
it remains a true completion barrier without holding any lock across external
launcher code. Every call site (Spawn, Handoff, AdoptOrphans, the retry path in
watch, and afterExit) moved to the ticket pattern.

[P3] beginLifecycleAdmission's contract and the Scheduler/Close handoff are now
both documented at the point that matters: the func-returning signature makes
the release contract explicit in the type itself, and Scheduler's comment spells
out why the read/write lock ordering guarantees exactly one of Scheduler or
Close closes whatever scheduler ends up installed.

Tests: TestCloseCancelsLauncherBlockedInLaunch reproduces the exact deadlock
(spawn parked in Launch, Close in a second goroutine, both must complete once
cancellation reaches the launcher) — verified it hangs under the old held-lock
pattern by temporarily restoring it. TestCloseWaitsForAdmittedSpawn is the
converse: Close must not return while an admitted spawn is still running.

Validated with `go test -race ./internal/swarm/... -count=1` under WSL/Linux
(this session's Windows host has no C toolchain for -race directly), plus
`go vet ./...` under WSL and go build/vet/test on Windows.

* fix(swarm): fail late queue admissions and dequeues after shutdown

Addresses jatmn's two #776 review findings:

- Close swept (clearQueue) every team's queue before waiting for
  admitted-but-in-flight lifecycle operations to finish. A Spawn,
  Handoff, or AdoptOrphans call that got its ticket right before
  closed flipped could still append to a team's queue after the
  sweep ran, stranding that spec forever (never launched, since
  shutdown began; never failed, since the sweep won't run again).
  Reorder Close to wait for lifecycleWork before sweeping queues, so
  every queue append from already-admitted work is visible to the
  sweep.

- afterExitAdmitted dequeued a queued spec (t.onExit) and launched it
  unconditionally once its own ticket was granted, even though Close
  can flip closed in the gap between that dequeue (which makes the
  spec invisible to the clearQueue sweep) and the launch. Re-check
  closed after dequeuing so a spec pulled off the queue after
  shutdown began fails with ErrSwarmClosed instead of starting a new
  worker.

Both regression tests are verified to fail against the pre-fix code
(confirmed by temporarily reverting each fix and rerunning).

Co-Authored-By: Claude Sonnet 5 <[email protected]>

* fix(swarm): snapshot teams after lifecycle work stops

Amp-Thread-ID: https://ampcode.com/threads/T-019fa018-a4d5-7078-a80b-802bab23607e

Co-authored-by: Pierre Bruno <[email protected]>

* fix(swarm): reject late admitted launches during shutdown

Re-check the closed state at the final launcher boundary so lifecycle work admitted before Close cannot start or retry a member after shutdown has already won the race.

Add deterministic late-dispatch and scheduler-handoff coverage, stress concurrent Spawn and Close under the race detector, and keep the late-team snapshot regression isolated from launcher behavior.

Amp-Thread-ID: https://ampcode.com/threads/T-019fa55a-4f7f-766e-a312-a7db232b9987

Co-authored-by: Pierre Bruno <[email protected]>

* fix(swarm): reject post-close launch commits

Amp-Thread-ID: https://ampcode.com/threads/T-019fa7a0-1223-701d-9529-48ba5d7cf8c8

Co-authored-by: Pierre Bruno <[email protected]>

* fix(swarm): gate retry relaunch on the post-close commit

The post-Launch shutdown gate added for spawns and queue drains was missing
from the bounded-retry path, which called the launcher and swapped in the new
handle unguarded. Since FuncLauncher returns a live handle immediately without
consulting its context, a Close landing between the pre-check and Launch's
return left the watcher supervising a member relaunched after shutdown began —
and recording that member's outcome on the task.

Extract the commit gate as commitLaunch and route the relaunch through it via
relaunchAdmitted, so an initial launch and a retry pass the same three gates:
admission, the pre-Launch re-check, and the post-Launch commit. When shutdown
wins, the handle is reaped and the watcher records the terminal failure instead
of adopting it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

---------

Co-authored-by: Claude Sonnet 5 <[email protected]>
Co-authored-by: Amp <[email protected]>
2026-07-29 09:46:25 +08:00
..