diff --git a/lib/startup-renderer.js b/lib/startup-renderer.js index f57ff68..5d5c9db 100644 --- a/lib/startup-renderer.js +++ b/lib/startup-renderer.js @@ -18,6 +18,20 @@ function createStartupFailureDocument(language) { return `Multi Hoster Uploader

${title}

${detail}

`; } +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, diff --git a/main.js b/main.js index 8baccde..62be20b 100644 --- a/main.js +++ b/main.js @@ -8,6 +8,7 @@ app.setAppUserModelId('com.multihoster.uploader'); const { configureStartupRenderer, createStartupFailureDocument, + createStartupNavigationLoader, createStartupRecoveryCoordinator, createStartupRendererHandlers, createStartupWindow, @@ -1461,23 +1462,6 @@ function createWindow() { 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', () => { _writeCrashLog('RENDERER UNRESPONSIVE', new Error('webContents unresponsive')); debugLog('RENDERER UNRESPONSIVE'); @@ -1501,9 +1485,10 @@ function createWindow() { try { startupLanguage = resolveStartupLanguage(configStore.load()); } catch {} const rendererTarget = path.join(__dirname, 'renderer', 'index.html'); const rendererOptions = { query: { language: startupLanguage } }; + const loadStartupDocument = createStartupNavigationLoader(mainWindow, rendererTarget, rendererOptions); const loadRendererSurface = async () => { try { - return await mainWindow.loadFile(rendererTarget, rendererOptions); + return await loadStartupDocument(); } catch (error) { _writeCrashLog('LOAD FILE FAILED', error); debugLog(`LOAD FILE FAILED: ${error && error.stack ? error.stack : error}`); @@ -1522,7 +1507,16 @@ function createWindow() { }); startupRendererHandlers = createStartupRendererHandlers({ window: mainWindow, + ipcMain, 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: () => { closeHandshakeReady = true; }, @@ -1539,7 +1533,7 @@ function createWindow() { if (startupRendererHandlers === currentStartupRendererHandlers) startupRendererHandlers = null; if (startupRecoveryCoordinator === currentStartupRecoveryCoordinator) startupRecoveryCoordinator = null; }); - void startupRecoveryCoordinator.loadInitial(rendererTarget, rendererOptions); + void startupRecoveryCoordinator.loadInitial(); } function createTray() { @@ -3012,14 +3006,6 @@ ipcMain.handle('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) => { if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents && isClosePreparationActive(attempt)) { armCloseFlushTimer(attempt, 3000); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 4b406c2..d6428f5 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -6,6 +6,7 @@ const path = require('node:path'); const { configureStartupRenderer, createStartupFailureDocument, + createStartupNavigationLoader, createStartupRecoveryCoordinator, createStartupRendererHandlers, createStartupWindow, @@ -24,19 +25,6 @@ test('startup failure document is a localized visible application surface', () = 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 { constructor(options) { 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', () => { let calls = 0; 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); }); -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, {}); startup.window.loadError = null; const loading = startup.load('renderer/index.html', () => {}); assert.deepEqual(startup.window.startupEvents, [ - 'listen:ready-to-show', 'load:renderer/index.html' ]); - startup.window.emit('ready-to-show'); startup.window.emit('ready-to-show'); 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 () => { @@ -166,6 +164,34 @@ test('startup load forwards navigation options before the renderer becomes visib 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 () => { const attempts = []; 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 () => { const loadErrors = [new Error('first load failed'), new Error('second load failed')]; 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 () => { const failures = []; let reloadCalls = 0; - let reportedFailure; - const webContents = {}; + let revealCalls = 0; + const reportedFailures = []; + const ipcMain = new EventEmitter(); + const webContents = new EventEmitter(); + const firstFrame = createRendererFrame('initialization-1'); + const secondFrame = createRendererFrame('initialization-2'); const coordinator = createStartupRecoveryCoordinator({ load() {}, async reload() { 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) { failures.push(failure); }, @@ -362,28 +482,50 @@ test('production startup handlers route renderer initialization failure through }); const handlers = createStartupRendererHandlers({ window: { isDestroyed: () => false, webContents }, + ipcMain, coordinator, onReady() {}, 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); - await handlers.rendererInitializationFailed({ sender: webContents }, details); + webContents.mainFrame = firstFrame; + 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(reportedFailure, details); + assert.equal(revealCalls, 1); + assert.deepEqual(reportedFailures, [firstDetails, secondDetails]); assert.deepEqual(failures, [{ phase: 'renderer-initialization', attempt: 2, - details + details: secondDetails }]); + handlers.dispose(); }); test('production startup handlers enforce the Ready deadline after every main document load', async () => { const scheduler = createManualScheduler(); + const webContents = new EventEmitter(); + const firstFrame = createRendererFrame('timeout-1'); + const secondFrame = createRendererFrame('timeout-2'); const failures = []; let reloadCalls = 0; const coordinator = createStartupRecoveryCoordinator({ @@ -401,21 +543,74 @@ test('production startup handlers enforce the Ready deadline after every main do cancelReadyDeadline: scheduler.cancel }); const handlers = createStartupRendererHandlers({ - window: { isDestroyed: () => false, webContents: {} }, + window: { isDestroyed: () => false, webContents }, coordinator, onReady() {}, onInitializationFailed() {} }); - handlers.documentLoadStarted(); - handlers.documentLoaded(); + webContents.mainFrame = firstFrame; + webContents.emit('did-start-navigation', { + isMainFrame: true, + isSameDocument: false, + url: firstFrame.url, + frame: firstFrame + }); + webContents.emit('did-finish-load'); assert.deepEqual(scheduler.delays(), [25]); await scheduler.fireNext(); assert.equal(reloadCalls, 1); - handlers.documentLoadStarted(); - handlers.documentLoaded(); + webContents.mainFrame = secondFrame; + 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(); 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 () => { const scheduler = createManualScheduler(); let reloadCalls = 0; let readyCalls = 0; - const webContents = {}; + const frame = createRendererFrame('ready'); + const webContents = { mainFrame: frame }; const coordinator = createStartupRecoveryCoordinator({ load() {}, async reload() { @@ -453,7 +757,7 @@ test('production startup handlers cancel the Ready deadline after a valid Ready handlers.documentLoadStarted(); handlers.documentLoaded(); - const ready = handlers.rendererReady({ sender: webContents }); + const ready = handlers.rendererReady({ sender: webContents, senderFrame: frame }); assert.equal(ready, true); assert.equal(readyCalls, 1); @@ -461,9 +765,10 @@ test('production startup handlers cancel the Ready deadline after a valid Ready 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 webContents = {}; + const frame = createRendererFrame('not-finished'); + const webContents = { mainFrame: frame }; const coordinator = createStartupRecoveryCoordinator({ load() {}, reload() {}, @@ -481,10 +786,11 @@ test('Ready before did-finish-load prevents a stale deadline', () => { }); handlers.documentLoadStarted(); - handlers.rendererReady({ sender: webContents }); + const ready = handlers.rendererReady({ sender: webContents, senderFrame: frame }); 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', () => { @@ -535,7 +841,9 @@ test('a successful renderer ready event reveals content and resets crash recover }); await coordinator.rendererCrashed(crashes[0]); - coordinator.rendererReady(); + const generation = coordinator.rendererLoadStarted(); + coordinator.rendererLoaded(generation); + coordinator.rendererReady(generation); await coordinator.rendererCrashed(crashes[1]); assert.equal(reloadCalls, 2);