Fix generation-safe renderer startup recovery
Bind renderer Ready and initialization-failure IPC to per-navigation document URLs and main-frame identity so stale documents cannot cancel the active deadline or reset the recovery budget. Serialize initial-load retries with crash, initialization, and timeout recovery behind one bounded navigation budget. Keep the main window hidden until a validated Ready signal while allowing the branded failsafe to become visible. Move production lifecycle wiring into the startup handlers and add event-driven regressions for stale Ready signals, pre-finish initialization failures, timeout recovery, visibility, and crashes during the initial navigation.
This commit is contained in:
+221
-66
@@ -18,6 +18,20 @@ function createStartupFailureDocument(language) {
|
|||||||
return `<!doctype html><html lang="${german ? 'de' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Multi Hoster Uploader</title><style>html,body{height:100%;margin:0;background:#1f1f1f;color:#f4f4f4;font:15px system-ui,sans-serif}body{display:grid;place-items:center}.card{width:min(520px,calc(100% - 48px));padding:28px;border:1px solid #444;border-radius:12px;background:#292929;box-shadow:0 18px 50px #0008}h1{margin:0 0 12px;font-size:22px}p{margin:0 0 22px;color:#c8c8c8;line-height:1.5}button{min-height:38px;padding:0 18px;border:1px solid #555;border-radius:7px;background:#363636;color:#fff;font-weight:650;cursor:pointer}button:hover{background:#414141}</style></head><body><main class="card"><h1>${title}</h1><p>${detail}</p><button type="button" onclick="window.close()">${close}</button></main></body></html>`;
|
return `<!doctype html><html lang="${german ? 'de' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Multi Hoster Uploader</title><style>html,body{height:100%;margin:0;background:#1f1f1f;color:#f4f4f4;font:15px system-ui,sans-serif}body{display:grid;place-items:center}.card{width:min(520px,calc(100% - 48px));padding:28px;border:1px solid #444;border-radius:12px;background:#292929;box-shadow:0 18px 50px #0008}h1{margin:0 0 12px;font-size:22px}p{margin:0 0 22px;color:#c8c8c8;line-height:1.5}button{min-height:38px;padding:0 18px;border:1px solid #555;border-radius:7px;background:#363636;color:#fff;font-weight:650;cursor:pointer}button:hover{background:#414141}</style></head><body><main class="card"><h1>${title}</h1><p>${detail}</p><button type="button" onclick="window.close()">${close}</button></main></body></html>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createStartupNavigationLoader(window, target, options = {}) {
|
||||||
|
let startupDocument = 0;
|
||||||
|
return function loadStartupDocument() {
|
||||||
|
startupDocument++;
|
||||||
|
return window.loadFile(target, {
|
||||||
|
...options,
|
||||||
|
query: {
|
||||||
|
...options.query,
|
||||||
|
startupDocument: String(startupDocument)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createStartupRecoveryCoordinator({
|
function createStartupRecoveryCoordinator({
|
||||||
load,
|
load,
|
||||||
reload,
|
reload,
|
||||||
@@ -29,12 +43,15 @@ function createStartupRecoveryCoordinator({
|
|||||||
cancelReadyDeadline = clearTimeout
|
cancelReadyDeadline = clearTimeout
|
||||||
}) {
|
}) {
|
||||||
let initialLoad;
|
let initialLoad;
|
||||||
let crashReloads = 0;
|
let initialLoadPending = false;
|
||||||
|
let navigation;
|
||||||
|
let recoveryNavigations = 0;
|
||||||
let terminalFailure;
|
let terminalFailure;
|
||||||
let recovery;
|
let recovery;
|
||||||
|
let queuedRecovery;
|
||||||
let readyDeadline;
|
let readyDeadline;
|
||||||
let rendererDocument = 0;
|
let rendererGeneration = 0;
|
||||||
let awaitingReady = false;
|
let awaitingGeneration = null;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
|
|
||||||
function clearRendererDeadline() {
|
function clearRendererDeadline() {
|
||||||
@@ -42,16 +59,33 @@ function createStartupRecoveryCoordinator({
|
|||||||
readyDeadline = undefined;
|
readyDeadline = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function abandonRendererDocument() {
|
function clearRendererDocument() {
|
||||||
awaitingReady = false;
|
awaitingGeneration = null;
|
||||||
clearRendererDeadline();
|
clearRendererDeadline();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function acceptRendererDocument(generation) {
|
||||||
|
if (!Number.isInteger(generation) || awaitingGeneration !== generation) return false;
|
||||||
|
clearRendererDocument();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runNavigation(operation, args = []) {
|
||||||
|
if (navigation) return navigation;
|
||||||
|
const currentNavigation = Promise.resolve().then(() => operation(...args));
|
||||||
|
navigation = currentNavigation;
|
||||||
|
try {
|
||||||
|
return await currentNavigation;
|
||||||
|
} finally {
|
||||||
|
if (navigation === currentNavigation) navigation = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function endWithFailure(failure) {
|
function endWithFailure(failure) {
|
||||||
if (stopped) return Promise.resolve(false);
|
if (stopped) return Promise.resolve(false);
|
||||||
if (!terminalFailure) {
|
if (!terminalFailure) {
|
||||||
abandonRendererDocument();
|
clearRendererDocument();
|
||||||
terminalFailure = (async () => {
|
terminalFailure = Promise.resolve().then(async () => {
|
||||||
if (typeof showFailure !== 'function') {
|
if (typeof showFailure !== 'function') {
|
||||||
await close(failure);
|
await close(failure);
|
||||||
return;
|
return;
|
||||||
@@ -62,29 +96,12 @@ function createStartupRecoveryCoordinator({
|
|||||||
} catch (surfaceError) {
|
} catch (surfaceError) {
|
||||||
await close({ ...failure, surfaceError });
|
await close({ ...failure, surfaceError });
|
||||||
}
|
}
|
||||||
})();
|
});
|
||||||
}
|
}
|
||||||
return terminalFailure;
|
return terminalFailure;
|
||||||
}
|
}
|
||||||
|
|
||||||
function recoverRenderer(phase, details) {
|
function trackRecovery(currentRecovery) {
|
||||||
if (stopped) return Promise.resolve(false);
|
|
||||||
if (terminalFailure) return terminalFailure;
|
|
||||||
if (recovery) return recovery;
|
|
||||||
abandonRendererDocument();
|
|
||||||
const currentRecovery = (async () => {
|
|
||||||
if (crashReloads >= 1) {
|
|
||||||
return endWithFailure({ phase, attempt: crashReloads + 1, details });
|
|
||||||
}
|
|
||||||
crashReloads++;
|
|
||||||
try {
|
|
||||||
await reload();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
if (stopped) return false;
|
|
||||||
return endWithFailure({ phase: 'renderer-reload', attempt: crashReloads, details, error });
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
recovery = currentRecovery;
|
recovery = currentRecovery;
|
||||||
currentRecovery.then(
|
currentRecovery.then(
|
||||||
() => {
|
() => {
|
||||||
@@ -97,16 +114,55 @@ function createStartupRecoveryCoordinator({
|
|||||||
return currentRecovery;
|
return currentRecovery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function performRecovery(phase, details) {
|
||||||
|
if (stopped) return false;
|
||||||
|
if (terminalFailure) return terminalFailure;
|
||||||
|
if (recoveryNavigations >= 1) {
|
||||||
|
return endWithFailure({ phase, attempt: recoveryNavigations + 1, details });
|
||||||
|
}
|
||||||
|
recoveryNavigations++;
|
||||||
|
try {
|
||||||
|
await runNavigation(reload);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (stopped) return false;
|
||||||
|
return endWithFailure({ phase: 'renderer-reload', attempt: recoveryNavigations, details, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recoverRenderer(phase, details) {
|
||||||
|
if (stopped) return Promise.resolve(false);
|
||||||
|
if (terminalFailure) return terminalFailure;
|
||||||
|
clearRendererDocument();
|
||||||
|
if (initialLoadPending) {
|
||||||
|
queuedRecovery = { phase, details, recoveryNavigations };
|
||||||
|
if (recovery) return recovery;
|
||||||
|
const currentRecovery = Promise.resolve(initialLoad).then(() => {
|
||||||
|
const pendingRecovery = queuedRecovery;
|
||||||
|
queuedRecovery = undefined;
|
||||||
|
if (stopped) return false;
|
||||||
|
if (terminalFailure) return terminalFailure;
|
||||||
|
if (!pendingRecovery || recoveryNavigations > pendingRecovery.recoveryNavigations) return true;
|
||||||
|
return performRecovery(pendingRecovery.phase, pendingRecovery.details);
|
||||||
|
});
|
||||||
|
return trackRecovery(currentRecovery);
|
||||||
|
}
|
||||||
|
if (recovery) return recovery;
|
||||||
|
return trackRecovery(performRecovery(phase, details));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
loadInitial(...args) {
|
loadInitial(...args) {
|
||||||
if (stopped) return Promise.resolve(false);
|
if (stopped) return Promise.resolve(false);
|
||||||
if (terminalFailure) return terminalFailure;
|
if (terminalFailure) return terminalFailure;
|
||||||
if (!initialLoad) {
|
if (!initialLoad) {
|
||||||
initialLoad = (async () => {
|
initialLoadPending = true;
|
||||||
|
const currentInitialLoad = (async () => {
|
||||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
if (stopped) return false;
|
if (stopped) return false;
|
||||||
|
if (attempt === 2) recoveryNavigations = Math.max(recoveryNavigations, 1);
|
||||||
try {
|
try {
|
||||||
return await load(...args);
|
return await runNavigation(load, args);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (stopped) return false;
|
if (stopped) return false;
|
||||||
if (attempt === 2) {
|
if (attempt === 2) {
|
||||||
@@ -115,6 +171,15 @@ function createStartupRecoveryCoordinator({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
initialLoad = currentInitialLoad;
|
||||||
|
currentInitialLoad.then(
|
||||||
|
() => {
|
||||||
|
initialLoadPending = false;
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
initialLoadPending = false;
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return initialLoad;
|
return initialLoad;
|
||||||
},
|
},
|
||||||
@@ -126,73 +191,165 @@ function createStartupRecoveryCoordinator({
|
|||||||
},
|
},
|
||||||
rendererLoadStarted() {
|
rendererLoadStarted() {
|
||||||
if (stopped || terminalFailure) return false;
|
if (stopped || terminalFailure) return false;
|
||||||
clearRendererDeadline();
|
clearRendererDocument();
|
||||||
rendererDocument++;
|
rendererGeneration++;
|
||||||
awaitingReady = true;
|
awaitingGeneration = rendererGeneration;
|
||||||
return true;
|
return rendererGeneration;
|
||||||
},
|
},
|
||||||
rendererLoaded() {
|
rendererLoaded(generation) {
|
||||||
if (stopped || terminalFailure || !awaitingReady) return false;
|
if (stopped || terminalFailure || awaitingGeneration !== generation) return false;
|
||||||
clearRendererDeadline();
|
clearRendererDeadline();
|
||||||
const document = rendererDocument;
|
|
||||||
readyDeadline = scheduleReadyDeadline(() => {
|
readyDeadline = scheduleReadyDeadline(() => {
|
||||||
if (stopped || terminalFailure || !awaitingReady || rendererDocument !== document) return false;
|
if (stopped || terminalFailure || awaitingGeneration !== generation) return false;
|
||||||
readyDeadline = undefined;
|
readyDeadline = undefined;
|
||||||
return recoverRenderer('renderer-ready-timeout', { timeoutMs: readyTimeoutMs });
|
return recoverRenderer('renderer-ready-timeout', { timeoutMs: readyTimeoutMs });
|
||||||
}, readyTimeoutMs);
|
}, readyTimeoutMs);
|
||||||
if (readyDeadline && typeof readyDeadline.unref === 'function') readyDeadline.unref();
|
if (readyDeadline && typeof readyDeadline.unref === 'function') readyDeadline.unref();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
rendererReady() {
|
rendererReady(generation) {
|
||||||
if (stopped || terminalFailure) return false;
|
if (stopped || terminalFailure || !acceptRendererDocument(generation)) return false;
|
||||||
abandonRendererDocument();
|
recoveryNavigations = 0;
|
||||||
crashReloads = 0;
|
|
||||||
reveal();
|
reveal();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
dispose() {
|
dispose() {
|
||||||
if (stopped) return false;
|
if (stopped) return false;
|
||||||
stopped = true;
|
stopped = true;
|
||||||
abandonRendererDocument();
|
queuedRecovery = undefined;
|
||||||
|
clearRendererDocument();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStartupRendererHandlers({ window, coordinator, onReady, onInitializationFailed }) {
|
function createStartupRendererHandlers({
|
||||||
|
window,
|
||||||
|
ipcMain,
|
||||||
|
coordinator,
|
||||||
|
onDocumentLoadStarted,
|
||||||
|
onRendererCrashed,
|
||||||
|
onReady,
|
||||||
|
onInitializationFailed
|
||||||
|
}) {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
|
let generation = null;
|
||||||
|
let activeFrame = null;
|
||||||
|
let pendingDocumentUrl = null;
|
||||||
|
let pendingFrameAddress = null;
|
||||||
|
const webContents = window && window.webContents;
|
||||||
|
|
||||||
function accepts(event) {
|
function frameAddress(frame) {
|
||||||
return !disposed && window && !window.isDestroyed() && event && event.sender === window.webContents;
|
if (!frame || frame.detached) return null;
|
||||||
|
if (typeof frame.isDestroyed === 'function' && frame.isDestroyed()) return null;
|
||||||
|
if (!Number.isInteger(frame.processId) || typeof frame.frameToken !== 'string' || !frame.frameToken) return null;
|
||||||
|
return `${frame.processId}:${frame.frameToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frameIdentity(frame) {
|
||||||
|
const address = frameAddress(frame);
|
||||||
|
if (!address) return null;
|
||||||
|
if (typeof frame.url !== 'string' || !frame.url) return null;
|
||||||
|
return `${address}:${frame.url}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDocument(url, frame) {
|
||||||
|
if (disposed) return false;
|
||||||
|
generation = coordinator.rendererLoadStarted();
|
||||||
|
activeFrame = null;
|
||||||
|
pendingDocumentUrl = typeof url === 'string' && url ? url : null;
|
||||||
|
pendingFrameAddress = frameAddress(frame);
|
||||||
|
if (generation !== false && typeof onDocumentLoadStarted === 'function') onDocumentLoadStarted();
|
||||||
|
return generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishDocument() {
|
||||||
|
if (disposed || generation === null) return false;
|
||||||
|
activeFrame = frameIdentity(webContents && webContents.mainFrame);
|
||||||
|
return coordinator.rendererLoaded(generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveEventFrame(event) {
|
||||||
|
if (disposed || !window || window.isDestroyed() || !event || event.sender !== webContents) return null;
|
||||||
|
if (!frameIdentity(event.senderFrame)) return null;
|
||||||
|
return event.senderFrame;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveReadyGeneration(event) {
|
||||||
|
const senderFrame = resolveEventFrame(event);
|
||||||
|
if (!senderFrame || !Number.isInteger(generation) || !activeFrame || frameIdentity(senderFrame) !== activeFrame) return null;
|
||||||
|
return generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveInitializationGeneration(event) {
|
||||||
|
const senderFrame = resolveEventFrame(event);
|
||||||
|
if (!senderFrame || !Number.isInteger(generation)) return null;
|
||||||
|
if (activeFrame) return frameIdentity(senderFrame) === activeFrame ? generation : null;
|
||||||
|
if (!pendingDocumentUrl || !pendingFrameAddress) return null;
|
||||||
|
if (senderFrame.url !== pendingDocumentUrl || frameAddress(senderFrame) !== pendingFrameAddress) return null;
|
||||||
|
return generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNavigation(details, _url, isInPlace, isMainFrame) {
|
||||||
|
const sameDocument = details && typeof details.isSameDocument === 'boolean' ? details.isSameDocument : isInPlace;
|
||||||
|
const mainFrame = details && typeof details.isMainFrame === 'boolean' ? details.isMainFrame : isMainFrame;
|
||||||
|
if (sameDocument || mainFrame === false) return false;
|
||||||
|
const url = details && typeof details.url === 'string' ? details.url : _url;
|
||||||
|
const frame = details && details.frame;
|
||||||
|
return startDocument(url, frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRendererCrash(_event, details) {
|
||||||
|
if (disposed) return false;
|
||||||
|
if (typeof onRendererCrashed === 'function') onRendererCrashed(details);
|
||||||
|
return coordinator.rendererCrashed(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRendererInitializationFailed(event, details) {
|
||||||
|
if (resolveInitializationGeneration(event) === null) return false;
|
||||||
|
if (typeof onInitializationFailed === 'function') onInitializationFailed(details);
|
||||||
|
return coordinator.rendererInitializationFailed(details);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRendererReady(event) {
|
||||||
|
const eventGeneration = resolveReadyGeneration(event);
|
||||||
|
if (eventGeneration === null) return false;
|
||||||
|
const ready = coordinator.rendererReady(eventGeneration);
|
||||||
|
if (ready && typeof onReady === 'function') onReady();
|
||||||
|
return ready;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (webContents && typeof webContents.on === 'function') {
|
||||||
|
webContents.on('did-start-navigation', handleNavigation);
|
||||||
|
webContents.on('did-finish-load', finishDocument);
|
||||||
|
webContents.on('render-process-gone', handleRendererCrash);
|
||||||
|
}
|
||||||
|
if (ipcMain && typeof ipcMain.on === 'function') {
|
||||||
|
ipcMain.on('app:close-handshake-ready', handleRendererReady);
|
||||||
|
ipcMain.on('app:renderer-initialization-failed', handleRendererInitializationFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
documentLoadStarted() {
|
documentLoadStarted: startDocument,
|
||||||
if (disposed) return false;
|
documentLoaded: finishDocument,
|
||||||
return coordinator.rendererLoadStarted();
|
|
||||||
},
|
|
||||||
documentLoaded() {
|
|
||||||
if (disposed) return false;
|
|
||||||
return coordinator.rendererLoaded();
|
|
||||||
},
|
|
||||||
rendererCrashed(details) {
|
rendererCrashed(details) {
|
||||||
if (disposed) return false;
|
if (disposed) return false;
|
||||||
return coordinator.rendererCrashed(details);
|
return coordinator.rendererCrashed(details);
|
||||||
},
|
},
|
||||||
rendererInitializationFailed(event, details) {
|
rendererInitializationFailed: handleRendererInitializationFailed,
|
||||||
if (!accepts(event)) return false;
|
rendererReady: handleRendererReady,
|
||||||
if (typeof onInitializationFailed === 'function') onInitializationFailed(details);
|
|
||||||
return coordinator.rendererInitializationFailed(details);
|
|
||||||
},
|
|
||||||
rendererReady(event) {
|
|
||||||
if (!accepts(event)) return false;
|
|
||||||
const ready = coordinator.rendererReady();
|
|
||||||
if (ready && typeof onReady === 'function') onReady();
|
|
||||||
return ready;
|
|
||||||
},
|
|
||||||
dispose() {
|
dispose() {
|
||||||
if (disposed) return false;
|
if (disposed) return false;
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
if (webContents && typeof webContents.removeListener === 'function') {
|
||||||
|
webContents.removeListener('did-start-navigation', handleNavigation);
|
||||||
|
webContents.removeListener('did-finish-load', finishDocument);
|
||||||
|
webContents.removeListener('render-process-gone', handleRendererCrash);
|
||||||
|
}
|
||||||
|
if (ipcMain && typeof ipcMain.removeListener === 'function') {
|
||||||
|
ipcMain.removeListener('app:close-handshake-ready', handleRendererReady);
|
||||||
|
ipcMain.removeListener('app:renderer-initialization-failed', handleRendererInitializationFailed);
|
||||||
|
}
|
||||||
return coordinator.dispose();
|
return coordinator.dispose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -200,9 +357,6 @@ function createStartupRendererHandlers({ window, coordinator, onReady, onInitial
|
|||||||
|
|
||||||
function createStartupWindow(BrowserWindow, options) {
|
function createStartupWindow(BrowserWindow, options) {
|
||||||
const window = new BrowserWindow({ ...options, show: false });
|
const window = new BrowserWindow({ ...options, show: false });
|
||||||
window.once('ready-to-show', () => {
|
|
||||||
window.show();
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
window,
|
window,
|
||||||
@@ -215,6 +369,7 @@ function createStartupWindow(BrowserWindow, options) {
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
configureStartupRenderer,
|
configureStartupRenderer,
|
||||||
createStartupFailureDocument,
|
createStartupFailureDocument,
|
||||||
|
createStartupNavigationLoader,
|
||||||
createStartupRecoveryCoordinator,
|
createStartupRecoveryCoordinator,
|
||||||
createStartupRendererHandlers,
|
createStartupRendererHandlers,
|
||||||
createStartupWindow,
|
createStartupWindow,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ app.setAppUserModelId('com.multihoster.uploader');
|
|||||||
const {
|
const {
|
||||||
configureStartupRenderer,
|
configureStartupRenderer,
|
||||||
createStartupFailureDocument,
|
createStartupFailureDocument,
|
||||||
|
createStartupNavigationLoader,
|
||||||
createStartupRecoveryCoordinator,
|
createStartupRecoveryCoordinator,
|
||||||
createStartupRendererHandlers,
|
createStartupRendererHandlers,
|
||||||
createStartupWindow,
|
createStartupWindow,
|
||||||
@@ -1461,23 +1462,6 @@ function createWindow() {
|
|||||||
|
|
||||||
mainWindow.webContents.setBackgroundThrottling(false);
|
mainWindow.webContents.setBackgroundThrottling(false);
|
||||||
|
|
||||||
mainWindow.webContents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => {
|
|
||||||
if (isInPlace || !isMainFrame) return;
|
|
||||||
closeHandshakeReady = false;
|
|
||||||
restoreClosePreparation(closePreparationAttempt);
|
|
||||||
if (startupRendererHandlers) startupRendererHandlers.documentLoadStarted();
|
|
||||||
});
|
|
||||||
|
|
||||||
mainWindow.webContents.on('did-finish-load', () => {
|
|
||||||
if (startupRendererHandlers) startupRendererHandlers.documentLoaded();
|
|
||||||
});
|
|
||||||
|
|
||||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
|
||||||
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
|
||||||
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
|
|
||||||
if (startupRecoveryCoordinator) void startupRecoveryCoordinator.rendererCrashed(details);
|
|
||||||
});
|
|
||||||
|
|
||||||
mainWindow.webContents.on('unresponsive', () => {
|
mainWindow.webContents.on('unresponsive', () => {
|
||||||
_writeCrashLog('RENDERER UNRESPONSIVE', new Error('webContents unresponsive'));
|
_writeCrashLog('RENDERER UNRESPONSIVE', new Error('webContents unresponsive'));
|
||||||
debugLog('RENDERER UNRESPONSIVE');
|
debugLog('RENDERER UNRESPONSIVE');
|
||||||
@@ -1501,9 +1485,10 @@ function createWindow() {
|
|||||||
try { startupLanguage = resolveStartupLanguage(configStore.load()); } catch {}
|
try { startupLanguage = resolveStartupLanguage(configStore.load()); } catch {}
|
||||||
const rendererTarget = path.join(__dirname, 'renderer', 'index.html');
|
const rendererTarget = path.join(__dirname, 'renderer', 'index.html');
|
||||||
const rendererOptions = { query: { language: startupLanguage } };
|
const rendererOptions = { query: { language: startupLanguage } };
|
||||||
|
const loadStartupDocument = createStartupNavigationLoader(mainWindow, rendererTarget, rendererOptions);
|
||||||
const loadRendererSurface = async () => {
|
const loadRendererSurface = async () => {
|
||||||
try {
|
try {
|
||||||
return await mainWindow.loadFile(rendererTarget, rendererOptions);
|
return await loadStartupDocument();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
_writeCrashLog('LOAD FILE FAILED', error);
|
_writeCrashLog('LOAD FILE FAILED', error);
|
||||||
debugLog(`LOAD FILE FAILED: ${error && error.stack ? error.stack : error}`);
|
debugLog(`LOAD FILE FAILED: ${error && error.stack ? error.stack : error}`);
|
||||||
@@ -1522,7 +1507,16 @@ function createWindow() {
|
|||||||
});
|
});
|
||||||
startupRendererHandlers = createStartupRendererHandlers({
|
startupRendererHandlers = createStartupRendererHandlers({
|
||||||
window: mainWindow,
|
window: mainWindow,
|
||||||
|
ipcMain,
|
||||||
coordinator: startupRecoveryCoordinator,
|
coordinator: startupRecoveryCoordinator,
|
||||||
|
onDocumentLoadStarted: () => {
|
||||||
|
closeHandshakeReady = false;
|
||||||
|
restoreClosePreparation(closePreparationAttempt);
|
||||||
|
},
|
||||||
|
onRendererCrashed: (details) => {
|
||||||
|
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
||||||
|
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
|
||||||
|
},
|
||||||
onReady: () => {
|
onReady: () => {
|
||||||
closeHandshakeReady = true;
|
closeHandshakeReady = true;
|
||||||
},
|
},
|
||||||
@@ -1539,7 +1533,7 @@ function createWindow() {
|
|||||||
if (startupRendererHandlers === currentStartupRendererHandlers) startupRendererHandlers = null;
|
if (startupRendererHandlers === currentStartupRendererHandlers) startupRendererHandlers = null;
|
||||||
if (startupRecoveryCoordinator === currentStartupRecoveryCoordinator) startupRecoveryCoordinator = null;
|
if (startupRecoveryCoordinator === currentStartupRecoveryCoordinator) startupRecoveryCoordinator = null;
|
||||||
});
|
});
|
||||||
void startupRecoveryCoordinator.loadInitial(rendererTarget, rendererOptions);
|
void startupRecoveryCoordinator.loadInitial();
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTray() {
|
function createTray() {
|
||||||
@@ -3012,14 +3006,6 @@ ipcMain.handle('app:quit', () => {
|
|||||||
app.quit();
|
app.quit();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('app:close-handshake-ready', (event) => {
|
|
||||||
if (startupRendererHandlers) startupRendererHandlers.rendererReady(event);
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.on('app:renderer-initialization-failed', (event, details) => {
|
|
||||||
if (startupRendererHandlers) void startupRendererHandlers.rendererInitializationFailed(event, details);
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.on('app:close-preparation-started', (event, attempt) => {
|
ipcMain.on('app:close-preparation-started', (event, attempt) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents && isClosePreparationActive(attempt)) {
|
if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents && isClosePreparationActive(attempt)) {
|
||||||
armCloseFlushTimer(attempt, 3000);
|
armCloseFlushTimer(attempt, 3000);
|
||||||
|
|||||||
+346
-38
@@ -6,6 +6,7 @@ const path = require('node:path');
|
|||||||
const {
|
const {
|
||||||
configureStartupRenderer,
|
configureStartupRenderer,
|
||||||
createStartupFailureDocument,
|
createStartupFailureDocument,
|
||||||
|
createStartupNavigationLoader,
|
||||||
createStartupRecoveryCoordinator,
|
createStartupRecoveryCoordinator,
|
||||||
createStartupRendererHandlers,
|
createStartupRendererHandlers,
|
||||||
createStartupWindow,
|
createStartupWindow,
|
||||||
@@ -24,19 +25,6 @@ test('startup failure document is a localized visible application surface', () =
|
|||||||
assert.doesNotMatch(english, /Electron/);
|
assert.doesNotMatch(english, /Electron/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('main process wires bounded startup recovery into real load and crash paths', () => {
|
|
||||||
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
|
||||||
assert.match(source, /createStartupRecoveryCoordinator/);
|
|
||||||
assert.match(source, /startupRecoveryCoordinator\.loadInitial/);
|
|
||||||
assert.match(source, /startupRecoveryCoordinator\.rendererCrashed/);
|
|
||||||
assert.match(source, /createStartupRendererHandlers/);
|
|
||||||
assert.match(source, /startupRendererHandlers\.documentLoadStarted/);
|
|
||||||
assert.match(source, /startupRendererHandlers\.documentLoaded/);
|
|
||||||
assert.match(source, /startupRendererHandlers\.rendererInitializationFailed/);
|
|
||||||
assert.match(source, /startupRendererHandlers\.rendererReady/);
|
|
||||||
assert.match(source, /createStartupFailureDocument/);
|
|
||||||
});
|
|
||||||
|
|
||||||
class TestBrowserWindow extends EventEmitter {
|
class TestBrowserWindow extends EventEmitter {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super();
|
super();
|
||||||
@@ -92,6 +80,18 @@ function createManualScheduler() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRendererFrame(frameToken, url = `file:///renderer/index.html?startupDocument=${frameToken}`) {
|
||||||
|
return {
|
||||||
|
detached: false,
|
||||||
|
frameToken,
|
||||||
|
processId: 1,
|
||||||
|
url,
|
||||||
|
isDestroyed() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
test('configureStartupRenderer leaves hardware acceleration enabled for a local Windows session', () => {
|
test('configureStartupRenderer leaves hardware acceleration enabled for a local Windows session', () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
configureStartupRenderer({ disableHardwareAcceleration() { calls++; } }, { SESSIONNAME: 'Console' }, 'win32');
|
configureStartupRenderer({ disableHardwareAcceleration() { calls++; } }, { SESSIONNAME: 'Console' }, 'win32');
|
||||||
@@ -129,21 +129,19 @@ test('main window uses the branded application icon', () => {
|
|||||||
assert.match(createWindowSource, /icon:\s*path\.join\(__dirname, ['"]assets['"], ['"]app_icon\.ico['"]\)/u);
|
assert.match(createWindowSource, /icon:\s*path\.join\(__dirname, ['"]assets['"], ['"]app_icon\.ico['"]\)/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('startup load registers visibility before navigation and shows only once', async () => {
|
test('ready-to-show cannot reveal the main window before renderer Ready', async () => {
|
||||||
const startup = createStartupWindow(TestBrowserWindow, {});
|
const startup = createStartupWindow(TestBrowserWindow, {});
|
||||||
startup.window.loadError = null;
|
startup.window.loadError = null;
|
||||||
const loading = startup.load('renderer/index.html', () => {});
|
const loading = startup.load('renderer/index.html', () => {});
|
||||||
|
|
||||||
assert.deepEqual(startup.window.startupEvents, [
|
assert.deepEqual(startup.window.startupEvents, [
|
||||||
'listen:ready-to-show',
|
|
||||||
'load:renderer/index.html'
|
'load:renderer/index.html'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
startup.window.emit('ready-to-show');
|
|
||||||
startup.window.emit('ready-to-show');
|
startup.window.emit('ready-to-show');
|
||||||
await loading;
|
await loading;
|
||||||
|
|
||||||
assert.equal(startup.window.showCalls, 1);
|
assert.equal(startup.window.showCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('startup load forwards a rejected navigation to the error handler', async () => {
|
test('startup load forwards a rejected navigation to the error handler', async () => {
|
||||||
@@ -166,6 +164,34 @@ test('startup load forwards navigation options before the renderer becomes visib
|
|||||||
assert.deepEqual(startup.window.loadOptions, options);
|
assert.deepEqual(startup.window.loadOptions, options);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('startup navigation gives every main document a distinct generation URL', async () => {
|
||||||
|
const calls = [];
|
||||||
|
const window = {
|
||||||
|
loadFile(target, options) {
|
||||||
|
calls.push([target, options]);
|
||||||
|
return Promise.resolve('loaded');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const loadDocument = createStartupNavigationLoader(window, 'renderer/index.html', {
|
||||||
|
hash: 'uploads',
|
||||||
|
query: { language: 'de' }
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadDocument();
|
||||||
|
await loadDocument();
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [
|
||||||
|
['renderer/index.html', {
|
||||||
|
hash: 'uploads',
|
||||||
|
query: { language: 'de', startupDocument: '1' }
|
||||||
|
}],
|
||||||
|
['renderer/index.html', {
|
||||||
|
hash: 'uploads',
|
||||||
|
query: { language: 'de', startupDocument: '2' }
|
||||||
|
}]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test('startup recovery retries the initial load exactly once before succeeding', async () => {
|
test('startup recovery retries the initial load exactly once before succeeding', async () => {
|
||||||
const attempts = [];
|
const attempts = [];
|
||||||
const options = { query: { language: 'de' } };
|
const options = { query: { language: 'de' } };
|
||||||
@@ -189,6 +215,86 @@ test('startup recovery retries the initial load exactly once before succeeding',
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a crash during initial load joins the serialized initial retry without a third navigation', async () => {
|
||||||
|
let rejectFirstLoad;
|
||||||
|
const firstLoad = new Promise((_, reject) => {
|
||||||
|
rejectFirstLoad = reject;
|
||||||
|
});
|
||||||
|
let activeNavigations = 0;
|
||||||
|
let maxConcurrentNavigations = 0;
|
||||||
|
let loadCalls = 0;
|
||||||
|
let reloadCalls = 0;
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
async load() {
|
||||||
|
loadCalls++;
|
||||||
|
activeNavigations++;
|
||||||
|
maxConcurrentNavigations = Math.max(maxConcurrentNavigations, activeNavigations);
|
||||||
|
try {
|
||||||
|
if (loadCalls === 1) return await firstLoad;
|
||||||
|
return 'loaded';
|
||||||
|
} finally {
|
||||||
|
activeNavigations--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async reload() {
|
||||||
|
reloadCalls++;
|
||||||
|
activeNavigations++;
|
||||||
|
maxConcurrentNavigations = Math.max(maxConcurrentNavigations, activeNavigations);
|
||||||
|
activeNavigations--;
|
||||||
|
return 'reloaded';
|
||||||
|
},
|
||||||
|
reveal() {},
|
||||||
|
close() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const initialLoading = coordinator.loadInitial('renderer/index.html');
|
||||||
|
const crashRecovery = coordinator.rendererCrashed({ reason: 'crashed', exitCode: 17 });
|
||||||
|
rejectFirstLoad(new Error('first navigation crashed'));
|
||||||
|
|
||||||
|
await Promise.all([initialLoading, crashRecovery]);
|
||||||
|
|
||||||
|
assert.equal(loadCalls, 2);
|
||||||
|
assert.equal(reloadCalls, 0);
|
||||||
|
assert.equal(maxConcurrentNavigations, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the initial navigation retry consumes the single recovery navigation budget', async () => {
|
||||||
|
const scheduler = createManualScheduler();
|
||||||
|
const failures = [];
|
||||||
|
let loadCalls = 0;
|
||||||
|
let reloadCalls = 0;
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
async load() {
|
||||||
|
loadCalls++;
|
||||||
|
if (loadCalls === 1) throw new Error('first navigation failed');
|
||||||
|
},
|
||||||
|
async reload() {
|
||||||
|
reloadCalls++;
|
||||||
|
},
|
||||||
|
reveal() {},
|
||||||
|
async showFailure(failure) {
|
||||||
|
failures.push(failure);
|
||||||
|
},
|
||||||
|
close() {},
|
||||||
|
readyTimeoutMs: 25,
|
||||||
|
scheduleReadyDeadline: scheduler.schedule,
|
||||||
|
cancelReadyDeadline: scheduler.cancel
|
||||||
|
});
|
||||||
|
|
||||||
|
await coordinator.loadInitial('renderer/index.html');
|
||||||
|
const generation = coordinator.rendererLoadStarted();
|
||||||
|
coordinator.rendererLoaded(generation);
|
||||||
|
await scheduler.fireNext();
|
||||||
|
|
||||||
|
assert.equal(loadCalls, 2);
|
||||||
|
assert.equal(reloadCalls, 0);
|
||||||
|
assert.deepEqual(failures, [{
|
||||||
|
phase: 'renderer-ready-timeout',
|
||||||
|
attempt: 2,
|
||||||
|
details: { timeoutMs: 25 }
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
test('startup recovery reveals a safe failure surface after both initial loads fail', async () => {
|
test('startup recovery reveals a safe failure surface after both initial loads fail', async () => {
|
||||||
const loadErrors = [new Error('first load failed'), new Error('second load failed')];
|
const loadErrors = [new Error('first load failed'), new Error('second load failed')];
|
||||||
const safeFailures = [];
|
const safeFailures = [];
|
||||||
@@ -347,14 +453,28 @@ test('renderer initialization failures share the bounded recovery path', async (
|
|||||||
test('production startup handlers route renderer initialization failure through bounded recovery', async () => {
|
test('production startup handlers route renderer initialization failure through bounded recovery', async () => {
|
||||||
const failures = [];
|
const failures = [];
|
||||||
let reloadCalls = 0;
|
let reloadCalls = 0;
|
||||||
let reportedFailure;
|
let revealCalls = 0;
|
||||||
const webContents = {};
|
const reportedFailures = [];
|
||||||
|
const ipcMain = new EventEmitter();
|
||||||
|
const webContents = new EventEmitter();
|
||||||
|
const firstFrame = createRendererFrame('initialization-1');
|
||||||
|
const secondFrame = createRendererFrame('initialization-2');
|
||||||
const coordinator = createStartupRecoveryCoordinator({
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
load() {},
|
load() {},
|
||||||
async reload() {
|
async reload() {
|
||||||
reloadCalls++;
|
reloadCalls++;
|
||||||
|
webContents.mainFrame = secondFrame;
|
||||||
|
webContents.emit('did-start-navigation', {
|
||||||
|
isMainFrame: true,
|
||||||
|
isSameDocument: false,
|
||||||
|
url: secondFrame.url,
|
||||||
|
frame: secondFrame
|
||||||
|
});
|
||||||
|
webContents.emit('did-finish-load');
|
||||||
|
},
|
||||||
|
reveal() {
|
||||||
|
revealCalls++;
|
||||||
},
|
},
|
||||||
reveal() {},
|
|
||||||
async showFailure(failure) {
|
async showFailure(failure) {
|
||||||
failures.push(failure);
|
failures.push(failure);
|
||||||
},
|
},
|
||||||
@@ -362,28 +482,50 @@ test('production startup handlers route renderer initialization failure through
|
|||||||
});
|
});
|
||||||
const handlers = createStartupRendererHandlers({
|
const handlers = createStartupRendererHandlers({
|
||||||
window: { isDestroyed: () => false, webContents },
|
window: { isDestroyed: () => false, webContents },
|
||||||
|
ipcMain,
|
||||||
coordinator,
|
coordinator,
|
||||||
onReady() {},
|
onReady() {},
|
||||||
onInitializationFailed(details) {
|
onInitializationFailed(details) {
|
||||||
reportedFailure = details;
|
reportedFailures.push(details);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const details = { message: 'top-level initialization failed' };
|
const firstDetails = { message: 'first top-level initialization failed' };
|
||||||
|
const secondDetails = { message: 'second top-level initialization failed' };
|
||||||
|
|
||||||
await handlers.rendererInitializationFailed({ sender: webContents }, details);
|
webContents.mainFrame = firstFrame;
|
||||||
await handlers.rendererInitializationFailed({ sender: webContents }, details);
|
webContents.emit('did-start-navigation', {
|
||||||
|
isMainFrame: true,
|
||||||
|
isSameDocument: false,
|
||||||
|
url: firstFrame.url,
|
||||||
|
frame: firstFrame
|
||||||
|
});
|
||||||
|
ipcMain.emit('app:renderer-initialization-failed', {
|
||||||
|
sender: webContents,
|
||||||
|
senderFrame: firstFrame
|
||||||
|
}, firstDetails);
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
ipcMain.emit('app:renderer-initialization-failed', {
|
||||||
|
sender: webContents,
|
||||||
|
senderFrame: secondFrame
|
||||||
|
}, secondDetails);
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
|
||||||
assert.equal(reloadCalls, 1);
|
assert.equal(reloadCalls, 1);
|
||||||
assert.equal(reportedFailure, details);
|
assert.equal(revealCalls, 1);
|
||||||
|
assert.deepEqual(reportedFailures, [firstDetails, secondDetails]);
|
||||||
assert.deepEqual(failures, [{
|
assert.deepEqual(failures, [{
|
||||||
phase: 'renderer-initialization',
|
phase: 'renderer-initialization',
|
||||||
attempt: 2,
|
attempt: 2,
|
||||||
details
|
details: secondDetails
|
||||||
}]);
|
}]);
|
||||||
|
handlers.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('production startup handlers enforce the Ready deadline after every main document load', async () => {
|
test('production startup handlers enforce the Ready deadline after every main document load', async () => {
|
||||||
const scheduler = createManualScheduler();
|
const scheduler = createManualScheduler();
|
||||||
|
const webContents = new EventEmitter();
|
||||||
|
const firstFrame = createRendererFrame('timeout-1');
|
||||||
|
const secondFrame = createRendererFrame('timeout-2');
|
||||||
const failures = [];
|
const failures = [];
|
||||||
let reloadCalls = 0;
|
let reloadCalls = 0;
|
||||||
const coordinator = createStartupRecoveryCoordinator({
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
@@ -401,21 +543,74 @@ test('production startup handlers enforce the Ready deadline after every main do
|
|||||||
cancelReadyDeadline: scheduler.cancel
|
cancelReadyDeadline: scheduler.cancel
|
||||||
});
|
});
|
||||||
const handlers = createStartupRendererHandlers({
|
const handlers = createStartupRendererHandlers({
|
||||||
window: { isDestroyed: () => false, webContents: {} },
|
window: { isDestroyed: () => false, webContents },
|
||||||
coordinator,
|
coordinator,
|
||||||
onReady() {},
|
onReady() {},
|
||||||
onInitializationFailed() {}
|
onInitializationFailed() {}
|
||||||
});
|
});
|
||||||
|
|
||||||
handlers.documentLoadStarted();
|
webContents.mainFrame = firstFrame;
|
||||||
handlers.documentLoaded();
|
webContents.emit('did-start-navigation', {
|
||||||
|
isMainFrame: true,
|
||||||
|
isSameDocument: false,
|
||||||
|
url: firstFrame.url,
|
||||||
|
frame: firstFrame
|
||||||
|
});
|
||||||
|
webContents.emit('did-finish-load');
|
||||||
|
|
||||||
assert.deepEqual(scheduler.delays(), [25]);
|
assert.deepEqual(scheduler.delays(), [25]);
|
||||||
await scheduler.fireNext();
|
await scheduler.fireNext();
|
||||||
assert.equal(reloadCalls, 1);
|
assert.equal(reloadCalls, 1);
|
||||||
|
|
||||||
handlers.documentLoadStarted();
|
webContents.mainFrame = secondFrame;
|
||||||
handlers.documentLoaded();
|
webContents.emit('did-start-navigation', {
|
||||||
|
isMainFrame: true,
|
||||||
|
isSameDocument: false,
|
||||||
|
url: secondFrame.url,
|
||||||
|
frame: secondFrame
|
||||||
|
});
|
||||||
|
webContents.emit('did-finish-load');
|
||||||
|
await scheduler.fireNext();
|
||||||
|
|
||||||
|
assert.equal(reloadCalls, 1);
|
||||||
|
assert.deepEqual(failures, [{
|
||||||
|
phase: 'renderer-ready-timeout',
|
||||||
|
attempt: 2,
|
||||||
|
details: { timeoutMs: 25 }
|
||||||
|
}]);
|
||||||
|
handlers.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('late Ready from an old renderer generation cannot clear the current deadline or recovery budget', async () => {
|
||||||
|
const scheduler = createManualScheduler();
|
||||||
|
const failures = [];
|
||||||
|
let reloadCalls = 0;
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
load() {},
|
||||||
|
async reload() {
|
||||||
|
reloadCalls++;
|
||||||
|
},
|
||||||
|
reveal() {},
|
||||||
|
async showFailure(failure) {
|
||||||
|
failures.push(failure);
|
||||||
|
},
|
||||||
|
close() {},
|
||||||
|
readyTimeoutMs: 25,
|
||||||
|
scheduleReadyDeadline: scheduler.schedule,
|
||||||
|
cancelReadyDeadline: scheduler.cancel
|
||||||
|
});
|
||||||
|
|
||||||
|
const oldGeneration = coordinator.rendererLoadStarted();
|
||||||
|
coordinator.rendererLoaded(oldGeneration);
|
||||||
|
await scheduler.fireNext();
|
||||||
|
|
||||||
|
const currentGeneration = coordinator.rendererLoadStarted();
|
||||||
|
coordinator.rendererLoaded(currentGeneration);
|
||||||
|
const accepted = coordinator.rendererReady(oldGeneration);
|
||||||
|
|
||||||
|
assert.equal(accepted, false);
|
||||||
|
assert.equal(scheduler.count(), 1);
|
||||||
|
|
||||||
await scheduler.fireNext();
|
await scheduler.fireNext();
|
||||||
|
|
||||||
assert.equal(reloadCalls, 1);
|
assert.equal(reloadCalls, 1);
|
||||||
@@ -426,11 +621,120 @@ test('production startup handlers enforce the Ready deadline after every main do
|
|||||||
}]);
|
}]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Ready without a renderer generation cannot clear the active deadline', () => {
|
||||||
|
const scheduler = createManualScheduler();
|
||||||
|
let revealCalls = 0;
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
load() {},
|
||||||
|
reload() {},
|
||||||
|
reveal() {
|
||||||
|
revealCalls++;
|
||||||
|
},
|
||||||
|
close() {},
|
||||||
|
readyTimeoutMs: 25,
|
||||||
|
scheduleReadyDeadline: scheduler.schedule,
|
||||||
|
cancelReadyDeadline: scheduler.cancel
|
||||||
|
});
|
||||||
|
|
||||||
|
const generation = coordinator.rendererLoadStarted();
|
||||||
|
coordinator.rendererLoaded(generation);
|
||||||
|
const accepted = coordinator.rendererReady();
|
||||||
|
|
||||||
|
assert.equal(accepted, false);
|
||||||
|
assert.equal(revealCalls, 0);
|
||||||
|
assert.equal(scheduler.count(), 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('production event wiring rejects an old document Ready and exposes the failsafe', async () => {
|
||||||
|
const scheduler = createManualScheduler();
|
||||||
|
const ipcMain = new EventEmitter();
|
||||||
|
const webContents = new EventEmitter();
|
||||||
|
const oldFrame = createRendererFrame('main-frame', 'file:///renderer/index.html?startupDocument=1');
|
||||||
|
const currentFrame = createRendererFrame('main-frame', 'file:///renderer/index.html?startupDocument=2');
|
||||||
|
const window = {
|
||||||
|
webContents,
|
||||||
|
isDestroyed() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const failures = [];
|
||||||
|
let reloadCalls = 0;
|
||||||
|
let revealCalls = 0;
|
||||||
|
let readyCalls = 0;
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
load() {},
|
||||||
|
async reload() {
|
||||||
|
reloadCalls++;
|
||||||
|
webContents.mainFrame = currentFrame;
|
||||||
|
webContents.emit('did-start-navigation', {
|
||||||
|
isMainFrame: true,
|
||||||
|
isSameDocument: false,
|
||||||
|
url: currentFrame.url,
|
||||||
|
frame: currentFrame
|
||||||
|
});
|
||||||
|
webContents.emit('did-finish-load');
|
||||||
|
},
|
||||||
|
reveal() {
|
||||||
|
revealCalls++;
|
||||||
|
},
|
||||||
|
async showFailure(failure) {
|
||||||
|
failures.push(failure);
|
||||||
|
},
|
||||||
|
close() {},
|
||||||
|
readyTimeoutMs: 25,
|
||||||
|
scheduleReadyDeadline: scheduler.schedule,
|
||||||
|
cancelReadyDeadline: scheduler.cancel
|
||||||
|
});
|
||||||
|
const handlers = createStartupRendererHandlers({
|
||||||
|
window,
|
||||||
|
ipcMain,
|
||||||
|
coordinator,
|
||||||
|
onReady() {
|
||||||
|
readyCalls++;
|
||||||
|
},
|
||||||
|
onInitializationFailed() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
webContents.mainFrame = oldFrame;
|
||||||
|
webContents.emit('did-start-navigation', {
|
||||||
|
isMainFrame: true,
|
||||||
|
isSameDocument: false,
|
||||||
|
url: oldFrame.url,
|
||||||
|
frame: oldFrame
|
||||||
|
});
|
||||||
|
webContents.emit('did-finish-load');
|
||||||
|
|
||||||
|
assert.equal(scheduler.count(), 1);
|
||||||
|
await scheduler.fireNext();
|
||||||
|
assert.equal(reloadCalls, 1);
|
||||||
|
assert.equal(scheduler.count(), 1);
|
||||||
|
|
||||||
|
ipcMain.emit('app:close-handshake-ready', {
|
||||||
|
sender: webContents,
|
||||||
|
senderFrame: oldFrame
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(readyCalls, 0);
|
||||||
|
assert.equal(scheduler.count(), 1);
|
||||||
|
|
||||||
|
await scheduler.fireNext();
|
||||||
|
|
||||||
|
assert.equal(reloadCalls, 1);
|
||||||
|
assert.equal(revealCalls, 1);
|
||||||
|
assert.deepEqual(failures, [{
|
||||||
|
phase: 'renderer-ready-timeout',
|
||||||
|
attempt: 2,
|
||||||
|
details: { timeoutMs: 25 }
|
||||||
|
}]);
|
||||||
|
handlers.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
test('production startup handlers cancel the Ready deadline after a valid Ready signal', async () => {
|
test('production startup handlers cancel the Ready deadline after a valid Ready signal', async () => {
|
||||||
const scheduler = createManualScheduler();
|
const scheduler = createManualScheduler();
|
||||||
let reloadCalls = 0;
|
let reloadCalls = 0;
|
||||||
let readyCalls = 0;
|
let readyCalls = 0;
|
||||||
const webContents = {};
|
const frame = createRendererFrame('ready');
|
||||||
|
const webContents = { mainFrame: frame };
|
||||||
const coordinator = createStartupRecoveryCoordinator({
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
load() {},
|
load() {},
|
||||||
async reload() {
|
async reload() {
|
||||||
@@ -453,7 +757,7 @@ test('production startup handlers cancel the Ready deadline after a valid Ready
|
|||||||
|
|
||||||
handlers.documentLoadStarted();
|
handlers.documentLoadStarted();
|
||||||
handlers.documentLoaded();
|
handlers.documentLoaded();
|
||||||
const ready = handlers.rendererReady({ sender: webContents });
|
const ready = handlers.rendererReady({ sender: webContents, senderFrame: frame });
|
||||||
|
|
||||||
assert.equal(ready, true);
|
assert.equal(ready, true);
|
||||||
assert.equal(readyCalls, 1);
|
assert.equal(readyCalls, 1);
|
||||||
@@ -461,9 +765,10 @@ test('production startup handlers cancel the Ready deadline after a valid Ready
|
|||||||
assert.equal(reloadCalls, 0);
|
assert.equal(reloadCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Ready before did-finish-load prevents a stale deadline', () => {
|
test('Ready before did-finish-load is rejected and cannot suppress the current deadline', () => {
|
||||||
const scheduler = createManualScheduler();
|
const scheduler = createManualScheduler();
|
||||||
const webContents = {};
|
const frame = createRendererFrame('not-finished');
|
||||||
|
const webContents = { mainFrame: frame };
|
||||||
const coordinator = createStartupRecoveryCoordinator({
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
load() {},
|
load() {},
|
||||||
reload() {},
|
reload() {},
|
||||||
@@ -481,10 +786,11 @@ test('Ready before did-finish-load prevents a stale deadline', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
handlers.documentLoadStarted();
|
handlers.documentLoadStarted();
|
||||||
handlers.rendererReady({ sender: webContents });
|
const ready = handlers.rendererReady({ sender: webContents, senderFrame: frame });
|
||||||
handlers.documentLoaded();
|
handlers.documentLoaded();
|
||||||
|
|
||||||
assert.equal(scheduler.count(), 0);
|
assert.equal(ready, false);
|
||||||
|
assert.equal(scheduler.count(), 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('disposing startup handlers cancels a pending Ready deadline', () => {
|
test('disposing startup handlers cancels a pending Ready deadline', () => {
|
||||||
@@ -535,7 +841,9 @@ test('a successful renderer ready event reveals content and resets crash recover
|
|||||||
});
|
});
|
||||||
|
|
||||||
await coordinator.rendererCrashed(crashes[0]);
|
await coordinator.rendererCrashed(crashes[0]);
|
||||||
coordinator.rendererReady();
|
const generation = coordinator.rendererLoadStarted();
|
||||||
|
coordinator.rendererLoaded(generation);
|
||||||
|
coordinator.rendererReady(generation);
|
||||||
await coordinator.rendererCrashed(crashes[1]);
|
await coordinator.rendererCrashed(crashes[1]);
|
||||||
|
|
||||||
assert.equal(reloadCalls, 2);
|
assert.equal(reloadCalls, 2);
|
||||||
|
|||||||
Reference in New Issue
Block a user