Repository navigation
fix(darwin): restart the daemon only under its launchd job, confirm over IPC; restart the updater job - #51
Merged
Merged
Conversation
…ver IPC, restart the updater job On macOS the updater ran `launchctl kickstart -k gui/<uid>/network.pilotprotocol.pilot-daemon` blindly and reported success as soon as launchctl returned. It never checked that launchd runs the daemon, never confirmed the daemon came back on the new release, and a one-shot `pilotctl update` that replaced pilot-updater left the updater service (network.pilotprotocol.pilot-updater) on its old binary. Now, after replacing binaries on darwin: - The daemon job is read with `launchctl print` (gui/<uid>, then user/<uid>; the legacy com.vulturelabs label too; SUDO_UID under sudo). It is kickstarted only when the job is running and its program is the replaced pilot-daemon. The updater then waits (60 s) for launchd to run a new pid and for that process to answer IPC `info` on the job's -socket with the installed version. - A daemon launchd does not run is never restarted: one answering with an older version is reported in restart_error with the restart command; one not answering is not running, and its next start uses the new binary. - A job running another binary, a failed kickstart, a job that does not come back, or a daemon that does not answer with the installed version are each recorded in restart_error with how to restart it. - A one-shot RunOnce that replaced pilot-updater kickstarts the updater job after the daemon (and after the restart record is written, so the new updater does not restart the daemon again). Recorded in updater_restart_error / updater_restarted_at. - Success is recorded in daemon_restarted_at / _by / _version. - A manual run that replaced pilot-updater now records the daemon restart it made (restart_error was kept before), and a macOS check clears restart_error once the daemon answers with the installed version. Tests use a fake launchd (print/kickstart state machine plus IPC answers), an exec-level test with a shell-script launchctl alone on PATH and the real IPC probe against a fake daemon socket. TestMain now also stubs the IPC probe so no test can reach a developer's daemon on /tmp/pilot.sock. CI runs the suite on macOS too. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pilotctl and the updater service share update-state.json and can run different updater versions. On a clean macOS runner the updater service just restarted onto v1.13.10 (updater v0.2.5) rewrote the file 40 ms after `pilotctl update` recorded the restart, and dropped daemon_restarted_* and updater_restarted_at, which v0.2.5 does not know. Read-merge-write now keeps unknown fields (appended after the known ones, in key order) so an older writer passes a newer writer's fields through. Known fields are still written, or cleared, as the writer decides. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On a Mac,
pilotctl update(v1.13.9) installed v1.13.10:pilot-daemon,pilotctlandpilot-updaterwere replaced, with the SLSA and checksum checks passing. But the launchd daemon (network.pilotprotocol.pilot-daemon,KeepAlive SuccessfulExit=false) kept running v1.13.9 until someone ranlaunchctl kickstart -kby hand. The updater service (network.pilotprotocol.pilot-updater) also kept running its old binary.Root cause, in two parts:
pilot-updater(every release does), v0.2.4'sapplyUpdatecalledexitFn(0), which isos.Exit(0). It did this before reachingsignalDaemonRestart, sopilotctl updateexited silently and never ranlaunchctl kickstart. v0.2.5 (fix: visible update failures, resumable downloads, systemd-aware Linux restart #49) fixed the exit:RunOnceno longer exits.launchctl kickstart -k gui/<uid>/network.pilotprotocol.pilot-daemonand reported success as soon as launchctl returned. It did not check that launchd runs this daemon or that the job's program is the replaced binary, and it did not confirm the daemon came back on the new release. It also never moved the updater service onto its new binary.restart_errorwas also kept after a manual run that did restart the daemon, because the release replacedpilot-updater. On macOS it was never cleared by a later check.Change (macOS; the Linux path is unchanged apart from the recording fix)
After replacing binaries on darwin,
signalDaemonRestartDarwinnow does the following (new filelaunchd.go):launchctl printingui/<uid>, thenuser/<uid>. It also finds the legacycom.vulturelabs.pilot-daemonlabel, and undersudoit uses theSUDO_UIDdomains first.programis the replacedInstallDir/pilot-daemon(symlinks resolved). Otherwise it leaves the daemon running:restart_errorwithrestart it with: pilotctl daemon stop && pilotctl daemon start. If nothing answers, the daemon is not running and its next start uses the new binary, which is not an error.launchctl kickstart -k <target>. launchd sends SIGTERM, the daemon's graceful shutdown stops its apps, and launchd starts the job again on the new binary, whatever itsKeepAlivesetting.infoon the job's-socket(default/tmp/pilot.sock) with the installed version from.pilot-version. It usescommon/driver, with a 3 s cap per probe. A failed kickstart, a job that does not come back, or a daemon that does not answer with the installed version each goes torestart_errorwith the command to fix it.update-state.json. New fieldsdaemon_restarted_at,daemon_restarted_by(for examplelaunchd gui/501/network.pilotprotocol.pilot-daemonorsystemd pilot-daemon.service) anddaemon_restarted_version.restart_erroris cleared on success.The updater service. When a one-shot
RunOnce(pilotctl update) has replacedpilot-updater,restartUpdaterServicekickstartsnetwork.pilotprotocol.pilot-updater. It does this only when the job is running from the replaced binary, and never when the caller is the job itself. It runs after the daemon restart and the.daemon-last-restartwrite, so the new updater does not restart the daemon a second time. The result goes toupdater_restart_error/updater_restarted_at. The loop still exits after replacing itself, and launchd restarts it (KeepAlive true). The loop never kickstarts its own job.Recording fixes:
update-state.jsonwriters keep the fields they do not define (see the verification section below).pilot-updaternow records the daemon restart it made.restart_erroronce the daemon answers over IPC with the installed version, as Linux already did from/proc. The daemon is asked only while arestart_erroris on record, so the hourly loop makes no extra launchctl or IPC calls.launchctlis still the only external command the updater runs, nowprintas well askickstart.TestNoExternalToolDependencynow checks every non-test source file.Tests
zz_launchd_test.go). A print/kickstart state machine that renders reallaunchctl printoutput, including nested blocks that repeatstate/pid, and answers IPC. It covers: kickstart and confirm, a symlinked program, the legacy label in the user domain, the sudo domain, no job (not running / hand-started old / unknown version / already current), job loaded but idle, job running another binary, a refused kickstart, no respawn, never answers, wrong version,Stop()aborting the wait, a self-pid guard, all updater-service cases, and end to end throughRunOnce(daemon then updater kickstarted, fields recorded, stalerestart_errorcleared, no second restart) and through the loop's self-update (it exits, and its successor restarts the daemon).zz_launchctl_exec_test.go). Uses the realrunCommand/execpath with a shell-scriptlaunchctlthat is the only thing onPATH, plus the realcommon/driverIPC probe against a fake daemon socket. Also covers the probe's answering, absent and wedged (timeout) cases.TestMainnow also stubs the IPC probe, so no test can reach a developer's daemon on/tmp/pilot.sock.test-macosjob runs the suite natively on macOS.go test -race ./...passes on macOS arm64 and on Linux (golang:1.25 container, non-root). gosec, with the repo's exclusions, reports nothing.Clean-runner verification (real launchd)
Run 35979204079 used a temporary
verify/launchd-restartbranch, now deleted, on clean GitHub macOS runners (3 jobs). Each job:install.sh, which writes and loads both LaunchAgents;checksums.txt-verified release archive and restarted the updater job onto v1.13.9. (install.sh --version v1.13.9refuses with "integrity anchors disagree", because the manifest anchors are the latest stable's);mainpilotctl built against this branch:pilotctl update --pin v1.13.10, with real SLSA and checksum verification.restart_errorempty,restart_needed: false; daemon.log shows the graceful SIGTERM shutdown (shutting down0 → 1); a secondpilotctl updateis a no-op (same pid)restart_error:daemon on /tmp/pilot.sock left running the old version (v1.13.9): no launchd job network.pilotprotocol.pilot-daemon is loaded in gui/501 or user/501, … restart it with: pilotctl daemon stop && pilotctl daemon start;restart_needed: true; updater job still restarted (pid 3031 → 3141)This confirms that
launchctl kickstart -kon theSuccessfulExit=falsedaemon job sends SIGTERM, which takes the graceful shutdown path, and that launchd then starts the new binary.Found by that run, fixed in the second commit. The restarted updater service is v1.13.10's own binary (updater v0.2.5). It rewrote
update-state.json40 ms afterpilotctl updateand droppeddaemon_restarted_*/updater_restarted_at, fields it does not know. Read-merge-write now keeps fields the writer does not define (TestUpdateStatus_KeepsFieldsOfNewerWriters), so from this version on an older writer passes newer fields through. Writers at v0.2.5 and earlier still drop them. That is why the harness reads the restart results from pilotctl's log rather than from those fields.Rollout note
A release with this updater fixes restarts for updates performed by that release: its
pilotctl update, and its updater service after that service restarts on the new binary. Going from v1.13.10 to the next release still runs v0.2.5's blind kickstart, which restarts a launchd daemon but does not confirm it. For web4, bumpgithub.com/pilot-protocol/updateronce this is tagged. Proposed tag:v0.2.6(v0.2.6-beta.1is the currentmain).Do not merge without review.
🤖 Generated with Claude Code