From aa8776907c173f4c6419dd27d9dcc227866b7b34 Mon Sep 17 00:00:00 2001 From: BIackFIame <77388790+BIackFIame@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:51:39 +0000 Subject: [PATCH] fix(startup): load the application surface only after the startup page settled Startup could fail with "ERR_ABORTED (-3) loading 'data:text/html...'" when the machine was busy. Since services start while the startup page loads, the application surface could start its navigation while the page was still committing or loading in its own renderer process. The application surface then committed first, and the page's ERR_ABORTED arrived afterwards, while the loadFile promise was still waiting. Electron's loadURL/loadFile promise takes the first main-frame did-fail-load it sees as its own, so the application load rejected with the startup page's abort and startup showed the failure page. Services still start while the page loads. The application surface now waits until the page load has settled, which it usually has by the time services are up. A close during the page load is still a quiet quit, and a real page error on a live window still fails startup. Measured with 40 sequential hidden launches per build, each helper process (renderer, GPU, utility) paused at random for 20 to 300 ms during the first navigations: origin/main failed 4 and 6 of 40, the same loop with this change 0 of 80. The build before "perf(startup): start services while the startup page loads" had 0 of 40. Without pauses all builds start 40 of 40; the median time to a ready window goes from 342 to 379 ms. --- src/main/index.ts | 54 ++++++++++++++++++-------------- tests/startup-lifecycle.test.mjs | 45 +++++++++++++++++++------- 2 files changed, 65 insertions(+), 34 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 2a9358ef..b2ae2c74 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -173,8 +173,8 @@ if (!hasSingleInstanceLock) app.quit(); /** * Creates the shell window and starts loading the startup page into it. The - * page load is not awaited: services start next to it, and the application - * surface may replace the page before it finished (see startApplication). + * page load is not awaited here: services start next to it, and startApplication + * waits for it to settle before it loads the application surface. */ function createWindow(): { window: BrowserWindow; startupPage: StartupPageLoad } { const window = new BrowserWindow({ @@ -241,28 +241,30 @@ function createWindow(): { window: BrowserWindow; startupPage: StartupPageLoad } } }); - const startupPage: StartupPageLoad = { failure: null, superseded: false }; - window.loadURL(startupPageUrl({ locale: app.getLocale(), isMacOS: process.platform === "darwin" })).catch((error) => { - // A close during this load aborts the navigation (ERR_ABORTED / ERR_FAILED). - // That is a quit, not a failed startup; the application surface replacing a - // page that was still loading aborts it the same way. Neither is a failure; - // a real error on a live window is kept for startApplication to report. - if (shellWindowGone(window)) { - console.warn("CanvasTTY startup page load stopped: its window is gone, the application is closing.", error); - return; - } - if (!startupPage.superseded) startupPage.failure = error; - }); + const startupPage: StartupPageLoad = window + .loadURL(startupPageUrl({ locale: app.getLocale(), isMacOS: process.platform === "darwin" })) + .then( + () => null, + (error: unknown) => { + // A close during this load aborts the navigation (ERR_ABORTED / ERR_FAILED). + // That is a quit, not a failed startup; a real error on a live window is + // handed to startApplication to report. + if (shellWindowGone(window)) { + console.warn("CanvasTTY startup page load stopped: its window is gone, the application is closing.", error); + return null; + } + return error ?? new Error("The startup page did not load."); + } + ); return { window, startupPage }; } -/** The startup page load of a fresh shell window, as startApplication sees it. */ -interface StartupPageLoad { - /** A real load error of the page, reported as a failed startup. */ - failure: unknown; - /** Set once the application surface starts loading: aborting the page is expected then. */ - superseded: boolean; -} +/** + * The startup page load of a fresh shell window: it settles with the load error + * to report as a failed startup, or null once the page loaded (or its window is + * gone). It never rejects. + */ +type StartupPageLoad = Promise; /** * True when the shell window is on its way out: its close was requested (the @@ -827,8 +829,14 @@ async function startApplication(): Promise { if (!servicesReady) await initializeServices(); if (shutdownRunning || shutdownComplete || shellWindowGone(window)) return; if (startupPage) { - if (startupPage.failure) throw startupPage.failure; - startupPage.superseded = true; + // The application surface must not replace a page that is still loading: + // Chromium can report that page's ERR_ABORTED after the next navigation has + // started, and Electron's loadFile/loadURL promise takes the first main-frame + // load failure it sees as its own, so startup would fail with the startup + // page's abort. The page usually settles before services are up. + const failure = await startupPage; + if (shutdownRunning || shutdownComplete || shellWindowGone(window)) return; + if (failure !== null) throw failure; } initializeUpdater(); await loadApplication(window); diff --git a/tests/startup-lifecycle.test.mjs b/tests/startup-lifecycle.test.mjs index 89faf867..6772fa5c 100644 --- a/tests/startup-lifecycle.test.mjs +++ b/tests/startup-lifecycle.test.mjs @@ -36,11 +36,15 @@ test("closing during service startup stops renderer loading without a failure di assert.equal(failures, 1, "a real error on a live window still reaches the failure page"); }); -test("services start while the startup page is still loading, and only a real page error fails startup", async () => { +test("services start while the startup page loads, and the application surface waits until the page settled", async () => { const source = await readFile(mainPath, "utf8"); const start = source.slice(source.indexOf("async function startApplication"), source.indexOf("function buildProviderCliRegistry")); const events = []; - let page; + const turn = () => new Promise((resolve) => setImmediate(resolve)); + let settlePage; + // The startup page load as createWindow hands it over: it settles with the error to report, or null. + const pendingPage = () => new Promise((resolve) => { settlePage = resolve; }); + let gone = false; const context = { startupRunning: false, shutdownRunning: false, @@ -48,22 +52,41 @@ test("services start while the startup page is still loading, and only a real pa servicesReady: false, mainWindow: null, process: { env: {} }, - shellWindowGone: () => false, - // The page load never settles here: startup must not wait for it. - createWindow: () => { page = { failure: null, superseded: false }; events.push("window"); return { window: {}, startupPage: page }; }, - initializeServices: async () => { events.push(`services superseded=${page.superseded}`); }, + shellWindowGone: () => gone, + createWindow: () => { events.push("window"); return { window: {}, startupPage: pendingPage() }; }, + initializeServices: async () => { events.push("services"); }, initializeUpdater: () => events.push("updater"), - loadApplication: async () => { events.push(`app superseded=${page.superseded}`); }, + loadApplication: async () => { events.push("app"); }, showStartupFailure: async (_window, error) => { events.push(`failure ${error.message}`); } }; const startApplication = runInNewContext(`${stripTypeScriptTypes(start)}; startApplication`, context); - await startApplication(); - assert.deepEqual(events, ["window", "services superseded=false", "updater", "app superseded=true"]); + // Services do not wait for the page. The application surface does: a page replaced while it is still loading + // reports its ERR_ABORTED late, and Electron's loadFile promise takes that failure as its own. + let startup = startApplication(); + await turn(); + assert.deepEqual(events, ["window", "services"]); + events.push("page settled"); + settlePage(null); + await startup; + assert.deepEqual(events, ["window", "services", "page settled", "updater", "app"]); + + // A real page error on a live window fails startup. events.length = 0; - context.initializeServices = async () => { page.failure = new Error("startup page failed"); }; + context.createWindow = () => { events.push("window"); return { window: {}, startupPage: Promise.resolve(new Error("startup page failed")) }; }; await startApplication(); - assert.deepEqual(events, ["window", "failure startup page failed"]); + assert.deepEqual(events, ["window", "services", "failure startup page failed"]); + + // A close while the page is still loading ends startup quietly once the page settles. + events.length = 0; + context.createWindow = () => { events.push("window"); return { window: {}, startupPage: pendingPage() }; }; + startup = startApplication(); + await turn(); + gone = true; + settlePage(null); + await startup; + assert.deepEqual(events, ["window", "services"]); + assert.equal(context.startupRunning, false); }); test("dependencies only some paths need are not imported when the main process starts", async () => {