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 () => {