Skip to content

Commit 12e7d75

Browse files
committed
e2e: invert the stale launchd repro to guard the fast, self-healing boot
The repro pinned the broken behavior (stale plist forces a >=14s dead wait). Flip it to guard the fix: the startup window must appear within a few seconds, the kickstart failure must be followed by a self-heal-or-fallback log line rather than a dead wait, boot-to-ready must stay under the fast budget and close to the clean control, and recovery must either repair launchd or fall back cleanly to managed spawn without attaching to the stale plist port. The E2E_RECORD recording now captures the fixed fast boot.
1 parent a1b7f3a commit 12e7d75

3 files changed

Lines changed: 114 additions & 54 deletions

File tree

e2e/desktop-packaged/stale-launchd-registration.test.ts

Lines changed: 114 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ import { RunDir } from "../src/services";
2323
const execFileAsync = promisify(execFile);
2424
const SERVICE_LABEL = "sh.executor.daemon";
2525
const SCENARIO_NAME =
26-
"Desktop packaged supervised attach: stale launchd registration pins the 15s dead boot wait";
27-
const RECORDING_FILE = resolve("recordings/stale-launchd-slow-boot.mp4");
26+
"Desktop packaged supervised attach: stale launchd registration self-heals without a dead boot wait";
27+
const RECORDING_FILE = resolve("recordings/stale-launchd-fast-boot.mp4");
2828
const RECORDING_WIDTH = 1280;
2929
const RECORDING_HEIGHT = 720;
3030
const STALE_PLIST_PORT = 58_251;
@@ -72,12 +72,15 @@ interface LaunchTiming {
7272

7373
interface BootResult {
7474
readonly pageCdpMs: number;
75+
readonly startupWindowMs: number;
7576
readonly sidecarReadyMs: number;
7677
readonly connectionKind: string;
7778
readonly origin: string;
7879
readonly manifestKind: string;
7980
readonly manifestPid: number;
80-
readonly matchedLogLine: string | null;
81+
readonly kickstartFailureLine: string | null;
82+
readonly recoveryLogLine: string | null;
83+
readonly launchdPrintSucceeded: boolean;
8184
}
8285

8386
declare global {
@@ -273,6 +276,17 @@ const launchctl = async (args: ReadonlyArray<string>): Promise<boolean> => {
273276
}
274277
};
275278

279+
const launchctlPrintService = async (): Promise<string | null> => {
280+
try {
281+
const { stdout, stderr } = await execFileAsync("launchctl", ["print", serviceTarget()], {
282+
encoding: "utf8",
283+
});
284+
return `${stdout}${stderr}`.trim();
285+
} catch {
286+
return null;
287+
}
288+
};
289+
276290
const captureLaunchdService = (): LaunchdServiceSnapshot | null => {
277291
if (process.platform !== "darwin") return null;
278292
const path = launchAgentPath();
@@ -482,31 +496,51 @@ const readServerManifest = (home: string): ServerManifest => {
482496
return JSON.parse(readFileSync(manifestPath, "utf8")) as ServerManifest;
483497
};
484498

485-
const readKickstartFailureLine = (home: string): string | null => {
499+
interface StaleRecoveryLog {
500+
readonly kickstartFailureLine: string | null;
501+
readonly recoveryLogLine: string | null;
502+
}
503+
504+
// The desktop's own classification line for a stale registration; electron-log
505+
// appends the launchctl error on the same line. Production launchctl fails the
506+
// kickstart with exit 113 ("Could not find service"); a tart guest's
507+
// `launchctl asuser` session fails it with exit 125 ("Domain does not support
508+
// specified action"). Same stale state, same self-heal branch, so match the
509+
// kickstart failure without pinning the environment-specific exit code.
510+
const isKickstartFailureLine = (line: string): boolean =>
511+
line.includes("Failed to restart registered supervised service; reinstalling") &&
512+
line.includes("launchctl kickstart failed");
513+
514+
const isRecoveryLogLine = (line: string): boolean =>
515+
line.includes("installed supervised service via bundled executor") ||
516+
line.includes("Failed to install supervised service after registered service restart failure") ||
517+
line.includes("using managed sidecar");
518+
519+
const readStaleRecoveryLog = (home: string): StaleRecoveryLog => {
486520
const logPath = join(home, "Library", "Logs", "Executor", "main.log");
487-
if (!existsSync(logPath)) return null;
488-
return (
489-
readFileSync(logPath, "utf8")
490-
.split("\n")
491-
.find(
492-
(line) =>
493-
line.includes("launchctl kickstart failed (exit 113)") &&
494-
line.includes('Could not find service "sh.executor.daemon"'),
495-
) ?? null
496-
);
521+
if (!existsSync(logPath)) return { kickstartFailureLine: null, recoveryLogLine: null };
522+
const lines = readFileSync(logPath, "utf8").split("\n");
523+
const kickstartIndex = lines.findIndex(isKickstartFailureLine);
524+
if (kickstartIndex < 0) return { kickstartFailureLine: null, recoveryLogLine: null };
525+
const recoveryLogLine =
526+
lines.slice(kickstartIndex + 1).find((line) => isRecoveryLogLine(line)) ?? null;
527+
return {
528+
kickstartFailureLine: lines[kickstartIndex] ?? null,
529+
recoveryLogLine,
530+
};
497531
};
498532

499-
const waitForKickstartFailureLine = async (home: string): Promise<string> => {
533+
const waitForStaleRecoveryLog = async (home: string): Promise<StaleRecoveryLog> => {
500534
const deadline = Date.now() + 30_000;
501535
for (;;) {
502-
const line = readKickstartFailureLine(home);
503-
if (line) return line;
536+
const lines = readStaleRecoveryLog(home);
537+
if (lines.kickstartFailureLine && lines.recoveryLogLine) return lines;
504538
if (Date.now() >= deadline) {
505539
const logPath = join(home, "Library", "Logs", "Executor", "main.log");
506540
const tail = existsSync(logPath)
507541
? readFileSync(logPath, "utf8").split("\n").slice(-20).join("\n")
508542
: "<main.log missing>";
509-
throw new Error(`Timed out waiting for kickstart failure log line\n${tail}`);
543+
throw new Error(`Timed out waiting for stale launchd recovery log lines\n${tail}`);
510544
}
511545
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
512546
}
@@ -516,14 +550,24 @@ const bootPackagedApp = async (input: {
516550
readonly home: string;
517551
readonly env: NodeJS.ProcessEnv;
518552
readonly recorder: BootRecorder | null;
519-
readonly expectKickstartFailure: boolean;
553+
readonly expectStartupWindow: boolean;
554+
readonly expectStaleRecoveryLog: boolean;
520555
}): Promise<BootResult> => {
521556
let app: PackagedApp | undefined;
522557
const startedAt = Date.now();
523558
try {
524559
const launched = await launchPackaged(input.env, input.recorder);
525560
app = launched.app;
526561
const page = app.cdp;
562+
const startupWindowVisible = await page
563+
.waitForText("Starting Executor", 5_000)
564+
.then(() => true)
565+
.catch(() => false);
566+
const startupWindowMs = startupWindowVisible ? Date.now() - startedAt : launched.pageCdpMs;
567+
expect(
568+
!input.expectStartupWindow || startupWindowVisible,
569+
"startup window shows before service recovery finishes when required",
570+
).toBe(true);
527571
await page.waitForText("Settings", 120_000);
528572
const sidecarReadyMs = Date.now() - startedAt;
529573
await input.recorder?.captureReadyFrame();
@@ -534,17 +578,21 @@ const bootPackagedApp = async (input: {
534578
} | null>("window.executor.getServerConnection()");
535579
expect(connection, "the app eventually exposes a server connection").not.toBeNull();
536580
const manifest = readServerManifest(input.home);
537-
const matchedLogLine = input.expectKickstartFailure
538-
? await waitForKickstartFailureLine(input.home)
539-
: readKickstartFailureLine(input.home);
581+
const recoveryLog = input.expectStaleRecoveryLog
582+
? await waitForStaleRecoveryLog(input.home)
583+
: readStaleRecoveryLog(input.home);
584+
const launchdPrintSucceeded = (await launchctlPrintService()) !== null;
540585
return {
541586
pageCdpMs: launched.pageCdpMs,
587+
startupWindowMs,
542588
sidecarReadyMs,
543589
connectionKind: connection!.kind,
544590
origin: connection!.origin,
545591
manifestKind: manifest.kind,
546592
manifestPid: manifest.pid,
547-
matchedLogLine,
593+
kickstartFailureLine: recoveryLog.kickstartFailureLine,
594+
recoveryLogLine: recoveryLog.recoveryLogLine,
595+
launchdPrintSucceeded,
548596
};
549597
} finally {
550598
await input.recorder?.stop();
@@ -680,7 +728,7 @@ class BootRecorder {
680728

681729
static create = (runDir: string): BootRecorder | null => {
682730
if (process.env.E2E_RECORD !== "1") return null;
683-
const frameDir = join(runDir, "stale-launchd-slow-boot-frames");
731+
const frameDir = join(runDir, "stale-launchd-fast-boot-frames");
684732
rmSync(frameDir, { recursive: true, force: true });
685733
mkdirSync(frameDir, { recursive: true });
686734
mkdirSync(dirname(RECORDING_FILE), { recursive: true });
@@ -707,7 +755,7 @@ class BootRecorder {
707755

708756
captureReadyFrame = async (): Promise<void> => {
709757
if (!this.enabled) return;
710-
this.enqueueFrame("managed sidecar ready", this.page);
758+
this.enqueueFrame("executor ready", this.page);
711759
await this.queue;
712760
};
713761

@@ -802,7 +850,8 @@ const run = async (runDir: string): Promise<void> => {
802850
home: cleanHome,
803851
env: cleanBootEnv(cleanHome),
804852
recorder: null,
805-
expectKickstartFailure: false,
853+
expectStartupWindow: false,
854+
expectStaleRecoveryLog: false,
806855
});
807856

808857
writeStaleLaunchAgentPlist(staleHome);
@@ -811,53 +860,64 @@ const run = async (runDir: string): Promise<void> => {
811860
home: staleHome,
812861
env: packagedAppEnv(staleHome),
813862
recorder: staleRecorder,
814-
expectKickstartFailure: true,
863+
expectStartupWindow: true,
864+
expectStaleRecoveryLog: true,
815865
});
816866

817867
/*
818-
* Regression pin for the current broken behavior:
819-
* a stale plist makes `service status` report registered even though launchd
820-
* cannot find the label. The packaged app logs the kickstart failure, then
821-
* still pays the full 15s wait before falling back to managed-spawn.
822-
*
823-
* When the fix lands, invert the >=14s stale assertions. The desired
824-
* behavior is a fast-fail to managed-spawn, close to the clean control.
868+
* Regression guard for the production incident:
869+
* a stale plist makes `service status` report registered while launchd cannot
870+
* find the label. The app must show a window immediately, then repair or
871+
* bypass that stale service without paying the old dead 15s wait.
825872
*/
826-
expect(stale.matchedLogLine, "stale boot logs the launchd kickstart failure").toContain(
827-
"launchctl kickstart failed (exit 113)",
828-
);
829-
expect(stale.matchedLogLine, "stale boot says launchd cannot find the label").toContain(
830-
'Could not find service "sh.executor.daemon"',
873+
expect(stale.kickstartFailureLine, "stale boot logs the launchd kickstart failure").toContain(
874+
"launchctl kickstart failed",
831875
);
876+
expect(
877+
stale.kickstartFailureLine,
878+
"the failed kickstart is classified as a stale registration to repair",
879+
).toContain("Failed to restart registered supervised service; reinstalling");
880+
expect(
881+
stale.recoveryLogLine,
882+
"kickstart failure is followed by self-heal or fallback logging",
883+
).not.toBeNull();
832884
expect(
833885
stale.pageCdpMs,
834-
"window/CDP is not reachable until the dead wait is paid",
835-
).toBeGreaterThanOrEqual(14_000);
886+
"the Electron page target is reachable before service recovery finishes",
887+
).toBeLessThan(3_000);
836888
expect(
837-
stale.sidecarReadyMs,
838-
"managed sidecar readiness is delayed by the dead wait",
839-
).toBeGreaterThanOrEqual(14_000);
840-
expect(stale.connectionKind, "the app eventually recovers through managed-spawn").toBe(
841-
"desktop-sidecar",
889+
stale.startupWindowMs,
890+
"the startup window appears promptly in the stale launchd case",
891+
).toBeLessThan(3_000);
892+
expect(new URL(stale.origin).port, "recovery must not attach to the stale plist port").not.toBe(
893+
String(STALE_PLIST_PORT),
842894
);
843-
expect(
844-
new URL(stale.origin).port,
845-
"managed recovery must not be a launchd attach to the stale plist port",
846-
).not.toBe(String(STALE_PLIST_PORT));
847895
expect(
848896
clean.sidecarReadyMs,
849897
"the clean control skips supervision and boots quickly",
850898
).toBeLessThan(10_000);
851899
expect(
852900
stale.sidecarReadyMs,
853-
"the stale launchd path is dramatically slower than the clean control",
854-
).toBeGreaterThan(clean.sidecarReadyMs + 5_000);
901+
"stale launchd recovery stays within the fast boot budget",
902+
).toBeLessThan(10_000);
903+
expect(
904+
stale.sidecarReadyMs,
905+
"the stale launchd path stays close to the clean control",
906+
).toBeLessThan(clean.sidecarReadyMs + 7_500);
907+
const repairedLaunchd = stale.launchdPrintSucceeded && stale.manifestKind === "cli-daemon";
908+
const managedFallback =
909+
!stale.launchdPrintSucceeded && stale.manifestKind === "desktop-sidecar";
910+
expect(
911+
repairedLaunchd || managedFallback,
912+
"stale state is either repaired into launchd or cleanly falls back to managed spawn",
913+
).toBe(true);
855914

856915
const recording = staleRecorder?.recordingStats();
857916
emitEvidence(
858-
`[stale-launchd-repro] clean=${clean.sidecarReadyMs}ms stale=${stale.sidecarReadyMs}ms stalePageCdp=${stale.pageCdpMs}ms cleanOrigin=${clean.origin} staleOrigin=${stale.origin} staleConnectionKind=${stale.connectionKind} staleManifestKind=${stale.manifestKind} stalePid=${stale.manifestPid}`,
917+
`[stale-launchd-repro] clean=${clean.sidecarReadyMs}ms stale=${stale.sidecarReadyMs}ms staleStartup=${stale.startupWindowMs}ms stalePageCdp=${stale.pageCdpMs}ms cleanOrigin=${clean.origin} staleOrigin=${stale.origin} staleConnectionKind=${stale.connectionKind} staleManifestKind=${stale.manifestKind} stalePid=${stale.manifestPid} launchdPrintAfterBoot=${stale.launchdPrintSucceeded}`,
859918
);
860-
emitEvidence(`[stale-launchd-repro] matchedLogLine=${stale.matchedLogLine}`);
919+
emitEvidence(`[stale-launchd-repro] kickstartFailureLine=${stale.kickstartFailureLine}`);
920+
emitEvidence(`[stale-launchd-repro] recoveryLogLine=${stale.recoveryLogLine}`);
861921
if (recording) {
862922
emitEvidence(
863923
`[stale-launchd-repro] recording=${recording.path} frames=${recording.frames} size=${recording.size}`,
392 KB
Binary file not shown.
-38.9 KB
Binary file not shown.

0 commit comments

Comments
 (0)