Skip to content

fix(app): exit 128+signum on SIGTERM so a kill is not a clean exit - #121

Merged
setkyar merged 1 commit into
mainfrom
fix/sigterm-exit-code
Sep 17, 2026
Merged

setkyar merged 1 commit into
mainfrom
fix/sigterm-exit-code

Conversation

@setkyar

@setkyar setkyar commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Main used signal.NotifyContext(..., os.Interrupt, syscall.SIGTERM) and then returned normally, so a SIGTERM kill was indistinguishable from a voluntary quit in every signal an operator has:

  • launchd reports last exit code = 0 — identical to "user stopped it"
  • a wait-based supervisor sees status 0 and logs a voluntary exit

That ambiguity is what made #112 take hours to diagnose: hundreds of kickstart -k kills that all read as clean exits, sending the reporter through launchd environment, stdin EOF, ThrottleInterval, plist churn, and bind conflicts before the actual cause. The storm is fixed (#113), but the diagnostic gap would cost the next person the same time for any other kill source.

This switches to an explicit signal.Notify channel so the signal is known, logs shutting down: received <signal>, and exits 128+signum (143 for SIGTERM, 130 for SIGINT).

Supervisor impact:

  • systemdinit/pi-web.service gains SuccessExitStatus=130 143, so systemctl stop still records a clean stop and Restart=on-failure does not fire. install.sh copies the unit verbatim and reinstalls it when it changes, so existing installs pick this up on the next upgrade. Until they do, an external kill -TERM (not systemctl stop, whose intent systemd tracks) would count as a failure and trigger a restart on those hosts.
  • launchdKeepAlive=true in com.pi-web.plist restarts regardless of exit status, so macOS behavior is unchanged; only the recorded exit code becomes informative.
  • Hand-run — Ctrl-C now exits 130 rather than 0, which is the conventional shell behavior.
  • e2estopServer in e2e/lib/server.ts sends SIGTERM and does not inspect the exit code.

Two consequences of the rework, both deliberate:

  • Main now waits for the shutdown goroutine rather than returning as soon as ListenAndServe reports ErrServerClosed. Previously the process could exit while srv.Shutdown() and manager.Close() were still running. (httpServer.Shutdown has exactly one call site, so the wait cannot hang.)
  • Exiting via os.Exit skips deferred functions, so the state-file unlink is now a named closure called from both the defer and the signal path. Without that, every SIGTERM would strand pi-web-state.json and the next start would report another pi-web instance appears to be running.

docs/sequence-flows/server-startup.md is updated to match.

Related issue

Closes #119

(Split out of #112, secondary finding 3, reported by @laulpogan.)

Type of change

  • fix — bug fix

Live vs. Export

  • Not applicable — this PR doesn't touch session rendering

Testing

  • make check passes (test + build + vet)
  • Frontend tests (vitest) cover the change
  • Go tests (go test ./...) cover the change
  • UI changes verified in a browser

No automated test: the behavior is process-level (signal → exit status), and the test suite has no harness that spawns and signals the built binary. Verified by hand against the built binary (macOS, isolated PI_CODING_AGENT_DIR, port 31999):

signal exit code stderr state file after
SIGTERM 143 shutting down: received terminated removed
SIGINT 130 shutting down: received interrupt removed

Reproduce with:

d=$(mktemp -d); mkdir -p "$d/sessions"
PI_CODING_AGENT_DIR=$d ./pi-web -p 31999 & pid=$!
sleep 1; kill -TERM $pid; wait $pid; echo "exit=$?"; ls "$d/pi-web"

The state-file check matters: an earlier build of this branch, before the releaseStateFile closure was shared with the signal path, left pi-web-state.json behind on every SIGTERM — confirmed by running that build. The systemd SuccessExitStatus change is the remaining unexercised part and still wants a look from someone on a systemd host, since CI cannot cover it.

Main used signal.NotifyContext and then returned normally, so a SIGTERM
kill was indistinguishable from a voluntary quit in every signal an
operator has: launchd reports `last exit code = 0`, and a wait-based
supervisor sees status 0. That is what made the restart storm in #112
take hours to find -- hundreds of kills that all looked like clean exits.

Use an explicit signal.Notify channel so the signal is known, log
"shutting down: received <signal>", and exit 128+signum (143 for SIGTERM,
130 for SIGINT). init/pi-web.service gains SuccessExitStatus=130 143 so
systemd still treats those as a clean stop; launchd's KeepAlive=true
restarts regardless of exit status, so macOS behaviour is unchanged.

Two consequences of the rework:

- Main now waits for the shutdown goroutine instead of returning as soon
  as ListenAndServe reports ErrServerClosed, which previously let the
  process exit while srv.Shutdown() and manager.Close() were still running.
- Exiting via os.Exit skips deferred functions, so the state-file unlink
  is now a named closure called from both the defer and the signal path.
  Without that, every SIGTERM would strand pi-web-state.json and the next
  start would report "another pi-web instance appears to be running".

Closes #119
@setkyar
setkyar merged commit 4aa7290 into main Sep 17, 2026
6 checks passed
@setkyar
setkyar deleted the fix/sigterm-exit-code branch September 17, 2026 09:25
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.

Exiting 0 on SIGTERM makes a kill indistinguishable from a clean shutdown

1 participant