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
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 { · 41 unmodified lines
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> => { · 189 unmodified lines
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 => { · 203 unmodified lines
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,7 +550,8 @@ const bootPackagedApp = async (input: { · 3 unmodified lines
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();
@@ -524,6 +559,15 @@ const bootPackagedApp = async (input: { · 1 unmodified line
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: { · 4 unmodified lines
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 { · 129 unmodified lines
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 { · 20 unmodified lines
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> => { · 88 unmodified lines
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> => { · 2 unmodified lines
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}`,
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.