Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/service/launchd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
49 changes: 45 additions & 4 deletions tests/service/launchd-repair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "<plist>legacy launcher, KeepAlive true</plist>", "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", () => {
Expand Down
Loading