Skip to content

fix(supervisor): stopping an app stops the processes it started - #39

Merged
TeoSlayer merged 6 commits into
mainfrom
fix/stop-app-process-group
Oct 1, 2026
Merged

TeoSlayer merged 6 commits into
mainfrom
fix/stop-app-process-group

Conversation

@TeoSlayer

Copy link
Copy Markdown
Contributor

Problem

Every way an app is stopped goes through one path: cancelling the context of its exec.CommandContext. That covers daemon shutdown (StopPlugins), pilotctl appstore uninstall (the rescan cancels the app), and a rescan that replaces an app. By default, exec's Cancel SIGKILLs only the app's own pid. Any process the app had started got reparented to launchd/init and kept running, and nothing would ever stop it. reapStale can't catch these because it matches the app's binary and socket in argv, and a subprocess doesn't carry them.

Reproduced on origin/main (79e9944) with a test app whose script runs sleep 300 &, then stopping it:

macOS:  zz_grandchild_probe_test.go:49: subprocess pid=78281 alive after app stop: true
Linux:  zz_grandchild_probe_test.go:49: subprocess pid=2238 alive after app stop: true

Fix

Apps already lead their own process group (Setpgid), and the other kill paths (reapStale, watchSocket) already signal the group. spawn now sets cmd.Cancel to killAppGroup, which sends SIGKILL to the whole group. It falls back to the pid alone (today's behaviour) when the group can't be signalled.

  • The signal is the same as before (SIGKILL), so apps see no behaviour change. It now also reaches the rest of the group.
  • Cancel runs before Wait reaps the app. The app's pid, which is also the group id, is still held (by the process or its zombie), so it can't have been reused.
  • A subprocess that moved itself into its own group or session (setsid) is not touched.

Tests

  • TestStopKillsAppSubprocesses starts an app that backgrounds sleep 300, stops it, and asserts the sleep is gone. With the fix removed it fails on macOS and on Linux (subprocess pid=... outlived the app it belongs to). With the fix it passes. A zombie counts as gone, because a container's pid 1 may never reap it.
  • Second commit (test-only): startFakeApp looped sleep in /bin/sh. Each forked sleep carries the shell's argv until its exec, so reapStale could count a third instance (seen in a Linux container: reapStale = [2110 2111 2113], want both pids 2110 and 2111). macOS's /bin/sh shim also re-execs bash, and a scan during that re-exec can miss an instance. The fake is now the test binary blocked on a pipe, which never forks or re-execs.

Runs:

  • macOS arm64: go test -race ./... passes; full plugin/appstore 3/3 runs; reap, watchSocket, hard-death and stop tests 10x -count.
  • Linux arm64 (golang:1.25.13-bookworm, with and without --init): go test -race ./... 5/5 plus 1.

Not covered here (remaining gap)

When the daemon dies hard on Linux, Pdeathsig kills the app itself but not the processes the app started. reapStale can't find those later, because the leader is gone and their argv doesn't match. Probe on origin/main: reapStale reaped=[]; subprocess pid=2236 alive: true. On macOS the same scenario is cleaned up, because reapStale kills the orphaned leader's whole group. A pid-reuse-safe fix needs a design choice (for example a marker the subprocesses inherit), so it's left out of this PR.

web4 picks this up with an app-store release and a go.mod bump (currently v1.0.3).

🤖 Generated with Claude Code

teovl and others added 2 commits September 24, 2026 10:11
Daemon shutdown (StopPlugins), `pilotctl appstore uninstall` (rescan
cancels the app) and a rescan that replaces an app all stop it by
cancelling the context of its exec.CommandContext. exec's default Cancel
SIGKILLs only the app's own pid, so every process the app had started
was reparented to launchd/init and kept running with nothing left that
would ever stop it (reapStale matches an app's binary and socket in
argv, which a subprocess does not carry).

Apps already lead their own process group (Setpgid), and the other kill
paths (reapStale, watchSocket) already signal the group. The app's
exec.Cmd.Cancel now does the same: SIGKILL to the whole group, falling
back to the pid alone, as before, when the group cannot be signalled.
Cancel runs before Wait reaps the app, so the pid, which is the group
id, is still held and cannot have been reused. A subprocess that moved
itself to its own group or session (setsid) is not touched.

TestStopKillsAppSubprocesses starts an app whose script backgrounds a
`sleep 300`, stops it, and asserts the sleep is gone. It fails on
origin/main on macOS and Linux (verified) and passes with this change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
startFakeApp ran `/bin/sh -c 'while :; do sleep 1; done' <argv>`. Each
`sleep` the loop forks carries the shell's argv until its exec, so
reapStale could count a third instance (seen in a Linux container:
"reapStale = [2110 2111 2113], want both pids 2110 and 2111"), and
macOS's /bin/sh shim re-execs bash, during which a scan can miss one.

The fake is now this test binary (APPSTORE_TEST_FAKE_ORPHAN), blocked
reading a pipe the test holds open: its argv is final once Start
returns, and it never forks. Test-only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 77.27273% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
plugin/appstore/reap.go 72.22% 3 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

teovl and others added 2 commits September 24, 2026 10:46
… group

Covers the fallback branch: a process that does not lead a process group
is still killed, as exec's default Cancel would.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r a 3 s grace

Builds on killAppGroup. Every stop (daemon shutdown, uninstall, a rescan
that replaces the app) still SIGKILLed the app at once, so the app never
ran its shutdown path. For io.pilot.wallet that means in-flight calls are
cut off between the ledger write and the spend-cap log append, the sqlite
ledger is never closed, and app.sock is left on disk.

cmd.Cancel is now stopAppGroup. It SIGTERMs the group the app leads
(falling back to the pid), waits up to appStopGrace (3 s) for the app to
exit, then SIGKILLs the group through killAppGroup. The group is only
signalled while the app is unreaped, so its pid (the group id) is still
held. The daemon's plugin stop budget is 5 s and apps stop in parallel.

Also:
- A stop now reports the app's real exit status instead of "wait:
  context canceled" / -1.
- watchSocket ignores a socket that disappears after the stop began. The
  app removing it on SIGTERM is a normal shutdown, not a "socket-lost"
  event.

Tests (zz7_stop_graceful_test.go). With the Cancel line reverted to
killAppGroup:
- TestStopLetsAppShutDown fails: "app never received SIGTERM", "socket
  ... left behind", "spawn returned -1".
- TestStopKillsAppThatIgnoresSIGTERM fails: "returned after 1.558ms;
  expected the 3s grace".
Both pass with this change. TestStopKillsAppSubprocesses still passes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fix(supervisor): SIGTERM an app's process group on stop, SIGKILL after a 3 s grace
@TeoSlayer
TeoSlayer merged commit e62c33b into main Oct 1, 2026
4 checks passed
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.

2 participants