Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 31 additions & 23 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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<unknown>;

/**
* True when the shell window is on its way out: its close was requested (the
Expand Down Expand Up @@ -827,8 +829,14 @@ async function startApplication(): Promise<void> {
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);
Expand Down
45 changes: 34 additions & 11 deletions tests/startup-lifecycle.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,34 +36,57 @@ 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,
shutdownComplete: false,
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 () => {
Expand Down
Loading