History

Desktop boot: show a window immediately and self-heal stale launchd state

Two boot-time fixes in the desktop main process: Show a lightweight "Starting Executor" window at app.whenReady, before any service or sidecar work, then load the real web UI into the same window once the connection resolves (no second-window flash). A pending second-instance now focuses that startup window instead of no-opping while connection is null. Classify the supervised service by both registered and running instead of ignoring the running signal: - registered and running: attach; only kick if the attach still misses it. - registered but not running (the stale plist/unit/task case): restart, and if the OS manager cannot start it, reinstall the service (install is already idempotent) before waiting. If install also fails, fall back to managed spawn immediately. The 15s attach wait is only paid after a kick or install actually succeeds, so a stale registration no longer costs a dead wait.

Rhys Sullivan committed 4 months ago

Commit 5023a2a1 parent 7f618052 files · +199 −45Browse files

Unified · Split

2 files
  1. apps/desktop/src/main/crash-screen.ts +98 −0
  2. apps/desktop/src/main/index.ts +101 −45
apps/desktop/src/main/crash-screen.ts+98−0
@@ -11,6 +11,104 @@ export interface CrashScreenOptions { · 10 unmodified lines
11 readonly reported: boolean;11 readonly reported: boolean;
12}12}
13 13
14export const startupWindowHtml = (): string => `<!doctype html>
15<html>
16 <head>
17 <meta charset="utf-8" />
18 <title>Executor</title>
19 <style>
20 :root {
21 color-scheme: light dark;
22 --background: #0a0a0a;
23 --foreground: #fafafa;
24 --muted: #a1a1aa;
25 --line: #27272a;
26 --pulse: #fafafa;
27 }
28 @media (prefers-color-scheme: light) {
29 :root {
30 --background: #f7f7f4;
31 --foreground: #18181b;
32 --muted: #60646c;
33 --line: #d8d8d0;
34 --pulse: #18181b;
35 }
36 }
37 body {
38 margin: 0;
39 min-height: 100vh;
40 display: grid;
41 place-items: center;
42 background: var(--background);
43 color: var(--foreground);
44 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
45 }
46 main {
47 width: min(22rem, calc(100vw - 4rem));
48 display: grid;
49 gap: 1rem;
50 justify-items: center;
51 text-align: center;
52 }
53 .mark {
54 width: 2.25rem;
55 height: 2.25rem;
56 border: 1px solid var(--line);
57 border-radius: 8px;
58 display: grid;
59 place-items: center;
60 font-size: 0.82rem;
61 font-weight: 650;
62 letter-spacing: 0;
63 }
64 h1 {
65 margin: 0;
66 font-size: 1.05rem;
67 font-weight: 600;
68 letter-spacing: 0;
69 }
70 p {
71 margin: -0.35rem 0 0;
72 color: var(--muted);
73 font-size: 0.82rem;
74 line-height: 1.5;
75 }
76 .activity {
77 width: 8rem;
78 height: 2px;
79 overflow: hidden;
80 border-radius: 999px;
81 background: var(--line);
82 }
83 .activity::after {
84 content: "";
85 display: block;
86 width: 45%;
87 height: 100%;
88 border-radius: inherit;
89 background: var(--pulse);
90 animation: slide 1.15s ease-in-out infinite;
91 }
92 @keyframes slide {
93 0% { transform: translateX(-110%); opacity: 0.35; }
94 50% { opacity: 0.9; }
95 100% { transform: translateX(245%); opacity: 0.35; }
96 }
97 @media (prefers-reduced-motion: reduce) {
98 .activity::after { animation: none; transform: none; width: 100%; opacity: 0.55; }
99 }
100 </style>
101 </head>
102 <body>
103 <main>
104 <div class="mark">Ex</div>
105 <h1>Starting Executor&hellip;</h1>
106 <p>Preparing your local workspace.</p>
107 <div class="activity" aria-hidden="true"></div>
108 </main>
109 </body>
110</html>`;
111
14export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<!doctype html>112export const sidecarCrashHtml = ({ reported }: CrashScreenOptions): string => `<!doctype html>
15<html>113<html>
16 <head>114 <head>
apps/desktop/src/main/index.ts+101−45
@@ -34,7 +34,7 @@ import { · 33 unmodified lines
34 initErrorReporting,34 initErrorReporting,
35 reportAProblem,35 reportAProblem,
36} from "./diagnostics";36} from "./diagnostics";
37import { sidecarCrashHtml } from "./crash-screen";37import { sidecarCrashHtml, startupWindowHtml } from "./crash-screen";
38import { replaceSupervisedDaemonForDesktop } from "./supervised-connection";38import { replaceSupervisedDaemonForDesktop } from "./supervised-connection";
39import {39import {
40 bundledExecutorPath,40 bundledExecutorPath,
@@ -137,6 +137,9 @@ const webUrlForConnection = (conn: SidecarConnection): string => { · 96 unmodified lines
137 return url.toString();137 return url.toString();
138};138};
139 139
140const htmlDataUrl = (html: string): string =>
141 `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
142
140// The supervised daemon (and the desktop sidecar) own this data dir — the same143// The supervised daemon (and the desktop sidecar) own this data dir — the same
141// path the CLI's `executor web`/daemon uses, so desktop and CLI share state.144// path the CLI's `executor web`/daemon uses, so desktop and CLI share state.
142const DESKTOP_DATA_DIR = join(homedir(), ".executor");145const DESKTOP_DATA_DIR = join(homedir(), ".executor");
@@ -216,6 +219,40 @@ const confirmEnableBackgroundService = async (): Promise<boolean> => { · 73 unmodified lines
216 return response === 0;219 return response === 0;
217};220};
218 221
222const acceptSupervisedConnection = async (
223 attached: SidecarConnection,
224): Promise<SidecarConnection | null> => {
225 if (!shouldReplaceDaemonForDesktop(attached)) return attached;
226 const settings = getServerSettings();
227 return replaceSupervisedDaemonForDesktop(attached, {
228 install: () =>
229 installSupervisedService({
230 port: settings.port,
231 dataDir: DESKTOP_DATA_DIR,
232 }),
233 waitForAttach: () => waitForSupervisedAttach(30_000, { port: settings.port }),
234 attach: attachToSupervisedDaemon,
235 onInstallFailure: (error) => {
236 log.warn("Failed to replace older supervised daemon; re-checking before falling back", error);
237 },
238 });
239};
240
241const installSupervisedAndWait = async (reason: string): Promise<SidecarConnection | null> => {
242 const settings = getServerSettings();
243 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: install failure falls back to managed-spawn so the app still launches
244 try {
245 await installSupervisedService({
246 port: settings.port,
247 dataDir: DESKTOP_DATA_DIR,
248 });
249 } catch (error) {
250 log.warn(`Failed to install supervised service after ${reason}; using managed sidecar`, error);
251 return null;
252 }
253 return waitForSupervisedAttach(15_000, { port: settings.port });
254};
255
219/**256/**
220 * Resolve a connection to the OS-supervised daemon, installing it on first run257 * Resolve a connection to the OS-supervised daemon, installing it on first run
221 * (with consent). Returns null when supervision is unavailable or the user258 * (with consent). Returns null when supervision is unavailable or the user
@@ -225,23 +262,7 @@ const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> = · 3 unmodified lines
225 // 1. Already running → attach.262 // 1. Already running → attach.
226 const attached = await attachToSupervisedDaemon();263 const attached = await attachToSupervisedDaemon();
227 if (attached) {264 if (attached) {
228 if (!shouldReplaceDaemonForDesktop(attached)) return attached;265 return acceptSupervisedConnection(attached);
229 const settings = getServerSettings();
230 return replaceSupervisedDaemonForDesktop(attached, {
231 install: () =>
232 installSupervisedService({
233 port: settings.port,
234 dataDir: DESKTOP_DATA_DIR,
235 }),
236 waitForAttach: () => waitForSupervisedAttach(30_000, { port: settings.port }),
237 attach: attachToSupervisedDaemon,
238 onInstallFailure: (error) => {
239 log.warn(
240 "Failed to replace older supervised daemon; re-checking before falling back",
241 error,
242 );
243 },
244 });
245 }266 }
246 267
247 // Headless/e2e: the first-run background-service prompt and the systemd/268 // Headless/e2e: the first-run background-service prompt and the systemd/
@@ -254,32 +275,41 @@ const ensureSupervisedConnection = async (): Promise<SidecarConnection | null> = · 6 unmodified lines
254 const status = await supervisedServiceStatus();275 const status = await supervisedServiceStatus();
255 if (!status.supported) return null;276 if (!status.supported) return null;
256 277
257 // 2. Registered but not currently serving → kick it and wait.278 // 2. Registered and running, but the first attach missed it. Kick once and
279 // wait only when the kick command succeeds.
258 if (status.registered) {280 if (status.registered) {
259 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a restart failure just falls through to managed-spawn281 if (status.running) {
282 const attachedAfterStatus = await attachToSupervisedDaemon();
283 if (attachedAfterStatus) return acceptSupervisedConnection(attachedAfterStatus);
284 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: a restart failure just falls through to managed-spawn
285 try {
286 await restartSupervisedService();
287 } catch (error) {
288 log.warn(
289 "Failed to restart running supervised service after attach failed; using managed sidecar",
290 error,
291 );
292 return null;
293 }
294 return waitForSupervisedAttach(15_000);
295 }
296
297 // 3. Registered but not running usually means a stale plist/unit/task. Try
298 // restart first, then reinstall the service if the OS manager cannot start it.
299 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: restart failure triggers supervised service self-heal
260 try {300 try {
261 await restartSupervisedService();301 await restartSupervisedService();
262 } catch (error) {302 } catch (error) {
263 log.warn("Failed to kickstart supervised service", error);303 log.warn("Failed to restart registered supervised service; reinstalling", error);
304 return installSupervisedAndWait("registered service restart failure");
264 }305 }
265 return waitForSupervisedAttach(15_000);306 return waitForSupervisedAttach(15_000);
266 }307 }
267 308
268 // 3. First run → ask, then install + start. The unit carries no secret; the309 // 4. First run → ask, then install + start. The unit carries no secret; the
269 // supervised daemon mints/loads its bearer from auth.json under DESKTOP_DATA_DIR.310 // supervised daemon mints/loads its bearer from auth.json under DESKTOP_DATA_DIR.
270 if (!(await confirmEnableBackgroundService())) return null;311 if (!(await confirmEnableBackgroundService())) return null;
271 const settings = getServerSettings();312 return installSupervisedAndWait("first-run prompt");
272 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: install failure falls back to managed-spawn so the app still launches
273 try {
274 await installSupervisedService({
275 port: settings.port,
276 dataDir: DESKTOP_DATA_DIR,
277 });
278 } catch (error) {
279 log.error("Failed to install supervised service; using managed sidecar", error);
280 return null;
281 }
282 return waitForSupervisedAttach(15_000);
283};313};
284 314
285// Crash monitor for the supervised daemon: the OS service manager restarts it315// Crash monitor for the supervised daemon: the OS service manager restarts it
@@ -308,8 +338,7 @@ const armSupervisedMonitor = () => { · 22 unmodified lines
308 if (supervisedMonitorMisses < SUPERVISED_MONITOR_MISSES_BEFORE_DOWN) return;338 if (supervisedMonitorMisses < SUPERVISED_MONITOR_MISSES_BEFORE_DOWN) return;
309 if (!supervisedDaemonDown && window) {339 if (!supervisedDaemonDown && window) {
310 supervisedDaemonDown = true;340 supervisedDaemonDown = true;
311 const html = sidecarCrashHtml({ reported: errorReportingEnabled });341 showCrashScreen(window);
312 void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
313 }342 }
314 return;343 return;
315 }344 }
@@ -408,14 +437,12 @@ const installDockIcon = () => { · 92 unmodified lines
408 log.info(`[dock-icon] set to ${iconPath} (${image.getSize().width}×${image.getSize().height})`);437 log.info(`[dock-icon] set to ${iconPath} (${image.getSize().width}×${image.getSize().height})`);
409};438};
410 439
411const createWindow = async (conn: SidecarConnection) => {440const createMainBrowserWindow = (options: { readonly show: boolean }): BrowserWindow => {
412 const windowState = windowStateKeeper({441 const windowState = windowStateKeeper({
413 defaultWidth: 1280,442 defaultWidth: 1280,
414 defaultHeight: 800,443 defaultHeight: 800,
415 });444 });
416 445
417 installBearerAuthHeader(conn.baseUrl, conn.authToken);
418
419 const linuxIcon = resolveLinuxIcon();446 const linuxIcon = resolveLinuxIcon();
420 447
421 const window = new BrowserWindow({448 const window = new BrowserWindow({
@@ -431,7 +458,7 @@ const createWindow = async (conn: SidecarConnection) => { · 9 unmodified lines
431 // header, which is offset to clear them (.desktop-macos-titlebar).458 // header, which is offset to clear them (.desktop-macos-titlebar).
432 minWidth: 768,459 minWidth: 768,
433 minHeight: 480,460 minHeight: 480,
434 show: false,461 show: options.show,
435 backgroundColor: "#0a0a0a",462 backgroundColor: "#0a0a0a",
436 autoHideMenuBar: true,463 autoHideMenuBar: true,
437 ...(process.platform === "darwin"464 ...(process.platform === "darwin"
@@ -488,15 +515,45 @@ const createWindow = async (conn: SidecarConnection) => { · 50 unmodified lines
488 return { action: "deny" };515 return { action: "deny" };
489 });516 });
490 517
518 return window;
519};
520
521const showStartupWindow = async (): Promise<void> => {
522 const window = liveMainWindow() ?? createMainBrowserWindow({ show: true });
523 if (window.isMinimized()) window.restore();
524 if (!window.isVisible()) window.show();
525 window.focus();
526
527 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local startup HTML should not block service startup
528 try {
529 await window.loadURL(htmlDataUrl(startupWindowHtml()));
530 } catch (error) {
531 log.warn("Failed to load startup window", error);
532 }
533};
534
535const showCrashScreen = (window: BrowserWindow | null = liveMainWindow()): void => {
536 if (!window) return;
537 const html = sidecarCrashHtml({ reported: errorReportingEnabled });
538 void window.loadURL(htmlDataUrl(html));
539};
540
541const createWindow = async (conn: SidecarConnection) => {
542 installBearerAuthHeader(conn.baseUrl, conn.authToken);
543
544 const existingWindow = liveMainWindow();
545 const window = existingWindow ?? createMainBrowserWindow({ show: false });
546
491 // A supervised daemon can pass the health probe and still disappear before547 // A supervised daemon can pass the health probe and still disappear before
492 // navigation begins. Treat that as a failed connection instead of leaving the548 // navigation begins. Treat that as a failed connection instead of leaving the
493 // user with a visible BrowserWindow that only shows the black background.549 // user with a visible BrowserWindow that only shows the black background.
494 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Electron navigation rejects when the sidecar vanishes during startup550 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: Electron navigation rejects when the sidecar vanishes during startup
495 try {551 try {
496 await window.loadURL(webUrlForConnection(conn));552 await window.loadURL(webUrlForConnection(conn));
553 if (!window.isDestroyed() && !window.isVisible()) window.show();
497 } catch (error) {554 } catch (error) {
498 log.error("Failed to load Executor web UI", error);555 log.error("Failed to load Executor web UI", error);
499 destroyWindow(window);556 if (!existingWindow) destroyWindow(window);
500 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: caller decides whether to fall back or surface startup failure557 // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: caller decides whether to fall back or surface startup failure
501 throw error;558 throw error;
502 }559 }
@@ -916,6 +973,7 @@ const runUpdateCheck = async ({ alertOnFail }: UpdateCheckOptions) => { · 413 unmodified lines
916// here, the boot-time update check never runs (it sits after a successful973// here, the boot-time update check never runs (it sits after a successful
917// sidecar start), so a broken app could never self-update its way out.974// sidecar start), so a broken app could never self-update its way out.
918const handleFatalSidecarFailure = async (error: unknown) => {975const handleFatalSidecarFailure = async (error: unknown) => {
976 showCrashScreen();
919 if (app.isPackaged) {977 if (app.isPackaged) {
920 // Install whatever finishes downloading by the time the user quits the978 // Install whatever finishes downloading by the time the user quits the
921 // failure dialog; if it downloads while the dialog is open, the regular979 // failure dialog; if it downloads while the dialog is open, the regular
@@ -1035,6 +1093,7 @@ const installApplicationMenu = () => { · 113 unmodified lines
1035const boot = async () => {1093const boot = async () => {
1036 installDockIcon();1094 installDockIcon();
1037 installApplicationMenu();1095 installApplicationMenu();
1096 await showStartupWindow();
1038 setupAutoUpdater();1097 setupAutoUpdater();
1039 applyFakeUpdateFromEnv();1098 applyFakeUpdateFromEnv();
1040 registerIpcHandlers();1099 registerIpcHandlers();
@@ -1042,10 +1101,7 @@ const boot = async () => { · 1 unmodified line
1042 // every request with no explanation. Swap in the crash screen — its1101 // every request with no explanation. Swap in the crash screen — its
1043 // buttons drive the regular preload bridge (restart / export diagnostics).1102 // buttons drive the regular preload bridge (restart / export diagnostics).
1044 onUnexpectedSidecarExit(() => {1103 onUnexpectedSidecarExit(() => {
1045 const window = liveMainWindow();1104 showCrashScreen();
1046 if (!window) return;
1047 const html = sidecarCrashHtml({ reported: errorReportingEnabled });
1048 void window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
1049 // A crashing sidecar may be a broken release — quietly stage any1105 // A crashing sidecar may be a broken release — quietly stage any
1050 // available update so the install prompt appears on its own (same1106 // available update so the install prompt appears on its own (same
1051 // self-heal as the fatal startup path).1107 // self-heal as the fatal startup path).

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t