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", () => {