Skip to content

fix(#6044): make grafel stop actually stop grafel — service-aware, persistent, honestly confirmed - #6049

Merged
cajasmota merged 2 commits into
mainfrom
fix/6044-service-aware-stop
Jul 30, 2026
Merged

fix(#6044): make grafel stop actually stop grafel — service-aware, persistent, honestly confirmed#6049
cajasmota merged 2 commits into
mainfrom
fix/6044-service-aware-stop

Conversation

@cajasmota

Copy link
Copy Markdown
Owner

grafel stop did not stop grafel. It sent the Stop RPC, the daemon obeyed, and launchd immediately respawned it — the LaunchAgent declares an unconditional KeepAlive. The command printed stop requested and returned 0 either way, so the user saw the service still running and reasonably concluded the command was broken.

Reported by a user, reproduced four times on macOS. The asymmetry is the root cause: grafel start is service-aware (OS service detected for this daemon; restarting via the OS service manager); stop was not. start went through launchd, stop went around it.

03:30:11  serve=72746 (up since 03:05:47)
03:30:20  $ grafel stop  →  "stop requested"  (rc=0)
03:30:21  serve=82993   ← respawned ~1s later

Three stop calls inside the same second returned stop requested / daemon not running / stop requested — launchd resurrecting the daemon between consecutive calls. That flapping is what a user experiences as "sometimes it stops it, and it never stays down".

stop is now persistent, and that decision is deliberate

The issue asked for this to be chosen rather than defaulted into. bootout stops the job now but the plist still loads at login, so macOS pairs it with launchctl disable; Load() unconditionally enables before bootstrap so the ordinary install/start/restart cycle is unaffected. systemd's Unload() was already disable --now and schtasks's already deletes the registration, so all three platforms now agree: stop means stay down; grafel start is the way back. grafel stop --help says so explicitly.

Confirmation is on the socket, not the job registry

The first implementation confirmed via Status(), which on darwin derives Running purely from launchctl list <label>'s exit code — is the job loaded in the domain, not is the process alive. After bootout the job is gone by definition, making the check very nearly a tautology, and blind to a manually-started daemon outside the domain entirely.

Concretely: a plist on disk plus a hand-started daemon routes to the service path (serviceInstalledForThisRoot only stats the plist). Unload() gives up after 3s with errors ignored, bootout no-ops on an unloaded job with its exit status discarded, and stop printed "daemon stopped" over a live daemon.

stopConverge now polls Probe() — the real socket — via waitStopped, mirroring the existing waitReady. Pinned by TestStopConverge_TrustsProbeNotStatus, whose fixture composes probeAlwaysUp with loaded:false so it genuinely models "Status says down, socket says up", and which asserts its own fixture precondition before the real assertion.

The persistence decision is now falsifiable

launchctlBootout/Bootstrap/Disable/Enable are package vars. Before that seam existed, deleting both the disable and enable calls outright left the entire suite green — the diff's most consequential decision was unpinned. Unload records the outcome on lastDisableErr and stopService reads it, so the CLI no longer prints "even across reboot" over a disable whose error it discarded.

Two callers of the new persistence that the first pass missed: uninstall now re-enables the label after teardown rather than leaving a launchd override with no plist to explain it, and install.sh's restart_daemon detects "plist present but not loaded" and calls enable before bootstrap instead of silently giving up on any machine where stop had been used.

The orphaned-engine window

A hard exit of serve orphaned its engine child — ppid flips to 1 — while launchd spawned a replacement alongside it: two process trees against one state directory, the shape that produces torn writes and doubled memory. Drain signals are now group-directed.

groupOrSingleSignal checks Getpgid(pid) == pid before negating. The #5999 precedent (sched/nice_unix.go) was verified rather than cited: it is safe there because Cancel runs strictly before Wait frees the pid. This supervisor's drain races a separate reaping goroutine and has no such ordering, so without the leadership check a lost race would signal whatever group inherited the reused pid. A microsecond TOCTOU window remains and is documented as such — a reuse race is not constructible in a test, and the comment says so rather than implying coverage.

defaultEngineChildCommand's cmd.Env = os.Environ() and its load-bearing comment are byte-identical; git diff -- internal/daemon/supervise.go is empty.

Verification

go build ./... && go vet ./... && gofmt -l . clean. 30 packages green at raw exit 0 across internal/cli, internal/daemon, cmd/grafel, internal/install. -race clean on the service and supervisor packages.

Mutation battery rebuilt against the final tree; every behavioural change dies individually. Two mutations that survived the first round now die, each naming its hole: reverting stopConverge to Status()-only, and deleting the disable/enable calls.

A false-pass fixture was found, disclosed by the author, and independently reproduced. The original SIGKILL test used a plain sleep grandchild, which died to the group SIGTERM before the drain timeout ever elapsed — so it passed regardless of whether signalKill was group-directed. The grandchild now does trap '' TERM; exec sleep 300 (SIG_IGN survives exec), and the escalation branch is genuinely reached: 0.59s blind versus 6.63s biting. Reverting the trap alone, with signalKill still broken, restores the false pass — which is how the fixture's load-bearing element was confirmed rather than assumed. signalTerminate and signalKill now have independent pins; mutating either fails only its own tests.

Known and disclosed

  • Probe() proves socket-death, not process-death. listener.Close() precedes the remaining graceful tail, so waitStopped can return before the process is gone — the issue's own worst case, a stalled RPC mid-index. Bounded by defaultShutdownWatchdog = 5s, which force-exits the whole tail, so the window is ≤~5s. The definitive axis (ReadPIDFile + pidStillAlive, already used by runDaemonRestart) is in-tree and unused here. Follow-up, not a blocker.
  • The Linux half of the installer asymmetry is unaddressed. A disabled systemd unit still appears in list-unit-files and still restarts, so an upgrade brings the daemon back running-but-still-disabled-at-boot, while macOS now fully reverts the stop. Neither tells the user their deliberate stop was undone. Follow-up.
  • waitStopped's budget is 60s against the RPC path's 5s. Success is fast (the first probe short-circuits); a stubborn daemon now blocks for a minute before reporting. Correct-over-fast, but the asymmetry is unexplained.
  • The watchdog force-exit can still orphan the engine — the pre-existing hard-exit case the issue itself calls out, orthogonal to the group-signal fix, which only applies when the drain actually runs.

Independently adversarially reviewed across two rounds; five of six findings verified closed by execution.

Closes #6044

`grafel stop` only sent a Stop RPC and reported success unconditionally;
when the daemon runs as an OS service (launchd/systemd/schtasks), the
service manager's own keep-alive/restart respawned it before the caller
could observe anything, and launchd's unconditional KeepAlive made this
happen almost instantly. `start`/`restart` already routed through the
service manager when one was installed for this root — `stop` did not.

- internal/daemon/service: add Stop/stopService/stopConverge. It Unloads
  (no re-Load) and confirms via Status() that the daemon is actually down
  before returning success, returning a genuine error otherwise (same
  defect class as #5991's `grafel reset`).
- Semantics chosen: stop is PERSISTENT — it survives the next
  login/reboot, matching what systemd's `disable --now` and schtasks task
  deletion already do in Unload(). macOS's bootout alone only clears the
  current session (RunAtLoad still fires at next login), so
  launchd_darwin.go now pairs bootout with `launchctl disable`, and Load()
  unconditionally `enable`s before bootstrap so the ordinary
  install/start/restart Unload;Load cycle is unaffected. `grafel start`
  documents itself as the way back; help text on `stop` states this
  explicitly.
- internal/cli/watcher_ctl.go: runDaemonStop now checks
  serviceInstalledForThisRoot() and routes to serviceStopForThisRoot
  (mirrors serviceRestartForThisRoot) when installed. The RPC-only path
  (manually-started foreground daemon) now polls for the socket to
  actually disappear before reporting success, instead of printing "stop
  requested" and returning 0 regardless. runDaemonRestart tolerates the
  new errStopNotConfirmed sentinel exactly like ErrDaemonNotRunning, since
  it performs its own more patient pid-based wait/SIGKILL escalation right
  after.
- internal/daemon/supervise_unix.go / supervise.go: the engine child's
  drain signals (SIGTERM, and the SIGKILL escalation) are now
  group-directed (kill(-pid)), following the #5999 precedent in
  sched/nice_unix.go. Setpgid alone isolates the child from serve's own
  signal group but a single-pid signal still leaves any grandchildren the
  engine child forks running as orphans after drain — group-directed
  closes that gap (issue #6044 item 4).

Mutation-tested: reverting each behavioural change individually (the
service-aware gate, the RPC confirmation, stopConverge's Status check, and
the group-directed signal) produces a real test failure, not a build
failure, in every case.

go build ./... && go vet ./... && gofmt -l . all clean.
rtk proxy go test ./internal/cli/... ./internal/daemon/... ./cmd/grafel/... exit=0.
…persistence, fix pid-reuse gap

Fixes for all 7 findings from the independent review of fix/6044-service-aware-stop:

1. HIGH (blocking) — stopConverge confirmed on the wrong axis. On darwin,
   Status().Running came from `launchctl list`'s exit code — "is the job
   loaded in the domain", not "is the daemon alive". A daemon started
   manually (never loaded in the domain) made bootout a no-op and Status()
   say "not running" while the daemon was still live, so `grafel stop`
   would have printed "daemon stopped" over it. stopConverge now polls
   Probe() (the actual socket) via a new waitStopped — the mirror image of
   the existing waitReady — before declaring success.

2/3. Persistent-stop decision had zero test coverage, and the CLI's
   "survives reboot" claim was unconditional even though only a
   discarded-error `launchctl disable` call made it true. launchctl
   bootout/bootstrap/disable/enable are now package vars
   (launchctlBootout/Bootstrap/Disable/Enable) so Unload/Load's full
   behavior is testable without ever touching the real
   gui/$UID/com.grafel.daemon. Unload records the disable outcome on
   launchdManager.lastDisableErr; stopService reads it and returns a
   genuine failure (not a lying success message) when persistence could
   not be confirmed.

4. SIGKILL escalation's group-directedness was unpinned — the drain test's
   helper died on the first SIGTERM, so it never exercised signalKill at
   all. Added a helper that ignores SIGTERM (forcing the drain timeout and
   the SIGKILL escalation to actually fire) plus a narrow
   TestSignalKill_IsGroupDirected unit test. Also fixed a latent flaw in
   the original grandchild fixture: a plain `sleep` grandchild died to the
   (already-correct) group-directed SIGTERM before the SIGKILL branch was
   ever reached, so the escalation test passed regardless of signalKill's
   correctness. The grandchild now also traps SIGTERM.

5. Group-kill pid-reuse race: terminateChild's SIGKILL escalation races
   cmd.Wait() on a separate goroutine — unlike the #5999 precedent, whose
   Cancel hook is ordered strictly before Wait frees the pid. A blind
   kill(-pid) could hit a just-reused pid's unrelated process group. Added
   a Getpgid(pid) == pid check before negating; falls back to a
   single-pid signal otherwise.

6. Two uncovered callers of the new persistence: `grafel uninstall` now
   re-enables the label after teardown (a full uninstall shouldn't leave a
   disabled-override with no plist to explain it), and install.sh's
   restart_daemon now detects "plist present but not loaded" (exactly what
   `grafel stop` leaves behind) and calls `launchctl enable` before
   `bootstrap`, instead of falling through to the generic "nothing
   registered" case and silently giving up. Both pinned with runtime tests
   (fake launchctl on PATH sourcing install.sh as a library).

7. LOW — the service-path stop failure message now names a next step
   (check the log / run `grafel status`), matching the RPC path.

Mutation-tested end to end: every one of the above — including the two
findings that previously survived (deleting disable/enable, mutating only
signalKill) — now produces a real test failure when reverted individually,
confirmed by reverting each change, running the specific test, observing
the failure, and restoring.

go build ./... && go vet ./... && gofmt -l . all clean (both before and
after; tree-sitter/CGO cross-compile noise on GOOS=linux/windows for
internal/cli is pre-existing and unrelated — internal/daemon/service and
internal/daemon build clean under both).
rtk proxy go test ./internal/cli/... ./internal/daemon/... ./cmd/grafel/... ./internal/install/... exit=0.
@cajasmota
cajasmota merged commit 41cd17b into main Jul 30, 2026
2 checks passed
@cajasmota
cajasmota deleted the fix/6044-service-aware-stop branch July 30, 2026 22:20
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.

grafel stop does not stop the daemon: launchd KeepAlive respawns it, and stop is not service-aware (start is)

1 participant