Skip to content

fix: restart the macOS service in one launchd operation - #58

Merged
krisiasty merged 1 commit into
mainfrom
fix/macos-restart-race
Oct 15, 2026
Merged

krisiasty merged 1 commit into
mainfrom
fix/macos-restart-race

Conversation

@krisiasty

Copy link
Copy Markdown
Owner

Pull request

Summary

On macOS, -restart sent launchctl stop, which only signals the process, and then an immediate launchctl start. A start that arrives while the old process is still exiting is ignored, and the clean exit that follows is not relaunched because the plist only keeps the job alive after a failed exit (KeepAlive.SuccessfulExit=false). Restart could therefore leave the service stopped while printing "restarted".

  • Restart runs launchctl kickstart -kp gui/<uid>/<label>: launchd sends SIGTERM, waits for the old process to exit (escalating to SIGKILL after the job's exit timeout), starts the new one, and prints its PID. It falls back to user/<uid> only when launchd reports the job is not in the GUI domain (exit status 113).
  • Restart succeeds only if that PID differs from the previous one and is alive. An uninstalled job, or one loaded in neither domain, gets an explicit error; an unloaded job is pointed at -reinstall (automatic recovery stays with enhancement: recover an installed but unregistered macOS LaunchAgent #51).
  • State is never derived from launchctl print, whose output the manual says not to rely on.
  • All launchctl calls go through an injectable runner that keeps stdout separate from diagnostics, so the PID printed by -p is not treated as an error and the restart paths are tested without touching launchd.
  • README: the -restart row describes the macOS behavior.

Closes #49.

User impact

-restart on macOS reliably replaces the running process, starts a stopped job, and fails with a clear message instead of reporting success when no new process starts. The old process still shuts down gracefully (SIGTERM), so sockets and locks are released as before. Linux is unchanged (systemctl restart was already synchronous).

Readiness after the restart (sockets bound) is not checked here; that remains #53. Status error handling remains #52.

Validation

  • Unit tests against a fake launchctl: running job, stopped job, GUI-to-user domain fallback, job not loaded, kickstart error propagation, PID not replaced, replacement exited, unexpected output, and uninstalled job. With the original stop/start Restart restored, these tests fail.
  • go test -race ./... with SSH_AGENT_PROXY_REQUIRE_OPENSSH=1; twenty race-enabled repetitions of the launchd tests.
  • Native and Linux golangci-lint (0 issues), go vet ./... (also GOOS=linux for the service package), formatting, git diff --check, and CGO-disabled builds for darwin/linux × amd64/arm64.
  • Live on macOS with a throwaway LaunchAgent (io.example.sap-kickstart-test, same RunAtLoad and KeepAlive.SuccessfulExit=false), removed afterwards; the real job was not touched:
    • Old stop + start: twice in a row the job ended up not running, reproducing the bug; launchd also throttled further starts.
    • kickstart -kp, three times back to back: exit 0, stdout exactly the new PID, empty stderr, old process gone, new process alive, no throttling.
    • The old process received SIGTERM each time, so shutdown stays graceful.
    • A process taking 3 seconds to exit: kickstart waited for it before starting the new one (no overlap).
    • A process ignoring SIGTERM: launchd sent SIGKILL after about 5 seconds and kickstart returned the new PID in 5.05 seconds, within the 15-second command timeout.
    • A stopped job: kickstart started it and returned its PID.
    • An unknown label: exit status 113 with the message on stderr and empty stdout.

Checklist

  • Tests cover changed behavior.
  • Documentation is updated when behavior or design changed.
  • No credentials, private configuration, or identifying key metadata are included.
  • go test -race ./... passes locally, or the reason it was not run is documented.

🤖 Generated with Claude Code

Restart sent "launchctl stop", which only signals the process, and then
an immediate "launchctl start". A start that arrives while the old
process is still exiting is ignored, and the clean exit that follows is
not relaunched (KeepAlive only covers failed exits), so restart could
leave the service stopped while reporting success.

Restart now runs "launchctl kickstart -kp gui/<uid>/<label>", falling
back to the user domain only when launchd reports the job is not in the
GUI domain. launchd sends SIGTERM, waits for the old process to exit
(escalating to SIGKILL after the job's exit timeout), starts the new
one, and prints its PID. Restart succeeds only if that PID is new and
alive; an uninstalled or unloaded job gets an explicit error.

All launchctl calls go through an injectable runner that keeps stdout
separate from diagnostics, so the restart paths are tested without
touching launchd.

Closes #49.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@krisiasty
krisiasty merged commit f45c5aa into main Oct 15, 2026
11 checks passed
@krisiasty
krisiasty deleted the fix/macos-restart-race branch October 15, 2026 19:11
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.

bug: macOS restart races asynchronous stop with an immediate start

1 participant