feat(scripts): supervise the bridge with launchd on macOS - #57
Merged
Conversation
Closes AltanS#55. ARCHITECTURE.md §3 already describes the macOS deployment as a "launchd agent … starts at login, restarts on failure" — and `launchd` appeared exactly once in the repo, in that sentence. On macOS `have_systemd()` is false, so `cmd_start` fell through to the unsupervised nohup + pidfile branch: nothing restarted a crashed bridge and nothing brought it back after a reboot, on a host the README lists as supported. From a phone that is indistinguishable from a sleeping host, because the `tailscale serve` mapping keeps resolving. `cmd_start`, `cmd_stop`, `cmd_uninstall` and `print_status_banner` now branch systemd → launchd → nohup, with `write_agent()` mirroring `write_unit()` key for key — mapping in the comment there. No ProcessType: Background throttles CPU and I/O, which the unit never does to the bridge. Two decisions worth stating here. The plist holds paths only: launchd has no `EnvironmentFile=`, and .env is mode 600 and may hold COLLIE_VAPID_PRIVATE, so baking config into a readable plist would copy a Web Push signing key out of a protected file — the agent re-enters this script as `_exec-bridge` and sources .env itself. And `stop` pairs `disable` with `bootout`, because bootout alone leaves RunAtLoad to resurrect a bridge the operator stopped; `start` re-enables, and `uninstall` clears that override so it stays the true inverse of `start`. Installs predating this still own the port through the nohup fallback, so `start` and `stop` release it — signalling only a pid `ps` still shows running our own bridge, since the pidfile outlives its process and pids get recycled. Tests: a fake `launchctl` on the scratch PATH for every case. Without it a macOS run bootstrapped a real job into the developer's own gui/<uid> domain, aimed at a temp dir the suite then deleted, and it crash-looped after the tests reported success. `have_launchd` is stubbed rather than trusted, since CI is ubuntu-latest. The assertions that matter are negative: a seeded COLLIE_VAPID_PRIVATE leaves no trace in the plist, a recycled pid is discarded rather than signalled, and the plist survives `plutil -lint` (guarded on the binary, so it no-ops on CI). The banner is covered separately — a first cut printed the pid twice and every other assertion still passed. Verified on macOS 26.5.2 under the system bash 3.2 the plist invokes, against a real herd: start serves and is idempotent, SIGKILL recovers in ~1s, disable + bootout blocks a re-bootstrap until enable, and start → stop → uninstall moves the override enabled → disabled → enabled with the plist gone. Not covered: an actual logout/login cycle. Version left at 0.20.2 with no CHANGELOG entry, matching AltanS#52 — the release commit is yours to cut. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`launchctl bootout` doesn't promise to wait for the job to finish tearing down, and the bridge drains connections on SIGTERM — so `restart` (and therefore `update`) can reach `bootstrap` while the old job is still going, which launchd answers with "Bootstrap failed: 5: Input/output error". Unretried under `set -e` that ends `start` with the bridge down, which is the outage the launchd branch exists to remove. Retry three times across the window, then fail loudly. EIO is also how launchd reports that `gui/<uid>` doesn't exist at all, so the give-up message names the no-console-login case rather than leaving an SSH-only Mac with a bare errno. Co-Authored-By: Claude Opus 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.
Closes #55.
Adds a launchd LaunchAgent on macOS, so the bridge starts at login and restarts on failure —
parity with the
systemd --userunit.have_systemd()is false there, sostartfell through to anunsupervised
nohupprocess.start/stop/uninstall/statusbranch systemd → launchd → nohup.write_agent()mirrorswrite_unit().Tests: every case now gets a fake
launchctlon the scratch PATH — without it a macOS runbootstrapped a real job into your own
gui/<uid>domain.have_launchdis stubbed rather thandetected, since CI is ubuntu-latest.
Verified on macOS 26.5.2.