Skip to content

fix: visible update failures, resumable downloads, systemd-aware Linux restart - #49

Merged
TeoSlayer merged 4 commits into
mainfrom
fix/visible-update-failures
Sep 23, 2026
Merged

TeoSlayer merged 4 commits into
mainfrom
fix/visible-update-failures

Conversation

@TeoSlayer

@TeoSlayer TeoSlayer commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

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.

  • Update failures were silent (HYG-05, FIELDLOGS-6).
    • RunOnce() returns nothing (updater.go:127), and errors only go to slog. web4 cmd/pilotctl/updates.go:327 calls u.RunOnce() and then always calls outputOK (:330).
    • Reproduced: pilotctl --json update printed {"status":"ok"} and exited 0. At the same time, stderr logged GitHub 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.log has 307 hourly failed to apply update … gh CLI required errors, 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.
  • Downloads had a fixed 30 s total timeout (ci-09).
    • &http.Client{Timeout: 30 * time.Second} (updater.go:102) covers the whole body read in downloadFile (updater.go:486, io.Copy at :508).
    • The v1.13.9 archives are 16,809,908–19,341,023 B, so a download needs a sustained 4.5–5.2 Mbit/s to finish in time.
    • updater.log has 3 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.
  • The releases API call never sent a token (HYG-05).
    • fetchRelease (updater.go:302) calls api.github.com without auth. Only the attestations call used a token.
    • updater.log has 23 API rate limit exceeded 403s (07-31 to 08-19) from VPN egress IPs, which share GitHub's limit of 60 unauthenticated requests per hour.
  • Linux daemons were never restarted after an update (found during this work).
    • signalDaemonRestartLinux compares /proc/<pid>/exe to the install path exactly (updater.go:765).
    • replaceBinary renames 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.
    • The code comment at :749 relies on Restart=on-failure. That setting does not restart the clean exit the daemon makes on SIGTERM.
  • RunOnce could exit its caller. When a release replaces pilot-updater, it calls u.exitFn(0) (os.Exit, updater.go:477). pilotctl update then exited 0 without saying anything and never restarted the daemon.
  • Nodes on v1.12.2–v1.13.4 are stranded (HYG-04, ci-03).
    • updater v0.2.3 runs exec.LookPath("gh") and fails closed (v0.2.3 updater.go:789-791). web4 pins v0.2.3 in 12 stable tags (2026-06-22 to 07-25).
    • The launchd plist sets no PATH.
    • Verification narrowed this: auto-update was off by default in that window. The nodes affected are the ones that opted in, plus any manual pilotctl update run without gh.
  • Go and grpc (ci-04).
    • The module declares go 1.25.12. On the v1.13.9 release binary, binary-mode govulncheck finds 16 standard-library vulnerabilities in the updater.
    • grpc v1.82.1 (GO-2026-6348) fails govulncheck on every PR.

What changed

Errors and status file (item 13): updater.go, status.go

  • RunOnce() now returns error (updater.go:173). Failed fetches, bad tags, invalid pins and failed applies are returned, not only logged.
  • There is a new update-state.json, written atomically after every check. It holds:
    • last_check_at, last_check_trigger, last_result (up_to_date / updated / failed), last_error
    • consecutive_failures, last_success_at, current_version, latest_version, last_update_*, restart_error
    • the loop heartbeat: loop_pid, loop_started_at and next_check_at. The loop writes these even while auto-update is disabled.
  • The path comes from Config.StatusPath. It defaults to the directory of StatePath, so cmd/updater writes ~/.pilot/update-state.json with no code change. ReadStatus(path) and u.LastStatus() expose the file.
  • Writers merge with the file on disk under an advisory flock on update-state.json.lock (status.go:145, :185), so processes that share a status path do not lose each other's updates.
    • If the lock is still held after 5 s, the write goes ahead without it and the updater logs this.
    • The lock file is opened read-only, so a lock file created by another user still works.
  • RunOnce no longer calls os.Exit on its caller. The loop still exits for its service manager, after it records the update.

