fix(daemon/launchd): prefer in-place kickstart on Restart; wait for label to disappear after bootout (#1833) - #1834
Open
chenhg5 wants to merge 1 commit into
Conversation
…abel to disappear after bootout (#1833) When the daemon has agent sessions in flight, `launchctl bootout` returns BEFORE launchd has actually removed the job; the old daemon needs several seconds to tear down. The existing 3 \xc3\x97 500 ms bootstrap retry (~1 s total) then races against that teardown window and fails with `Bootstrap failed: 5: Input/output error` for every attempt. `Restart()` returns an error, the job has been removed from launchd, and KeepAlive cannot recover because the service is no longer loaded. Fix the race two ways: 1. Fast path: when the job is already loaded AND the canonical plist is still on disk, use `launchctl kickstart -k <target>` (in-place). launchd sequences the SIGTERM/relaunch itself, so the bootout/bootstrap race cannot occur on this path. 2. Fallback path: bootout + a new `waitLaunchdTargetsGone(30s)` helper that polls `loadedLaunchdTarget()` every 200 ms until the label disappears. This closes the async-removal window before the existing 3 \xc3\x97 500 ms bootstrap retry runs as a safety net. Tested in daemon/launchd_test.go (darwin tag): - TestRestartPrefersInPlaceKickstartWhenLoadedAndPlistPresent - TestRestartFallbackToBootoutWhenJobNotLoaded - TestRestartFallbackToBootoutWhenPlistMissing - TestWaitLaunchdTargetsGoneReturnsImmediatelyWhenAlreadyGone - TestWaitLaunchdTargetsGoneReturnsAfterJobDisappears - TestWaitLaunchdTargetsGoneHonoursTimeout Closes #1833; related to #35 (original 3 \xc3\x97 500 ms retry that only covered the idle-daemon case).
chenhg5
commented
Sep 12, 2026
chenhg5
left a comment
Owner
Author
There was a problem hiding this comment.
QA Review — PR #1834
Thanks for the clean reproduction timeline from #1833 and the side-by-side cc-connect.log + unified log slice — that made the root cause unambiguous.
Conclusion: Approve ✅
This is mergeable. CI is 5/5 green on dee31ace4, mergeable_state is clean, the diff is 2 files / +404/-4 entirely inside daemon/launchd.go (//go:build darwin), and the test surface is genuine.
What looks good
- Race eliminated by construction, not by retry-til-it-works. The fast path lets
launchd kickstart -ksequence SIGTERM/relaunch itself, so the bootout/agent-teardown race that#1833documents cannot occur on this path. The fallback path then explicitly waits for the label to disappear viawaitLaunchdTargetsGone(30s)before running the existing 3 × 500 ms bootstrap safety net — closing the same race on the bootout branch. This is the right shape; option A from the decision doc (just stretching retries) would have papered over the symptom. - The plist-on-disk gate is principled. Gating fast-path on
os.Stat(plistPath)instead of trying to diff contents is the correct trade-off — if a user has replaced the plist (binary path, env), they almost always wantInstall(), not an in-place reload. The bootout path remains as the fallback for that exact case, and theTestRestartFallbackToBootoutWhenPlistMissingtest pins it. - Constants are centralized and the rationale is in the comments.
launchdRestartInPlaceTimeout = 60s(with the explicit "do not silently fall through to bootout" note),launchdRestartWaitPoll = 200ms,launchdRestartWaitDefault = 30s(comfortably above the reporter's 5 s observed teardown). The 30 s default is loud enough to surface a genuine launchd hang without flapping on slow teardowns. - Soft timeout is the right behavior. When
waitLaunchdTargetsGonehits the deadline it logs a WARN and returns, letting the existing 3 × 500 ms bootstrap retry handle the residual race. Operators get a log line; the user doesn't get a hard error on a transient slow launchd. - Test coverage matches the branches exactly. 6 unit tests, all using a
runLaunchctlstub so they don't depend on a reallaunchd: fast-path taken when loaded + plist present, fallback to bootout when not loaded, fallback to bootout when plist missing, wait helper returns immediately when already gone, wait helper returns after the label actually disappears, wait helper honours timeout. The fact that you can writeTestRestartFallbackToBootoutWhenJobNotLoadedwithout launching macOS launchd is exactly the level of test isolation you want for this kind of code. - Public API unchanged.
Manager.Install / Start / Stop / Statussignatures are untouched.Restart()only changes behavior on the narrowloaded && plist 在condition; everywhere else the original behavior is preserved. Zero blast radius for any existing caller. - No new dependencies.
core / agent / platform / webare not touched.
🔵 Optional (P3)
launchdRestartInPlaceTimeoutis defined but currently unused. The comment promises "if the kickstart does not complete within this budget we surface the error rather than fall through to bootout," but the actual code path returnsrunLaunchctl's error directly without acontext.WithTimeoutwrap. Either wire it up viaexec.CommandContext+ a goroutine that reaps the process, or drop the constant (and the comment about not falling through). I'd lean toward wiring it up — the constant exists, the rationale is right, and the implementation is two lines — but it's a follow-up, not a blocker.
Testing / Risk
- ✅ CI: 5/5 green on
dee31ace41f6961ed7fd7f85093b0885780975bd(lint / unit-test / smoke-test / regression-test / performance-test).unit-testruns on the macOS CI runner, so the 6 darwin-tagged tests are actually executed. - ✅ Local:
gofmt -l daemon/launchd.go daemon/launchd_test.goclean;GOOS=darwin go vet ./daemon/clean;GOOS=darwin go test -cbuilds;go test ./daemon/passes on Linux tag (cross-platform surface unchanged). - ✅ Test files reviewed:
daemon/launchd_test.go(+320 LOC) — 6 new tests, all stub-based and deterministic. - 🟡 Remaining risk: live macOS daemon reproduction (
#1833path with sessions in flight) is only validated via the macOS CI unit tests. The reporter's original scenario (busy daemon, 5 s teardown) is well-modeled by the tests but a single manual smoke from a maintainer against a real agent session would be the cheapest confidence bump. Not blocking.
Next step
Maintainer: merge when convenient. Post-merge, the only thing I'd ask is a one-time manual smoke (cc-connect daemon restart while a session is active) and a glance at the release notes so the new behavior is documented. Solid bugfix.
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 #1833. Related to #35 (original 3 \xc3\x97 500 ms retry that only covered the idle-daemon case).
Root cause
When the daemon has agent sessions in flight,
launchctl bootoutreturns BEFORE launchd has actually removed the job; the old daemon needs several seconds to tear down. The existing 3 \xc3\x97 500 ms bootstrap retry (\xe2\x89\x881 s total) then races against that teardown window and fails withBootstrap failed: 5: Input/output errorfor every attempt.Restart()returns an error, the job has been removed from launchd, and KeepAlive cannot recover because the service is no longer loaded.Reporter leishao provided a clear side-by-side timeline from
cc-connect.log+ unified log showing the daemon exiting 5 s after bootout, while the CLI finished bootout + 3 failed bootstraps in ~1 s. The previous 20+ successful restarts all happened while the daemon was idle, masking the race.Fix
Two complementary paths in
daemon/launchd.go::Restart():launchctl kickstart -k <target>(in-place). launchd sequences the SIGTERM/relaunch itself, so the bootout/bootstrap race cannot occur on this path.waitLaunchdTargetsGone(30s)helper that pollsloadedLaunchdTarget()every 200 ms until the label disappears, THEN run the existing 3 \xc3\x97 500 ms bootstrap retry as a safety net for genuine bootstrap races.The plist-on-disk check is intentional: if a user has replaced the plist (binary path, env, etc.) they almost always want a full reinstall via
Install(), not an in-place reload. The bootout path remains for the rare case where the job is not loaded or the plist has been removed between the daemon writing it and us restarting.Tests added (darwin tag)
TestRestartPrefersInPlaceKickstartWhenLoadedAndPlistPresent\xe2\x80\x94 verifies the in-place path is taken when conditions are met.TestRestartFallbackToBootoutWhenJobNotLoaded\xe2\x80\x94 verifies bootout + wait + bootstrap + kickstart when job is not loaded.TestRestartFallbackToBootoutWhenPlistMissing\xe2\x80\x94 verifies fallback when plist is removed (reinstall scenario).TestWaitLaunchdTargetsGoneReturnsImmediatelyWhenAlreadyGone\xe2\x80\x94 no wasted polls.TestWaitLaunchdTargetsGoneReturnsAfterJobDisappears\xe2\x80\x94 returns after the label actually disappears.TestWaitLaunchdTargetsGoneHonoursTimeout\xe2\x80\x94 soft timeout fires within ~5 poll intervals.Local validation
gofmt -l daemon/launchd.go daemon/launchd_test.goclean.GOOS=darwin go vet ./daemon/clean.GOOS=darwin go test -c -o /tmp/daemon-darwin.test ./daemon/builds cleanly (darwin binary; full execution requires macOS CI runner).go test ./daemon/passes on linux tag (cross-platform surface unchanged).Risk
daemon/launchd.goand its test file. No public API change, no platform/agent code touched.Files
daemon/launchd.go\xe2\x80\x94 +84 / -4 lines (addedRestart()fast path,waitLaunchdTargetsGonehelper, 3 named constants for the timeouts).daemon/launchd_test.go\xe2\x80\x94 +320 lines (6 new tests).