Skip to content

feat(scripts): supervise the bridge with launchd on macOS - #57

Merged
AltanS merged 2 commits into
AltanS:mainfrom
darieldatoon:feat/macos-launchd-autostart
Jul 30, 2026
Merged

feat(scripts): supervise the bridge with launchd on macOS#57
AltanS merged 2 commits into
AltanS:mainfrom
darieldatoon:feat/macos-launchd-autostart

Conversation

@darieldatoon

Copy link
Copy Markdown
Contributor

Closes #55.

Adds a launchd LaunchAgent on macOS, so the bridge starts at login and restarts on failure —
parity with the systemd --user unit. have_systemd() is false there, so start fell through to an
unsupervised nohup process.

start / stop / uninstall / status branch systemd → launchd → nohup. write_agent() mirrors
write_unit().

Tests: every case now gets a fake launchctl on the scratch PATH — without it a macOS run
bootstrapped a real job into your own gui/<uid> domain. have_launchd is stubbed rather than
detected, since CI is ubuntu-latest.

Verified on macOS 26.5.2.

darieldatoon and others added 2 commits July 30, 2026 08:43
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>
@AltanS
AltanS merged commit 8a68416 into AltanS:main Jul 30, 2026
1 check passed
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.

Enable autostart on login on MacOS

2 participants