Linux daemon restart: supervisor.go, updater.go:836

  • The daemon is matched whether /proc/<pid>/exe shows the plain install path or the path with (deleted).
  • The updater sends SIGTERM only when the daemon will come back. That means the daemon is the ExecStart= program of a system service that restarts after a clean exit: Restart=always/on-success, with RestartForceExitStatus/RestartPreventExitStatus honoured.
    • The updater finds the unit from /proc/<pid>/cgroup (v1, v2 or hybrid).
    • It then reads the unit file and its drop-ins from the systemd search path. There is no systemctl exec.
  • In every other case, the daemon keeps running and restart_error gives the reason and the command that restarts it. The other cases are:
    • pilotctl daemon start in a container, WSL or CI
    • units written by install.sh up to v1.9.0, which say Restart=on-failure
    • user services
    • a daemon running as a child of another service
  • After a SIGTERM, the updater waits up to 30 s for a new daemon process on the new binary (supervisor.go:437). If none appears, it records a failure.
  • On Linux, the next check that finds the daemon on the installed binary clears restart_error, for example after a manual restart.

Downloads (item 45): download.go

  • There is no total timeout any more. An attempt fails after 60 s with no data, and one file is capped at 30 min in total.
  • A dropped or stalled transfer resumes with an HTTP Range request, or starts over if the server ignores Range. There are up to 4 attempts; 5xx responses are retried and 4xx are not.
  • Stop() aborts a download in progress.
  • The TLS handshake timeout goes from 10 s to 20 s.

GitHub token (items 13 and 45): github.go

  • The releases API now sends GITHUB_TOKEN / GH_TOKEN, as the attestations call already did. The token only ever goes to api.github.com.
  • If GitHub rejects the token with a 401, the call is retried without it.
  • Rate-limit errors now say what happened, when the limit resets and how to raise it.

No gh (item 12)

  • TestNoExternalToolDependency parses the sources. It fails on any exec other than runCommand (which runs only launchctl) and on any "gh" literal.
  • The live attestation test runs with an empty PATH.
  • The README explains what stranded nodes must do. It also has a new "Restarting the daemon" section and gives the Linux log location (journalctl -u pilot-updater).

Go, grpc and CI (item 7)

  • go 1.25.12 → go 1.25.13.
  • grpc v1.82.1 → v1.83.2, which fixes GO-2026-6348. Dependabot's v1.83.1 (chore(deps): bump google.golang.org/grpc from 1.82.1 to 1.83.1 #46) fails dependency review.
  • The govulncheck job is pinned to v1.7.0. @latest is v1.8.0, which requires Go 1.26, while setup-go runs with GOTOOLCHAIN=local, so the job failed before it scanned anything. Unpin it when moving to Go 1.26.
  • TestMain stubs runCommand. Before this, running the suite on a Mac with Pilot installed ran launchctl kickstart -k on the developer's live daemon.

Compatibility

  • Exported API: everything is additive except one signature change.
    • Added: Status, ReadStatus, (*Updater).LastStatus and Config.StatusPath.
    • Changed: RunOnce() now returns error. Statement calls still compile. Only code that stores it as a func() value would break.
    • The review-fix commit (4474271) adds only unexported fields and functions.
  • web4: its call sites are unaffected. cmd/pilotctl/updates.go:327 and cmd/updater/main.go:95 build and vet against this branch, and the cmd/updater tests pass. This was checked in a scratch copy with a replace directive.
  • New file on disk: writers create update-state.json.lock next to update-state.json. This is expected, and cleanup scripts should not delete it.
  • Linux restart behaviour: see "Behaviour change to announce" under Deploy notes.

Tests

