From dddf2a20b2cb53ffb53e69d40d740bbd7ecb0ef8 Mon Sep 17 00:00:00 2001 From: Vadym O Date: Thu, 15 Oct 2026 22:04:31 +0800 Subject: [PATCH] fix(service): wait out the evicted launchd job before repair bootstraps `launchctl bootout` returns at once while the old job keeps its label (`state = SIGTERMed`) until the process exits, and `bootstrap` fails with 5 for that whole window. The proxy drains on SIGTERM for up to `shutdownTimeoutMs` (5 s default), but the settle loop gave up after 1 s, so repair bootstrapped into the window, its retry and rollback raced it too, and the old job then exited leaving the service unloaded and stopped. Settle for up to 25 s, bounded by launchd's default 20 s ExitTimeOut. Co-Authored-By: Claude Code --- src/service/launchd.ts | 12 +++++-- tests/service/launchd-repair.test.ts | 49 +++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/src/service/launchd.ts b/src/service/launchd.ts index 5d896b2735b..4014a86d57b 100644 --- a/src/service/launchd.ts +++ b/src/service/launchd.ts @@ -382,8 +382,16 @@ export function probeLaunchdLoadState(deps: { return { state: "not-loaded" }; } -/** Up to ~5 × 200 ms, the launchd twin of the Windows scheduler settle delays. */ -const LAUNCHD_SETTLE_ATTEMPTS = 5; +/** + * Up to 125 × 200 ms = 25 s: long enough for an evicted job to finish EXITING. Measured on + * macOS 26: `bootout` returns at once, the label stays registered (`state = SIGTERMed`) until + * the process exits, and `bootstrap` fails with 5 for that whole window. The proxy drains on + * SIGTERM (`shutdownTimeoutMs`, 5 s default) behind a launcher that waits for it, so a 1 s + * settle bootstrapped into that window, the retry and the rollback raced it too, and the old + * job then exited leaving nothing loaded. launchd SIGKILLs at its default `ExitTimeOut` of + * 20 s, which bounds this wait. + */ +const LAUNCHD_SETTLE_ATTEMPTS = 125; const LAUNCHD_SETTLE_DELAY_MS = 200; diff --git a/tests/service/launchd-repair.test.ts b/tests/service/launchd-repair.test.ts index 256dcfad75f..fb7bcfee3fc 100644 --- a/tests/service/launchd-repair.test.ts +++ b/tests/service/launchd-repair.test.ts @@ -328,10 +328,51 @@ describe("installLaunchd: repair must not be an outage (#4236 defect 1)", () => sleepSync: ms => { delays.push(ms); }, }); - // 5 × 200 ms per evicted domain, then give up and try the bootstrap anyway — a wedged - // domain must reach the diagnosable throw rather than hang. - expect(delays).toEqual(Array.from({ length: 10 }, () => 200)); - expect(verbs(argv).filter(v => v === "print")).toHaveLength(10); + // 125 × 200 ms (launchd's 20 s ExitTimeOut plus margin) per evicted domain, then give up + // and try the bootstrap anyway — a wedged domain must reach the diagnosable throw rather + // than hang. + expect(delays).toEqual(Array.from({ length: 250 }, () => 200)); + expect(verbs(argv).filter(v => v === "print")).toHaveLength(250); + }); + + test("a job still draining after bootout is waited out, so repair never strands the service", () => { + // The stale-plist repair incident: `bootout` returns at once while the old proxy drains + // for several seconds. Until it exits, `print` answers 0 (`state = SIGTERMed`) and + // `bootstrap` fails with 5; bootstrapping into that window, then retrying and rolling + // back into it too, left nothing loaded once the old job finally exited. + const plistPath = fixturePlist(); + writeFileSync(plistPath, "legacy launcher, KeepAlive true", "utf8"); + let state: "old" | "exiting" | "gone" | "new" = "old"; + let exitingPolls = 0; + const argv: string[][] = []; + const launchctl = ((args: string[]): LaunchctlResult => { + argv.push([...args]); + const [verb, target = ""] = args; + const gui = target.startsWith("gui/"); + if (state === "exiting" && ++exitingPolls > 30) state = "gone"; // ~6 s of drain + if (verb === "bootout") { + if (gui && (state === "old" || state === "new")) { state = "exiting"; return ok(); } + return fail(3, "Boot-out failed: 3: No such process"); + } + if (verb === "print") return gui && state !== "gone" ? ok() : fail(113, "Could not find service"); + if (verb === "bootstrap") { + if (state !== "gone") return fail(5, "Bootstrap failed: 5: Input/output error"); + state = "new"; + return ok(); + } + return ok(); + }) as typeof runLaunchctl; + const probe = ((): LaunchdLoadProbe => state === "new" + ? { state: "loaded-current", domain: "gui/501" } + : state === "gone" ? { state: "not-loaded" } : { state: "loaded-stale", domain: "gui/501" } + ) as typeof probeLaunchdLoadState; + + const outcome = installLaunchd({ launchctl, plistPath, probe, sleepSync: () => {} }); + + expect(outcome.reloaded).toBe(true); + expect(state).toBe("new"); + expect(verbs(argv).filter(v => v === "bootstrap")).toHaveLength(1); + expect(readFileSync(plistPath, "utf8")).toBe(renderedPlist()); }); test("a bootout that evicted nothing is not settled", () => {