Repository navigation
fix: visible update failures, resumable downloads, systemd-aware Linux restart - #49
Merged
Merged
Conversation
Raise the go directive from 1.25.12 to 1.25.13 so CI (setup-go reads go-version-file) builds with the standard-library security fixes (crypto/tls, net/http, net/url, encoding/asn1, ...). Bump the indirect google.golang.org/grpc from v1.82.1 to v1.83.2. It fixes GO-2026-6348, which fails the govulncheck job on every PR today. v1.83.2 rather than v1.83.1 (Dependabot #46) because v1.83.1 fails dependency review (GHSA-2v4p-qf9q-27wj). govulncheck now reports 0 reachable vulnerabilities. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Update failures were invisible. RunOnce returned nothing, so `pilotctl --json update` printed status ok and exited 0 on a GitHub 403. `update status` could only show the enabled flag, so a 16-day stall (307 hourly failures) went unnoticed. Errors and state - RunOnce() now returns error. Existing statement call sites still compile. Failed checks (fetch, bad tag, invalid pin, apply) are returned instead of only being logged. - New update-state.json, written atomically after every check. It holds last check time, trigger, result and error, consecutive failures, last success, installed and latest versions, last update, a daemon restart error, and a loop heartbeat (loop_pid, next_check_at). Config.StatusPath sets the path. By default the file goes next to StatePath, so cmd/updater writes ~/.pilot/update-state.json with no change on its side. Writers merge with the file on disk, so manual and loop runs share one record. ReadStatus and LastStatus expose it. - RunOnce no longer os.Exit()s the caller when a release replaces pilot-updater. Before, `pilotctl update` exited 0 silently and never restarted the daemon. The loop still exits for its service manager, after recording the update. - Linux: the daemon is now restarted after an update. /proc/<pid>/exe reads "<path> (deleted)" once the binary is renamed over, so the old exact match never found the daemon. Restart failures are returned and recorded as restart_error. Downloads (sweep item 45) - Downloads no longer have a fixed 30 s total timeout, which needed ~5 Mbit/s for a 19 MB archive. A transfer fails after 60 s with no data or 30 min in total. Dropped or stalled transfers resume with HTTP Range (up to 4 attempts, 5xx retried, restart when Range is ignored). Stop() aborts an in-flight download. TLS handshake timeout is 20 s. GitHub API - The releases API now sends GITHUB_TOKEN/GH_TOKEN, as the attestations API already did. Tokens go only to api.github.com, and a 401 falls back to an unauthenticated retry. Rate-limit errors say so, give the reset time and how to raise the limit. No gh dependency (sweep item 12) - TestNoExternalToolDependency parses the sources and fails on any exec other than runCommand (used only for launchctl), or any "gh" literal. - The live attestation test runs with an empty PATH. It passes in a container that has no gh. - README documents what nodes stranded on v1.12.2-v1.13.4 (updater v0.2.3, which needed gh) must do. Tests no longer run the real launchctl. TestMain stubs runCommand. On a developer Mac, the suite used to `launchctl kickstart -k` the running Pilot daemon. 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! |
govulncheck@latest resolves to v1.8.0, which requires go >= 1.26.0. setup-go runs with GOTOOLCHAIN=local on the go.mod version (1.25.13), so the job failed before scanning anything, on every PR. v1.7.0 supports Go 1.25 and reports 0 reachable vulnerabilities on this branch. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… file Review findings on #49. UPD49-1 (high): signalDaemonRestartLinux now matched the renamed-over daemon and SIGTERMed it on the assumption that systemd Restart=always would bring it back. Nothing restarts a daemon started with `pilotctl daemon start` (containers, WSL, CI), and units written by install.sh up to v1.9.0 say Restart=on-failure, under which systemd does not restart the clean exit pilot-daemon makes on SIGTERM. Those nodes went offline while the status file said last_result=updated with no restart_error. The updater now sends SIGTERM only when the daemon is the ExecStart= program of a system service that restarts after a clean exit (Restart=always/on-success, RestartForceExitStatus, RestartPreventExitStatus honoured). The service is found from /proc/<pid>/cgroup (v1, v2, hybrid) and its unit file plus drop-ins are read from the systemd search path; no systemctl exec, so TestNoExternalToolDependency still holds. Otherwise the daemon keeps running and restart_error says why and gives the command that restarts it. After a SIGTERM the updater waits up to 30 s for a new daemon process on the new binary and records a failure if none appears. On Linux, restart_error is cleared by the next check that finds the daemon on the installed binary (e.g. after a manual restart). Verified against a real systemd (PID 1, privileged container): always -> restarted onto the new binary; on-failure -> left running, error reported; Setsid daemon -> left running, error reported (zz_systemd_e2e_test.go, env-gated). UPD49-2 (low): updateStatus's read-merge-write now holds an advisory flock on update-state.json.lock, so writers in different processes no longer drop each other's changes (the repro lost 203 of 400 increments without it). A writer that cannot get the lock within 5 s writes unlocked and logs; the lock file is opened read-only so one created by another user still works. UPD49-3 (low): README/CHANGELOG no longer claim `pilotctl update` writes or shares update-state.json: RunOnce writes it only when the caller sets StatusPath/StatePath, and web4's pilotctl does not yet (nor does it check RunOnce's error). The stranded-node section gives the Linux log location (journalctl -u pilot-updater). New "Restarting the daemon" section. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
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.
This PR covers the updater part of reliability sweep items 13, 45, 12 and 7. See "Deploy notes" for the release order.
Problem (sweep evidence)
Line numbers are for
origin/main/ v0.2.4 unless another tag is named.RunOnce()returns nothing (updater.go:127), and errors only go to slog. web4cmd/pilotctl/updates.go:327callsu.RunOnce()and then always callsoutputOK(:330).pilotctl --json updateprinted{"status":"ok"}and exited 0. At the same time, stderr loggedGitHub API returned 403: ... API rate limit exceeded.update status(updates.go:66-88) shows only the auto-update flag and pilotctl's own compiled version.~/.pilot/updater.loghas 307 hourlyfailed to apply update … gh CLI requirederrors, from 2026-07-31T19:08 to 2026-08-16T11:10. That is 16 days stuck on current=v1.9.1 while latest=v1.13.9, and no Pilot surface showed it.&http.Client{Timeout: 30 * time.Second}(updater.go:102) covers the whole body read indownloadFile(updater.go:486,io.Copyat:508).context deadline exceeded (... while reading body)errors, 1 checksums timeout and 2 TLS handshake timeouts. After each one, the updater waited a full hour before retrying.fetchRelease(updater.go:302) calls api.github.com without auth. Only the attestations call used a token.API rate limit exceeded403s (07-31 to 08-19) from VPN egress IPs, which share GitHub's limit of 60 unauthenticated requests per hour.signalDaemonRestartLinuxcompares/proc/<pid>/exeto the install path exactly (updater.go:765).replaceBinaryrenames the new binary over the old one (updater.go:668), after which the link reads<path> (deleted). The daemon is never found, so it keeps running the old binary and nothing records this.:749relies onRestart=on-failure. That setting does not restart the clean exit the daemon makes on SIGTERM.RunOncecould exit its caller. When a release replacespilot-updater, it callsu.exitFn(0)(os.Exit,updater.go:477).pilotctl updatethen exited 0 without saying anything and never restarted the daemon.exec.LookPath("gh")and fails closed (v0.2.3updater.go:789-791). web4 pins v0.2.3 in 12 stable tags (2026-06-22 to 07-25).pilotctl updaterun withoutgh.go 1.25.12. On the v1.13.9 release binary, binary-mode govulncheck finds 16 standard-library vulnerabilities in the updater.govulncheckon every PR.What changed
Errors and status file (item 13):
updater.go,status.goRunOnce()now returnserror(updater.go:173). Failed fetches, bad tags, invalid pins and failed applies are returned, not only logged.update-state.json, written atomically after every check. It holds:last_check_at,last_check_trigger,last_result(up_to_date/updated/failed),last_errorconsecutive_failures,last_success_at,current_version,latest_version,last_update_*,restart_errorloop_pid,loop_started_atandnext_check_at. The loop writes these even while auto-update is disabled.Config.StatusPath. It defaults to the directory ofStatePath, so cmd/updater writes~/.pilot/update-state.jsonwith no code change.ReadStatus(path)andu.LastStatus()expose the file.update-state.json.lock(status.go:145,:185), so processes that share a status path do not lose each other's updates.RunOnceno longer callsos.Exiton its caller. The loop still exits for its service manager, after it records the update.Linux daemon restart:
supervisor.go,updater.go:836/proc/<pid>/exeshows the plain install path or the path with(deleted).ExecStart=program of a system service that restarts after a clean exit:Restart=always/on-success, withRestartForceExitStatus/RestartPreventExitStatushonoured./proc/<pid>/cgroup(v1, v2 or hybrid).systemctlexec.restart_errorgives the reason and the command that restarts it. The other cases are:pilotctl daemon startin a container, WSL or CIRestart=on-failuresupervisor.go:437). If none appears, it records a failure.restart_error, for example after a manual restart.Downloads (item 45):
download.goRangerequest, or starts over if the server ignoresRange. There are up to 4 attempts; 5xx responses are retried and 4xx are not.Stop()aborts a download in progress.GitHub token (items 13 and 45):
github.goGITHUB_TOKEN/GH_TOKEN, as the attestations call already did. The token only ever goes toapi.github.com.No
gh(item 12)TestNoExternalToolDependencyparses the sources. It fails on any exec other thanrunCommand(which runs onlylaunchctl) and on any"gh"literal.PATH.journalctl -u pilot-updater).Go, grpc and CI (item 7)
go 1.25.12→go 1.25.13.govulncheckjob is pinned to v1.7.0.@latestis v1.8.0, which requires Go 1.26, while setup-go runs withGOTOOLCHAIN=local, so the job failed before it scanned anything. Unpin it when moving to Go 1.26.TestMainstubsrunCommand. Before this, running the suite on a Mac with Pilot installed ranlaunchctl kickstart -kon the developer's live daemon.Compatibility
Status,ReadStatus,(*Updater).LastStatusandConfig.StatusPath.RunOnce()now returnserror. Statement calls still compile. Only code that stores it as afunc()value would break.cmd/pilotctl/updates.go:327andcmd/updater/main.go:95build and vet against this branch, and the cmd/updater tests pass. This was checked in a scratch copy with areplacedirective.update-state.json.locknext toupdate-state.json. This is expected, and cleanup scripts should not delete it.Tests
All green.
go vet,go test ./...andgo test -race ./...pass. I re-ran them on 4474271 while preparing this PR.supervisor.goand the status-lock functions are at 88–100%.go vetandgo test -racewith coverage pass (91.1%).go test -count=3passes.-race.#nosec G115, on an fd-to-int conversion.zz_systemd_e2e_test.go, run in a privilegeddebian:bookwormcontainer with systemd as PID 1):Restart=alwaysrestarted the daemon onto the new binary.Restart=on-failureleft the daemon active on the old pid and reported the error.Setsiddaemon was left running and the error was reported.zz_supervisor_test.go: table tests for cgroup forms, restart policies and drop-ins. It also covers end-to-endRunOnceandrecoverPendingRestarton the Linux path, clearing a stalerestart_error, and the parsers.zz_status_lock_test.go: writers in one process and across processes, the lock held during the write, the lock timeout, a read-only lock file and an unusable lock path.zz_systemd_e2e_test.go.restartSpyinzz_status_test.goandTestSignalDaemonRestartLinux_Pathsinzz_restart_test.gonow use a supervised fake systemd.RunOncenot exiting its caller(deleted)exe pathTestReplaceBinary_TmpCreateFailsandTestExtractTarGz_DestDirReadOnlyfail only when run as root, because root can write to read-only directories. CI runs as non-root.Review outcome
The review found 3 issues, and all 3 are fixed in 4474271.
pilotctl daemon starthosts, and units withRestart=on-failure. Those nodes went offline while the status saidlast_result=updated. The updater now sends SIGTERM only under a systemd unit that restarts after a clean exit, as described above.pilotctl updatewritesupdate-state.json. It does not until web4 passesStatusPath. The docs are corrected.Deploy notes
github.com/pilot-protocol/updaterin web4, whosego.modhas v0.2.4.cmd/pilotctl/updates.go:StatusPath: ~/.pilot/update-state.json.RunOnce.LastStatus().RestartError.update statusshould readupdater.ReadStatusand warn whennext_check_atis stale orloop_pidis dead.(deleted)bug above). After this release, daemons under a systemd system unit withRestart=alwaysare restarted onto the new binary.Restart=on-failure(install.sh up to v1.9.0). The difference is thatrestart_errornow reports this and gives the manual command. Re-running install.sh rewrites the unit withRestart=always.install.sh.PATHin the plist and unit thatinstall.shwrites.🤖 Generated with Claude Code