All green.

  • Local macOS (go1.25.13, GOWORK=off):
    • go vet, go test ./... and go test -race ./... pass. I re-ran them on 4474271 while preparing this PR.
    • Coverage is 90.2% in total. supervisor.go and the status-lock functions are at 88–100%.
  • Linux (golang:1.25.13-bookworm container, non-root, as in CI):
    • go vet and go test -race with coverage pass (91.1%).
    • go test -count=3 passes.
    • The new tests also pass 10 times in a row with -race.
  • Security scans:
    • gosec v2.28.0 with CI's exclusions: 0 issues. There is one #nosec G115, on an fd-to-int conversion.
    • govulncheck v1.7.0: 0 reachable vulnerabilities.
    • gitleaks: no leaks.
  • Real systemd e2e (new env-gated zz_systemd_e2e_test.go, run in a privileged debian:bookworm container with systemd as PID 1):
    • Restart=always restarted the daemon onto the new binary.
    • Restart=on-failure left the daemon active on the old pid and reported the error.
    • A Setsid daemon was left running and the error was reported.
    • With the supervision check bypassed, the last two cases fail, and the post-SIGTERM wait records "did not start it again" instead of a false success.
  • New test files:
    • zz_supervisor_test.go: table tests for cgroup forms, restart policies and drop-ins. It also covers end-to-end RunOnce and recoverPendingRestart on the Linux path, clearing a stale restart_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.
  • Updated tests: restartSpy in zz_status_test.go and TestSignalDaemonRestartLinux_Paths in zz_restart_test.go now use a supervised fake systemd.
  • Other test coverage: there are also tests for:
    • resuming after a drop, a stall, a server that ignores Range, and a mismatched Content-Range
    • 4xx vs 5xx handling, and aborting on Stop
    • the token: sent, aliased, the 401 fallback, and never sent to other hosts
    • the rate-limit message
    • the failure streak resetting
    • the loop heartbeat
    • RunOnce not exiting its caller
    • matching the (deleted) exe path
  • CI on 4474271: all checks pass: test, race, gosec, govulncheck, gitleaks, dependency-review, CodeQL/analyze, codecov/patch and snyk.
  • Known and unrelated: TestReplaceBinary_TmpCreateFails and TestExtractTarGz_DestDirReadOnly fail 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.

  • UPD49-1 (high): the first fix SIGTERMed the daemon even when nothing would restart it: pilotctl daemon start hosts, and units with Restart=on-failure. Those nodes went offline while the status said last_result=updated. The updater now sends SIGTERM only under a systemd unit that restarts after a clean exit, as described above.
  • UPD49-2 (low): writers in different processes lost each other's status updates. In the repro, 203 of 400 increments were lost. The read-merge-write now holds the flock: 2 Updaters × 200 increments give exactly 400, and 3 child processes plus the parent × 100 give exactly 400.
  • UPD49-3 (low): the README and CHANGELOG claimed that pilotctl update writes update-state.json. It does not until web4 passes StatusPath. The docs are corrected.

Deploy notes

  • Order:
    1. Merge this PR.
    2. Tag the updater release.
    3. Bump github.com/pilot-protocol/updater in web4, whose go.mod has v0.2.4.
  • web4 follow-up in cmd/pilotctl/updates.go:
    • Pass StatusPath: ~/.pilot/update-state.json.
    • Check the error from RunOnce.
    • Show LastStatus().RestartError.
    • Without these, manual updates are still not recorded (UPD49-3).
    • Later: update status should read updater.ReadStatus and warn when next_check_at is stale or loop_pid is dead.
  • Behaviour change to announce:
    • Released updaters never found the Linux daemon (see the (deleted) bug above). After this release, daemons under a systemd system unit with Restart=always are restarted onto the new binary.
    • The daemon is still not restarted when it has no systemd supervisor, or when its unit says Restart=on-failure (install.sh up to v1.9.0). The difference is that restart_error now reports this and gives the manual command. Re-running install.sh rewrites the unit with Restart=always.
    • The pre-fix revision of this branch, which SIGTERMed unsupervised daemons, was never released, so no host ran it.
  • Other follow-ups (not in this PR):
    • Make the auto-update default in help and docs match install.sh.
    • Set PATH in the plist and unit that install.sh writes.
    • Send a MOTD or registry notice to v1.12.2–v1.13.4 nodes (item 12).

🤖 Generated with Claude Code

teovl and others added 2 commits September 24, 2026 01:18
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

codecov Bot commented Sep 23, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

teovl and others added 2 commits September 24, 2026 01:21
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>
@TeoSlayer TeoSlayer changed the title fix: make update failures visible; resumable downloads; GITHUB_TOKEN; go 1.25.13 fix: visible update failures, resumable downloads, systemd-aware Linux restart Sep 23, 2026
@TeoSlayer
TeoSlayer merged commit 66630f5 into main Sep 23, 2026
10 checks passed
@TeoSlayer
TeoSlayer deleted the fix/visible-update-failures branch September 23, 2026 23:57
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