History

[codex] Fix desktop black screen fallback (#1051)

* Fix desktop black screen fallback * Add desktop black screen changeset

Rhys Sullivan committed 4 months ago

Commit cfda0ac1 parent af96bae6 files · +355 −18Browse files

Unified · Split

6 files
  1. .changeset/desktop-black-screen-fallback.md +8 −0
  2. apps/desktop/src/main/index.ts +83 −17
  3. apps/desktop/src/main/supervised-connection.test.ts +79 −0
  4. apps/desktop/src/main/supervised-connection.ts +22 −0
  5. e2e/desktop-packaged/supervised-regressions.test.ts +135 −1
  6. e2e/notes/testing-on-mac.md +28 −0
.changeset/desktop-black-screen-fallback.md+8−0
@@ -0,0 +1,8 @@
1---
2"executor": patch
3---
4
5Fix desktop startup so a failed supervised-daemon replacement no longer leaves
6the app on a black window. The desktop now re-checks the daemon after install
7failures, falls back to a managed sidecar when the stale daemon disappears, and
8surfaces startup recovery instead of leaving a failed renderer visible.
apps/desktop/src/main/index.ts+83−17
@@ -35,6 +35,7 @@ import { · 34 unmodified lines
35 reportAProblem,35 reportAProblem,
36} from "./diagnostics";36} from "./diagnostics";
37import { sidecarCrashHtml } from "./crash-screen";37import { sidecarCrashHtml } from "./crash-screen";
38import { replaceSupervisedDaemonForDesktop } from "./supervised-connection";
38import {39import {
39 bundledExecutorPath,40 bundledExecutorPath,
40 installSupervisedService,41 installSupervisedService,
@@ -88,6 +89,11 @@ const liveMainWindow = (): BrowserWindow | null => { · 47 unmodified lines
88 return window;89 return window;
89};90};
90 91
92const destroyWindow = (window: BrowserWindow) => {
93 if (mainWindow === window) mainWindow = null;
94 if (!window.isDestroyed()) window.destroy();
95};
96
91const focusMainWindow = () => {97const focusMainWindow = () => {
92 const window = liveMainWindow();98 const window = liveMainWindow();
93 if (!window) {99 if (!window) {
@@ -208,17 +214,21 @@ const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> = · 114 unmodified lines
208 if (attached) {214 if (attached) {
209 if (!shouldReplaceDaemonForDesktop(attached)) return attached;215 if (!shouldReplaceDaemonForDesktop(attached)) return attached;
210 const settings = getServerSettings();216 const settings = getServerSettings();
211 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: desktop launch should attach to the running daemon if automatic upgrade fails217 return replaceSupervisedDaemonForDesktop(attached, {
212 try {218 install: () =>
213 await installSupervisedService({219 installSupervisedService({
214 port: settings.port,220 port: settings.port,
215 dataDir: DESKTOP_DATA_DIR,221 dataDir: DESKTOP_DATA_DIR,
216 });222 }),
217 return (await waitForSupervisedAttach(30_000, { port: settings.port })) ?? attached;223 waitForAttach: () => waitForSupervisedAttach(30_000, { port: settings.port }),
218 } catch (error) {224 attach: attachToSupervisedDaemon,
219 log.warn("Failed to replace older supervised daemon; attaching to the running daemon", error);225 onInstallFailure: (error) => {
220 return attached;226 log.warn(
221 }227 "Failed to replace older supervised daemon; re-checking before falling back",
228 error,
229 );
230 },
231 });
222 }232 }
223 233
224 const status = await supervisedServiceStatus();234 const status = await supervisedServiceStatus();
@@ -452,7 +462,18 @@ const createWindow = async (conn: SidecarConnection) => { · 227 unmodified lines
452 return { action: "deny" };462 return { action: "deny" };
453 });463 });
454 464
455 await window.loadURL(webUrlForConnection(conn));465 // A supervised daemon can pass the health probe and still disappear before
466 // navigation begins. Treat that as a failed connection instead of leaving the
467 // user with a visible BrowserWindow that only shows the black background.
468 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Electron navigation rejects when the sidecar vanishes during startup
469 try {
470 await window.loadURL(webUrlForConnection(conn));
471 } catch (error) {
472 log.error("Failed to load Executor web UI", error);
473 destroyWindow(window);
474 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: caller decides whether to fall back or surface startup failure
475 throw error;
476 }
456};477};
457 478
458const showPortInUseDialog = async (port: number) => {479const showPortInUseDialog = async (port: number) => {
@@ -852,10 +873,18 @@ const boot = async () => { · 393 unmodified lines
852 const supervised = await ensureSupervisedConnection();873 const supervised = await ensureSupervisedConnection();
853 if (supervised) {874 if (supervised) {
854 connection = supervised;875 connection = supervised;
855 await createWindow(supervised); // installs the bearer-auth header itself876 // createWindow installs the bearer-auth header itself.
856 armSupervisedMonitor();877 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: supervised attach can race with daemon shutdown; fall back to managed spawn
857 void runUpdateCheck({ alertOnFail: false });878 try {
858 return;879 await createWindow(supervised);
880 armSupervisedMonitor();
881 void runUpdateCheck({ alertOnFail: false });
882 return;
883 } catch (error) {
884 log.warn("Failed to load supervised daemon; falling back to managed sidecar", error);
885 stopSupervisedMonitor();
886 connection = null;
887 }
859 }888 }
860 }889 }
861 connection = await startWithCurrentSettings();890 connection = await startWithCurrentSettings();
@@ -878,7 +907,44 @@ const boot = async () => { · 16 unmodified lines
878 app.quit();907 app.quit();
879 return;908 return;
880 }909 }
881 await createWindow(connection);910 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: renderer navigation failure must show the startup recovery dialog, not a black window
911 try {
912 await createWindow(connection);
913 } catch (error) {
914 log.error("Failed to load managed sidecar web UI", error);
915 const failedConnection = connection;
916 connection = null;
917 await stopConnection(failedConnection);
918
919 const retryAfterReset = await handleFatalSidecarFailure(error);
920 if (!retryAfterReset) {
921 app.quit();
922 return;
923 }
924
925 lastSidecarStartError = null;
926 connection = await startWithCurrentSettings();
927 if (!connection && lastSidecarStartError != null) {
928 await handleFatalSidecarFailure(lastSidecarStartError);
929 }
930 if (!connection) {
931 app.quit();
932 return;
933 }
934
935 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: one post-reset retry before giving the user the final startup failure
936 try {
937 await createWindow(connection);
938 } catch (retryError) {
939 log.error("Failed to load managed sidecar web UI after reset", retryError);
940 const failedRetryConnection = connection;
941 connection = null;
942 await stopConnection(failedRetryConnection);
943 await handleFatalSidecarFailure(retryError);
944 app.quit();
945 return;
946 }
947 }
882 // Check at boot. If an update is available, autoDownload pulls it and948 // Check at boot. If an update is available, autoDownload pulls it and
883 // the 'update-downloaded' handler fires the install dialog. Silent on949 // the 'update-downloaded' handler fires the install dialog. Silent on
884 // no-update / failure so we don't bother users on every launch.950 // no-update / failure so we don't bother users on every launch.
apps/desktop/src/main/supervised-connection.test.ts+79−0
@@ -0,0 +1,79 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";
3import type { SidecarConnection } from "./sidecar";
4import { replaceSupervisedDaemonForDesktop } from "./supervised-connection";
5
6const connection = (baseUrl: string): SidecarConnection => ({
7 baseUrl,
8 hostname: "localhost",
9 port: 4789,
10 username: "executor",
11 authToken: "token",
12 child: null,
13 supervisedDaemon: true,
14 ownerVersion: "1.5.12",
15 ownerClient: "cli",
16 ownerExecutablePath: "/tmp/old-executor",
17});
18
19const rejectedInstall = (error: unknown): Promise<void> => Effect.runPromise(Effect.fail(error));
20
21describe("replaceSupervisedDaemonForDesktop", () => {
22 it("uses the replacement daemon after install succeeds", async () => {
23 const stale = connection("http://localhost:4789");
24 const fresh = connection("http://localhost:55334");
25 let installCalls = 0;
26 let waitCalls = 0;
27 let attachCalls = 0;
28
29 const result = await replaceSupervisedDaemonForDesktop(stale, {
30 install: async () => {
31 installCalls += 1;
32 },
33 waitForAttach: async () => {
34 waitCalls += 1;
35 return fresh;
36 },
37 attach: async () => {
38 attachCalls += 1;
39 return stale;
40 },
41 });
42
43 expect(result).toBe(fresh);
44 expect(installCalls).toBe(1);
45 expect(waitCalls).toBe(1);
46 expect(attachCalls).toBe(0);
47 });
48
49 it("re-probes after install fails instead of returning the stale daemon", async () => {
50 const stale = connection("http://localhost:4789");
51 const installFailure = { _tag: "InstallFailure" };
52 let loggedError: unknown;
53 let loggedConnection: SidecarConnection | null = null;
54 let waitCalls = 0;
55 let attachCalls = 0;
56
57 const result = await replaceSupervisedDaemonForDesktop(stale, {
58 install: () => rejectedInstall(installFailure),
59 waitForAttach: async () => {
60 waitCalls += 1;
61 return stale;
62 },
63 attach: async () => {
64 attachCalls += 1;
65 return null;
66 },
67 onInstallFailure: (error, connection) => {
68 loggedError = error;
69 loggedConnection = connection;
70 },
71 });
72
73 expect(result).toBeNull();
74 expect(waitCalls).toBe(0);
75 expect(attachCalls).toBe(1);
76 expect(loggedError).toBe(installFailure);
77 expect(loggedConnection).toBe(stale);
78 });
79});
apps/desktop/src/main/supervised-connection.ts+22−0
@@ -0,0 +1,22 @@
1/* oxlint-disable executor/no-try-catch-or-throw -- boundary: Electron startup must convert service-install failures into a fresh daemon probe */
2import type { SidecarConnection } from "./sidecar";
3
4export interface ReplaceSupervisedDaemonInput {
5 readonly install: () => Promise<void>;
6 readonly waitForAttach: () => Promise<SidecarConnection | null>;
7 readonly attach: () => Promise<SidecarConnection | null>;
8 readonly onInstallFailure?: (error: unknown, staleConnection: SidecarConnection) => void;
9}
10
11export const replaceSupervisedDaemonForDesktop = async (
12 staleConnection: SidecarConnection,
13 input: ReplaceSupervisedDaemonInput,
14): Promise<SidecarConnection | null> => {
15 try {
16 await input.install();
17 return (await input.waitForAttach()) ?? (await input.attach());
18 } catch (error) {
19 input.onInstallFailure?.(error, staleConnection);
20 return await input.attach();
21 }
22};
e2e/desktop-packaged/supervised-regressions.test.ts+135−1
@@ -660,6 +660,15 @@ if (!guiAvailable() || !packagedSingleInstanceAvailable()) { · 659 unmodified lines
660 }),660 }),
661 );661 );
662 662
663 scenario(
664 "Desktop packaged supervised attach · install failure falls back instead of black-screening",
665 { timeout: 300_000 },
666 Effect.gen(function* () {
667 const runDir = yield* RunDir;
668 yield* Effect.promise(() => runInstallFailureFallsBackToManagedSidecar(runDir));
669 }),
670 );
671
663 scenario(672 scenario(
664 "Desktop packaged supervised settings · changing the port moves the active daemon",673 "Desktop packaged supervised settings · changing the port moves the active daemon",
665 { timeout: 300_000 },674 { timeout: 300_000 },
@@ -685,6 +694,9 @@ const writeCliDaemonManifest = (input: { · 19 unmodified lines
685 readonly origin: string;694 readonly origin: string;
686 readonly displayName: string;695 readonly displayName: string;
687 readonly token: string;696 readonly token: string;
697 readonly ownerVersion?: string | null;
698 readonly ownerClient?: "cli" | "desktop";
699 readonly ownerExecutablePath?: string | null;
688}): void => {700}): void => {
689 mkdirSync(input.controlDir, { recursive: true });701 mkdirSync(input.controlDir, { recursive: true });
690 writeFileSync(702 writeFileSync(
@@ -701,12 +713,134 @@ const writeCliDaemonManifest = (input: { · 10 unmodified lines
701 displayName: input.displayName,713 displayName: input.displayName,
702 auth: { kind: "bearer", token: input.token },714 auth: { kind: "bearer", token: input.token },
703 }),715 }),
704 owner: { client: "cli", version: null, executablePath: null },716 owner: {
717 client: input.ownerClient ?? "cli",
718 version: input.ownerVersion ?? null,
719 executablePath: input.ownerExecutablePath ?? null,
720 },
705 }),721 }),
706 { mode: 0o600 },722 { mode: 0o600 },
707 );723 );
708};724};
709 725
726const shellSingleQuote = (value: string): string => `'${value.replaceAll("'", "'\"'\"'")}'`;
727
728const withFailingBundledInstall = async <T>(run: () => Promise<T>): Promise<T> => {
729 const { executor } = requireBundle();
730 const original = readFileSync(executor);
731 const mode = statSync(executor).mode & 0o777;
732 const backup = `${executor}.e2e-real`;
733 writeFileSync(backup, original, { mode });
734 chmodSync(backup, mode);
735 writeFileSync(
736 executor,
737 [
738 "#!/bin/sh",
739 'if [ "$1" = "install" ]; then',
740 ' echo "launchctl bootstrap failed (exit 5): Bootstrap failed: 5: Input/output error" >&2',
741 " exit 1",
742 "fi",
743 `exec ${shellSingleQuote(backup)} "$@"`,
744 "",
745 ].join("\n"),
746 { mode: 0o755 },
747 );
748
749 try {
750 return await run();
751 } finally {
752 writeFileSync(executor, original, { mode });
753 chmodSync(executor, mode);
754 rmSync(backup, { force: true });
755 }
756};
757
758const runInstallFailureFallsBackToManagedSidecar = async (runDir: string) => {
759 const home = mkdtempSync(join(tmpdir(), "executor-pkg-install-failure-fallback-"));
760 const dataDir = join(home, ".executor");
761 const controlDir = join(dataDir, "server-control");
762 const token = "install-failure-fallback-token";
763 const launchdSnapshot = captureLaunchdService();
764 const oldPort = await freePort();
765 const requests: Array<{ readonly url: string; readonly authorization: string | null }> = [];
766 let resolveHealthProbe!: () => void;
767 const sawHealthProbe = new Promise<void>((resolve) => {
768 resolveHealthProbe = resolve;
769 });
770 let app: PackagedApp | undefined;
771 let serverOpen = false;
772
773 const server = createServer((req: IncomingMessage, res) => {
774 const url = req.url ?? "/";
775 requests.push({
776 url,
777 authorization: req.headers.authorization ?? null,
778 });
779 if (url.startsWith("/api/health")) {
780 res.writeHead(200, {
781 "content-type": "text/plain",
782 connection: "close",
783 });
784 res.end("ok", () => {
785 resolveHealthProbe();
786 void closeServer();
787 });
788 return;
789 }
790 res.writeHead(200, { "content-type": "text/html" });
791 res.end("<!doctype html><title>Stale Executor</title><body>Stale daemon</body>");
792 });
793 const closeServer = async (): Promise<void> => {
794 if (!serverOpen) return;
795 serverOpen = false;
796 await new Promise<void>((resolve) => server.close(() => resolve()));
797 };
798
799 try {
800 await new Promise<void>((resolve) =>
801 server.listen(oldPort, "127.0.0.1", () => {
802 serverOpen = true;
803 resolve();
804 }),
805 );
806 writeCliDaemonManifest({
807 controlDir,
808 dataDir,
809 origin: `http://127.0.0.1:${oldPort}`,
810 displayName: "Older daemon that disappears",
811 token,
812 ownerVersion: "0.0.0",
813 });
814
815 await withFailingBundledInstall(async () => {
816 app = await launchPackaged(home);
817 const page = app.cdp;
818 await sawHealthProbe;
819 await page.waitForText("Settings", 120_000);
820 await page.screenshot(join(runDir, "01-fell-back-to-managed-sidecar.png"));
821
822 const connection = await page.evaluate<{ readonly origin: string } | null>(
823 "window.executor.getServerConnection()",
824 );
825 expect(
826 new URL(connection!.origin).port,
827 "desktop should not keep rendering from the stale daemon port after install failure",
828 ).not.toBe(String(oldPort));
829 expect(
830 requests
831 .filter((request) => request.url.startsWith("/api/health"))
832 .map((request) => request.authorization),
833 "the stale-daemon health probe must not disclose the saved bearer",
834 ).not.toContain(`Bearer ${token}`);
835 });
836 } finally {
837 await closePackaged(app);
838 await restoreLaunchdService(launchdSnapshot);
839 await closeServer();
840 rmSync(home, { recursive: true, force: true });
841 }
842};
843
710const runSlowLiveDaemonProbe = async (runDir: string) => {844const runSlowLiveDaemonProbe = async (runDir: string) => {
711 const home = mkdtempSync(join(tmpdir(), "executor-pkg-slow-live-daemon-"));845 const home = mkdtempSync(join(tmpdir(), "executor-pkg-slow-live-daemon-"));
712 const dataDir = join(home, ".executor");846 const dataDir = join(home, ".executor");
e2e/notes/testing-on-mac.md+28−0
@@ -0,0 +1,28 @@
1# Testing on macOS
2
3Packaged desktop e2e tests need a real GUI session. Check `launchctl managername`;
4it should report `Aqua`. SSH-only sessions usually land in the Background
5launchd domain, where `open` can fail with `OSLaunchdErrorDomain Code=125` even
6after the VM is reachable over SSH.
7
8For VM runs, log in through VNC before launching the app. SSH is still useful
9for staging files, reading logs, and running probes, but GUI app launch and
10window assertions are more reliable after a console user is active.
11
12[trycua/cua](https://github.com/trycua/cua)'s Lume CLI is a useful way to clone,
13boot, SSH into, VNC into, stop, and delete local macOS VMs programmatically.
14
15The packaged desktop app has a single-instance lock on macOS. If
16`/Applications/Executor.app` is already running on the host, prefer an isolated
17VM or close that app before running `vitest run --project desktop-packaged`.
18
19Useful evidence locations:
20
21- Main process log: `~/Library/Logs/Executor/main.log`
22- Supervised daemon manifest: `~/.executor/server-control/server.json`
23- Electron CDP port: `~/Library/Application Support/Executor/DevToolsActivePort`
24
25When debugging black-window reports, verify both pixels and renderer state. VNC
26can occasionally show a black framebuffer while CDP proves the renderer DOM is
27healthy, so capture a CDP screenshot or inspect `document.body.innerText` before
28calling a UI blank.