Skip to content

fix(dev): stop dev.ts signal handlers preempting lifecycle shutdown (#459)#491

Merged
markhayden merged 5 commits into
mainfrom
fix/dev-shutdown-signal-ordering
Jun 12, 2026
Merged

fix(dev): stop dev.ts signal handlers preempting lifecycle shutdown (#459)#491
markhayden merged 5 commits into
mainfrom
fix/dev-shutdown-signal-ordering

Conversation

@markhayden

Copy link
Copy Markdown
Owner

Summary

Closes #459 (defect 1 — the last open half; defect 2 + the singleton lock shipped in #465).

scripts/dev.ts registers SIGINT/SIGTERM handlers before await import('../server'), and signal listeners run in registration order — so its synchronous process.exit(0) preempted lifecycle.ts's graceful chain (plugins → dispatch → watcher → search.shutdown() → SSE → HTTP → ledger → server lock). The antfly child survived parent death and squatted its port; overlapping half-dead generations produced phantom UI behavior.

Changes

  • New scripts/dev-shutdown.ts (registerDevShutdown, DI for testability): always kills the tailwind child, but only owns process.exit(0) while it is the sole listener on the signal (build phase, via listenerCount). Once lifecycle.registerShutdownHandlers() registers on the same signals, the dev handler falls through and the lifecycle listener runs the full graceful chain and owns the exit.
  • Escape hatch: a second SIGINT/SIGTERM during a hung shutdown warns and force-exits (130/143) — bun run dev is never unkillable at the terminal.
  • Tests: tests/scripts/dev-shutdown.test.ts (8 cases, fake proc — real signals would kill the runner), including a regression test asserting the later-registered lifecycle listener actually runs.
  • Docs: Shutdown-ordering section in .claude/knowledge/dev-loop.md; spec + plan under .claude/specs/.

Out of scope (owned by #457, not duplicated here): the adapter-side sync exit hook, the antfly/ watcher ignore, and the boot-window gap before handler registration. Also surfaced but untouched: a pre-existing leak where killing the tailwind bunx wrapper orphans the underlying node tailwindcss grandchild (documented in dev-loop.md).

Verification

  • bun run dev → ready → kill -TERM → full lifecycle chain logged ("Stopping Antfly server..." → "Antfly stopped"), antfly child dead, ports 3737/3738 free
  • SIGINT during the build phase → prompt exit, nothing leaked
  • Double SIGTERM during shutdown → warning logged, process dead ~46ms later (vs the 1s graceful timer), confirmed force path via missing "Shutdown complete" in server.log
  • Full suite: 4986 pass / 0 fail; typecheck clean

🤖 Generated with Claude Code

markhayden and others added 5 commits June 11, 2026 19:39
New scripts/dev-shutdown.ts: signal handling for the dev loop that only
owns process exit while it is the sole SIGINT/SIGTERM listener (build
phase). Once lifecycle.registerShutdownHandlers() registers on the same
signals, the dev handler kills tailwind and falls through so the full
graceful chain (including search.shutdown() -> antfly stop) runs. A
second signal forces exit 130 as the escape hatch for a hung shutdown.

Process object is dependency-injected for unit testing (emitting real
signals would kill the test runner). dev.ts wiring lands separately.

Part of #459 (defect 1). Spec: .claude/specs/dev-shutdown-signal-ordering.md

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
scripts/dev.ts's old signal handlers called process.exit(0) synchronously.
They register before server.ts imports, and signal listeners run in
registration order — so lifecycle.ts's graceful shutdown (which stops the
antfly child via search.shutdown()) never ran under bun run dev, leaving
:3738 orphaned. registerShutdown() now delegates to registerDevShutdown():
tailwind is always killed, but exit is only owned while dev.ts is the sole
listener (build phase); a second signal forces exit 130.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add a Shutdown ordering section to .claude/knowledge/dev-loop.md covering
the build-phase/post-boot exit ownership split, the second-signal escape
hatch, the accepted pre-registration gap (covered by PR #457), and the
pre-existing tailwind bunx-wrapper grandchild leak. Check in the approved
implementation plan alongside the spec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Assert the lifecycle-style listener registered after the dev handler
actually runs when a signal arrives — the core defect was the dev
handler exiting before it could. Also assert the force-exit warning
names the signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Force-exit code follows the second signal (130 SIGINT / 143 SIGTERM)
- Name the two-registrant invariant at the listenerCount check
- Fix the misleading exit-hook test: the module does not dedupe
  killTailwind; assert the double call and note dedup is the caller's
  .killed guard in dev.ts
- Import order: node builtin before bun:test

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@markhayden markhayden merged commit 4dd7295 into main Jun 12, 2026
1 check passed
@markhayden markhayden deleted the fix/dev-shutdown-signal-ordering branch June 12, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dev loop leaves orphaned processes: dev.ts signal handlers preempt lifecycle shutdown; unhandled EADDRINUSE crashes after full boot

1 participant