History

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.

Rhys Sullivan committed 4 months ago

Commit 12e7d751 parent a1b7f3a3 files · +112 −52Browse files

Unified · Split

3 files
  1. e2e/desktop-packaged/stale-launchd-registration.test.ts +114 −54
  2. e2e/recordings/stale-launchd-fast-boot.mp4 +0 −0
  3. e2e/recordings/stale-launchd-slow-boot.mp4 +0 −0
e2e/desktop-packaged/stale-launchd-registration.test.ts+114−54
@@ -23,8 +23,8 @@ import { RunDir } from "../src/services"; · 22 unmodified lines
23const execFileAsync = promisify(execFile);23const execFileAsync = promisify(execFile);
24const SERVICE_LABEL = "sh.executor.daemon";24const SERVICE_LABEL = "sh.executor.daemon";
25const SCENARIO_NAME =25const SCENARIO_NAME =
26 "Desktop packaged supervised attach: stale launchd registration pins the 15s dead boot wait";26 "Desktop packaged supervised attach: stale launchd registration self-heals without a dead boot wait";
27const RECORDING_FILE = resolve("recordings/stale-launchd-slow-boot.mp4");27const RECORDING_FILE = resolve("recordings/stale-launchd-fast-boot.mp4");
28const RECORDING_WIDTH = 1280;28const RECORDING_WIDTH = 1280;
29const RECORDING_HEIGHT = 720;29const RECORDING_HEIGHT = 720;
30const STALE_PLIST_PORT = 58_251;30const STALE_PLIST_PORT = 58_251;
@@ -72,12 +72,15 @@ interface LaunchTiming { · 41 unmodified lines
72 72
73interface BootResult {73interface BootResult {
74 readonly pageCdpMs: number;74 readonly pageCdpMs: number;
75 readonly startupWindowMs: number;
75 readonly sidecarReadyMs: number;76 readonly sidecarReadyMs: number;
76 readonly connectionKind: string;77 readonly connectionKind: string;
77 readonly origin: string;78 readonly origin: string;
78 readonly manifestKind: string;79 readonly manifestKind: string;
79 readonly manifestPid: number;80 readonly manifestPid: number;
80 readonly matchedLogLine: string | null;81 readonly kickstartFailureLine: string | null;
82 readonly recoveryLogLine: string | null;
83 readonly launchdPrintSucceeded: boolean;
81}84}
82 85
83declare global {86declare global {
@@ -273,6 +276,17 @@ const launchctl = async (args: ReadonlyArray<string>): Promise<boolean> => { · 189 unmodified lines
273 }276 }
274};277};
275 278
279const 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
276const captureLaunchdService = (): LaunchdServiceSnapshot | null => {290const captureLaunchdService = (): LaunchdServiceSnapshot | null => {
277 if (process.platform !== "darwin") return null;291 if (process.platform !== "darwin") return null;
278 const path = launchAgentPath();292 const path = launchAgentPath();
@@ -482,31 +496,51 @@ const readServerManifest = (home: string): ServerManifest => { · 203 unmodified lines
482 return JSON.parse(readFileSync(manifestPath, "utf8")) as ServerManifest;496 return JSON.parse(readFileSync(manifestPath, "utf8")) as ServerManifest;
483};497};
484 498
485const readKickstartFailureLine = (home: string): string | null => {499interface 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.
510const isKickstartFailureLine = (line: string): boolean =>
511 line.includes("Failed to restart registered supervised service; reinstalling") &&
512 line.includes("launchctl kickstart failed");
513
514const 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
519const readStaleRecoveryLog = (home: string): StaleRecoveryLog => {
486 const logPath = join(home, "Library", "Logs", "Executor", "main.log");520 const logPath = join(home, "Library", "Logs", "Executor", "main.log");
487 if (!existsSync(logPath)) return null;521 if (!existsSync(logPath)) return { kickstartFailureLine: null, recoveryLogLine: null };
488 return (522 const lines = readFileSync(logPath, "utf8").split("\n");
489 readFileSync(logPath, "utf8")523 const kickstartIndex = lines.findIndex(isKickstartFailureLine);
490 .split("\n")524 if (kickstartIndex < 0) return { kickstartFailureLine: null, recoveryLogLine: null };
491 .find(525 const recoveryLogLine =
492 (line) =>526 lines.slice(kickstartIndex + 1).find((line) => isRecoveryLogLine(line)) ?? null;
493 line.includes("launchctl kickstart failed (exit 113)") &&527 return {
494 line.includes('Could not find service "sh.executor.daemon"'),528 kickstartFailureLine: lines[kickstartIndex] ?? null,
495 ) ?? null529 recoveryLogLine,
496 );530 };
497};531};
498 532
499const waitForKickstartFailureLine = async (home: string): Promise<string> => {533const waitForStaleRecoveryLog = async (home: string): Promise<StaleRecoveryLog> => {
500 const deadline = Date.now() + 30_000;534 const deadline = Date.now() + 30_000;
501 for (;;) {535 for (;;) {
502 const line = readKickstartFailureLine(home);536 const lines = readStaleRecoveryLog(home);
503 if (line) return line;537 if (lines.kickstartFailureLine && lines.recoveryLogLine) return lines;
504 if (Date.now() >= deadline) {538 if (Date.now() >= deadline) {
505 const logPath = join(home, "Library", "Logs", "Executor", "main.log");539 const logPath = join(home, "Library", "Logs", "Executor", "main.log");
506 const tail = existsSync(logPath)540 const tail = existsSync(logPath)
507 ? readFileSync(logPath, "utf8").split("\n").slice(-20).join("\n")541 ? readFileSync(logPath, "utf8").split("\n").slice(-20).join("\n")
508 : "<main.log missing>";542 : "<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}`);
510 }544 }
511 await new Promise((resolveWait) => setTimeout(resolveWait, 250));545 await new Promise((resolveWait) => setTimeout(resolveWait, 250));
512 }546 }
@@ -516,7 +550,8 @@ const bootPackagedApp = async (input: { · 3 unmodified lines
516 readonly home: string;550 readonly home: string;
517 readonly env: NodeJS.ProcessEnv;551 readonly env: NodeJS.ProcessEnv;
518 readonly recorder: BootRecorder | null;552 readonly recorder: BootRecorder | null;
519 readonly expectKickstartFailure: boolean;553 readonly expectStartupWindow: boolean;
554 readonly expectStaleRecoveryLog: boolean;
520}): Promise<BootResult> => {555}): Promise<BootResult> => {
521 let app: PackagedApp | undefined;556 let app: PackagedApp | undefined;
522 const startedAt = Date.now();557 const startedAt = Date.now();
@@ -524,6 +559,15 @@ const bootPackagedApp = async (input: { · 1 unmodified line
524 const launched = await launchPackaged(input.env, input.recorder);559 const launched = await launchPackaged(input.env, input.recorder);
525 app = launched.app;560 app = launched.app;
526 const page = app.cdp;561 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);
527 await page.waitForText("Settings", 120_000);571 await page.waitForText("Settings", 120_000);
528 const sidecarReadyMs = Date.now() - startedAt;572 const sidecarReadyMs = Date.now() - startedAt;
529 await input.recorder?.captureReadyFrame();573 await input.recorder?.captureReadyFrame();
@@ -534,17 +578,21 @@ const bootPackagedApp = async (input: { · 4 unmodified lines
534 } | null>("window.executor.getServerConnection()");578 } | null>("window.executor.getServerConnection()");
535 expect(connection, "the app eventually exposes a server connection").not.toBeNull();579 expect(connection, "the app eventually exposes a server connection").not.toBeNull();
536 const manifest = readServerManifest(input.home);580 const manifest = readServerManifest(input.home);
537 const matchedLogLine = input.expectKickstartFailure581 const recoveryLog = input.expectStaleRecoveryLog
538 ? await waitForKickstartFailureLine(input.home)582 ? await waitForStaleRecoveryLog(input.home)
539 : readKickstartFailureLine(input.home);583 : readStaleRecoveryLog(input.home);
584 const launchdPrintSucceeded = (await launchctlPrintService()) !== null;
540 return {585 return {
541 pageCdpMs: launched.pageCdpMs,586 pageCdpMs: launched.pageCdpMs,
587 startupWindowMs,
542 sidecarReadyMs,588 sidecarReadyMs,
543 connectionKind: connection!.kind,589 connectionKind: connection!.kind,
544 origin: connection!.origin,590 origin: connection!.origin,
545 manifestKind: manifest.kind,591 manifestKind: manifest.kind,
546 manifestPid: manifest.pid,592 manifestPid: manifest.pid,
547 matchedLogLine,593 kickstartFailureLine: recoveryLog.kickstartFailureLine,
594 recoveryLogLine: recoveryLog.recoveryLogLine,
595 launchdPrintSucceeded,
548 };596 };
549 } finally {597 } finally {
550 await input.recorder?.stop();598 await input.recorder?.stop();
@@ -680,7 +728,7 @@ class BootRecorder { · 129 unmodified lines
680 728
681 static create = (runDir: string): BootRecorder | null => {729 static create = (runDir: string): BootRecorder | null => {
682 if (process.env.E2E_RECORD !== "1") return null;730 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");
684 rmSync(frameDir, { recursive: true, force: true });732 rmSync(frameDir, { recursive: true, force: true });
685 mkdirSync(frameDir, { recursive: true });733 mkdirSync(frameDir, { recursive: true });
686 mkdirSync(dirname(RECORDING_FILE), { recursive: true });734 mkdirSync(dirname(RECORDING_FILE), { recursive: true });
@@ -707,7 +755,7 @@ class BootRecorder { · 20 unmodified lines
707 755
708 captureReadyFrame = async (): Promise<void> => {756 captureReadyFrame = async (): Promise<void> => {
709 if (!this.enabled) return;757 if (!this.enabled) return;
710 this.enqueueFrame("managed sidecar ready", this.page);758 this.enqueueFrame("executor ready", this.page);
711 await this.queue;759 await this.queue;
712 };760 };
713 761
@@ -802,7 +850,8 @@ const run = async (runDir: string): Promise<void> => { · 88 unmodified lines
802 home: cleanHome,850 home: cleanHome,
803 env: cleanBootEnv(cleanHome),851 env: cleanBootEnv(cleanHome),
804 recorder: null,852 recorder: null,
805 expectKickstartFailure: false,853 expectStartupWindow: false,
854 expectStaleRecoveryLog: false,
806 });855 });
807 856
808 writeStaleLaunchAgentPlist(staleHome);857 writeStaleLaunchAgentPlist(staleHome);
@@ -811,53 +860,64 @@ const run = async (runDir: string): Promise<void> => { · 2 unmodified lines
811 home: staleHome,860 home: staleHome,
812 env: packagedAppEnv(staleHome),861 env: packagedAppEnv(staleHome),
813 recorder: staleRecorder,862 recorder: staleRecorder,
814 expectKickstartFailure: true,863 expectStartupWindow: true,
864 expectStaleRecoveryLog: true,
815 });865 });
816 866
817 /*867 /*
818 * Regression pin for the current broken behavior:868 * Regression guard for the production incident:
819 * a stale plist makes `service status` report registered even though launchd869 * a stale plist makes `service status` report registered while launchd cannot
820 * cannot find the label. The packaged app logs the kickstart failure, then870 * find the label. The app must show a window immediately, then repair or
821 * still pays the full 15s wait before falling back to managed-spawn.871 * bypass that stale service without paying the old dead 15s wait.
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.
825 */872 */
826 expect(stale.matchedLogLine, "stale boot logs the launchd kickstart failure").toContain(873 expect(stale.kickstartFailureLine, "stale boot logs the launchd kickstart failure").toContain(
827 "launchctl kickstart failed (exit 113)",874 "launchctl kickstart failed",
828 );
829 expect(stale.matchedLogLine, "stale boot says launchd cannot find the label").toContain(
830 'Could not find service "sh.executor.daemon"',
831 );875 );
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();
832 expect(884 expect(
833 stale.pageCdpMs,885 stale.pageCdpMs,
834 "window/CDP is not reachable until the dead wait is paid",886 "the Electron page target is reachable before service recovery finishes",
835 ).toBeGreaterThanOrEqual(14_000);887 ).toBeLessThan(3_000);
836 expect(888 expect(
837 stale.sidecarReadyMs,889 stale.startupWindowMs,
838 "managed sidecar readiness is delayed by the dead wait",890 "the startup window appears promptly in the stale launchd case",
839 ).toBeGreaterThanOrEqual(14_000);891 ).toBeLessThan(3_000);
840 expect(stale.connectionKind, "the app eventually recovers through managed-spawn").toBe(892 expect(new URL(stale.origin).port, "recovery must not attach to the stale plist port").not.toBe(
841 "desktop-sidecar",893 String(STALE_PLIST_PORT),
842 );894 );
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));
847 expect(895 expect(
848 clean.sidecarReadyMs,896 clean.sidecarReadyMs,
849 "the clean control skips supervision and boots quickly",897 "the clean control skips supervision and boots quickly",
850 ).toBeLessThan(10_000);898 ).toBeLessThan(10_000);
851 expect(899 expect(
852 stale.sidecarReadyMs,900 stale.sidecarReadyMs,
853 "the stale launchd path is dramatically slower than the clean control",901 "stale launchd recovery stays within the fast boot budget",
854 ).toBeGreaterThan(clean.sidecarReadyMs + 5_000);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);
855 914
856 const recording = staleRecorder?.recordingStats();915 const recording = staleRecorder?.recordingStats();
857 emitEvidence(916 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}`,
859 );918 );
860 emitEvidence(`[stale-launchd-repro] matchedLogLine=${stale.matchedLogLine}`);919 emitEvidence(`[stale-launchd-repro] kickstartFailureLine=${stale.kickstartFailureLine}`);
920 emitEvidence(`[stale-launchd-repro] recoveryLogLine=${stale.recoveryLogLine}`);
861 if (recording) {921 if (recording) {
862 emitEvidence(922 emitEvidence(
863 `[stale-launchd-repro] recording=${recording.path} frames=${recording.frames} size=${recording.size}`,923 `[stale-launchd-repro] recording=${recording.path} frames=${recording.frames} size=${recording.size}`,
e2e/recordings/stale-launchd-fast-boot.mp4+0−0

Binary file not shown.

e2e/recordings/stale-launchd-slow-boot.mp4+0−0

Binary file not shown.