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>`;
|
||||
}
|
||||
|
||||
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({
|
||||
load,
|
||||
reload,
|
||||
@@ -29,12 +43,15 @@ function createStartupRecoveryCoordinator({
|
||||
cancelReadyDeadline = clearTimeout
|
||||
}) {
|
||||
let initialLoad;
|
||||
let crashReloads = 0;
|
||||
let initialLoadPending = false;
|
||||
let navigation;
|
||||
let recoveryNavigations = 0;
|
||||
let terminalFailure;
|
||||
let recovery;
|
||||
let queuedRecovery;
|
||||
let readyDeadline;
|
||||
let rendererDocument = 0;
|
||||
let awaitingReady = false;
|
||||
let rendererGeneration = 0;
|
||||
let awaitingGeneration = null;
|
||||
let stopped = false;
|
||||
|
||||
function clearRendererDeadline() {
|
||||
@@ -42,16 +59,33 @@ function createStartupRecoveryCoordinator({
|
||||
readyDeadline = undefined;
|
||||
}
|
||||
|
||||
function abandonRendererDocument() {
|
||||
awaitingReady = false;
|
||||
function clearRendererDocument() {
|
||||
awaitingGeneration = null;
|
||||
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) {
|
||||
if (stopped) return Promise.resolve(false);
|
||||
if (!terminalFailure) {
|
||||
abandonRendererDocument();
|
||||
terminalFailure = (async () => {
|
||||
clearRendererDocument();
|
||||
terminalFailure = Promise.resolve().then(async () => {
|
||||
if (typeof showFailure !== 'function') {
|
||||
await close(failure);
|
||||
return;
|
||||
@@ -62,29 +96,12 @@ function createStartupRecoveryCoordinator({
|
||||
} catch (surfaceError) {
|
||||
await close({ ...failure, surfaceError });
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
return terminalFailure;
|
||||
}
|
||||
|
||||
function recoverRenderer(phase, details) {
|
||||
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 });
|
||||
}
|
||||
})();
|
||||
function trackRecovery(currentRecovery) {
|
||||
recovery = currentRecovery;
|
||||
currentRecovery.then(
|
||||
() => {
|
||||
@@ -97,16 +114,55 @@ function createStartupRecoveryCoordinator({
|
||||
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 {
|
||||
loadInitial(...args) {
|
||||
if (stopped) return Promise.resolve(false);
|
||||
if (terminalFailure) return terminalFailure;
|
||||
if (!initialLoad) {
|
||||
initialLoad = (async () => {
|
||||
initialLoadPending = true;
|
||||
const currentInitialLoad = (async () => {
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
if (stopped) return false;
|
||||
if (attempt === 2) recoveryNavigations = Math.max(recoveryNavigations, 1);
|
||||
try {
|
||||
return await load(...args);
|
||||
return await runNavigation(load, args);
|
||||
} catch (error) {
|
||||
if (stopped) return false;
|
||||
if (attempt === 2) {
|
||||
@@ -115,6 +171,15 @@ function createStartupRecoveryCoordinator({
|
||||
}
|
||||
}
|
||||
})();
|
||||
initialLoad = currentInitialLoad;
|
||||
currentInitialLoad.then(
|
||||
() => {
|
||||
initialLoadPending = false;
|
||||
},
|
||||
() => {
|
||||
initialLoadPending = false;
|
||||
}
|
||||
);
|
||||
}
|
||||
return initialLoad;
|
||||
},
|
||||
@@ -126,73 +191,165 @@ function createStartupRecoveryCoordinator({
|
||||
},
|
||||
rendererLoadStarted() {
|
||||
if (stopped || terminalFailure) return false;
|
||||
clearRendererDeadline();
|
||||
rendererDocument++;
|
||||
awaitingReady = true;
|
||||
return true;
|
||||
clearRendererDocument();
|
||||
rendererGeneration++;
|
||||
awaitingGeneration = rendererGeneration;
|
||||
return rendererGeneration;
|
||||
},
|
||||
rendererLoaded() {
|
||||
if (stopped || terminalFailure || !awaitingReady) return false;
|
||||
rendererLoaded(generation) {
|
||||
if (stopped || terminalFailure || awaitingGeneration !== generation) return false;
|
||||
clearRendererDeadline();
|
||||
const document = rendererDocument;
|
||||
readyDeadline = scheduleReadyDeadline(() => {
|
||||
if (stopped || terminalFailure || !awaitingReady || rendererDocument !== document) return false;
|
||||
if (stopped || terminalFailure || awaitingGeneration !== generation) return false;
|
||||
readyDeadline = undefined;
|
||||
return recoverRenderer('renderer-ready-timeout', { timeoutMs: readyTimeoutMs });
|
||||
}, readyTimeoutMs);
|
||||
if (readyDeadline && typeof readyDeadline.unref === 'function') readyDeadline.unref();
|
||||
return true;
|
||||
},
|
||||
rendererReady() {
|
||||
if (stopped || terminalFailure) return false;
|
||||
abandonRendererDocument();
|
||||
crashReloads = 0;
|
||||
rendererReady(generation) {
|
||||
if (stopped || terminalFailure || !acceptRendererDocument(generation)) return false;
|
||||
recoveryNavigations = 0;
|
||||
reveal();
|
||||
return true;
|
||||
},
|
||||
dispose() {
|
||||
if (stopped) return false;
|
||||
stopped = true;
|
||||
abandonRendererDocument();
|
||||
queuedRecovery = undefined;
|
||||
clearRendererDocument();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createStartupRendererHandlers({ window, coordinator, onReady, onInitializationFailed }) {
|
||||
function createStartupRendererHandlers({
|
||||
window,
|
||||
ipcMain,
|
||||
coordinator,
|
||||
onDocumentLoadStarted,
|
||||
onRendererCrashed,
|
||||
onReady,
|
||||
onInitializationFailed
|
||||
}) {
|
||||
let disposed = false;
|
||||
let generation = null;
|
||||
let activeFrame = null;
|
||||
let pendingDocumentUrl = null;
|
||||
let pendingFrameAddress = null;
|
||||
const webContents = window && window.webContents;
|
||||
|
||||
function accepts(event) {
|
||||
return !disposed && window && !window.isDestroyed() && event && event.sender === window.webContents;
|
||||
function frameAddress(frame) {
|
||||
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 {
|
||||
documentLoadStarted() {
|
||||
if (disposed) return false;
|
||||
return coordinator.rendererLoadStarted();
|
||||
},
|
||||
documentLoaded() {
|
||||
if (disposed) return false;
|
||||
return coordinator.rendererLoaded();
|
||||
},
|
||||
documentLoadStarted: startDocument,
|
||||
documentLoaded: finishDocument,
|
||||
rendererCrashed(details) {
|
||||
if (disposed) return false;
|
||||
return coordinator.rendererCrashed(details);
|
||||
},
|
||||
rendererInitializationFailed(event, details) {
|
||||
if (!accepts(event)) return false;
|
||||
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;
|
||||
},
|
||||
rendererInitializationFailed: handleRendererInitializationFailed,
|
||||
rendererReady: handleRendererReady,
|
||||
dispose() {
|
||||
if (disposed) return false;
|
||||
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();
|
||||
}
|
||||
};
|
||||
@@ -200,9 +357,6 @@ function createStartupRendererHandlers({ window, coordinator, onReady, onInitial
|
||||
|
||||
function createStartupWindow(BrowserWindow, options) {
|
||||
const window = new BrowserWindow({ ...options, show: false });
|
||||
window.once('ready-to-show', () => {
|
||||
window.show();
|
||||
});
|
||||
|
||||
return {
|
||||
window,
|
||||
@@ -215,6 +369,7 @@ function createStartupWindow(BrowserWindow, options) {
|
||||
module.exports = {
|
||||
configureStartupRenderer,
|
||||
createStartupFailureDocument,
|
||||
createStartupNavigationLoader,
|
||||
createStartupRecoveryCoordinator,
|
||||
createStartupRendererHandlers,
|
||||
createStartupWindow,
|
||||
|
||||
Reference in New Issue
Block a user