From a4d0854e762fcd53110625f8acc425e5324d1316 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:25:28 +0200 Subject: [PATCH] Harden renderer startup readiness recovery Require a bounded Ready signal after each main-document load, cancel the deadline on successful readiness or window disposal, and keep recovery limited to one reload before the branded failure surface. Route renderer initialization failures through production startup handlers and reject malformed authenticated remote keyboard payloads without throwing or logging. --- lib/startup-renderer.js | 136 +++++++++++++++++++-- main.js | 43 +++++-- tests/main-remote-input.test.js | 76 ++++++++++++ tests/startup-renderer.test.js | 205 +++++++++++++++++++++++++++++++- 4 files changed, 436 insertions(+), 24 deletions(-) create mode 100644 tests/main-remote-input.test.js diff --git a/lib/startup-renderer.js b/lib/startup-renderer.js index 1a921ca..f57ff68 100644 --- a/lib/startup-renderer.js +++ b/lib/startup-renderer.js @@ -18,13 +18,39 @@ function createStartupFailureDocument(language) { return `Multi Hoster Uploader

${title}

${detail}

`; } -function createStartupRecoveryCoordinator({ load, reload, reveal, showFailure, close }) { +function createStartupRecoveryCoordinator({ + load, + reload, + reveal, + showFailure, + close, + readyTimeoutMs = 15000, + scheduleReadyDeadline = setTimeout, + cancelReadyDeadline = clearTimeout +}) { let initialLoad; let crashReloads = 0; let terminalFailure; + let recovery; + let readyDeadline; + let rendererDocument = 0; + let awaitingReady = false; + let stopped = false; + + function clearRendererDeadline() { + if (readyDeadline !== undefined) cancelReadyDeadline(readyDeadline); + readyDeadline = undefined; + } + + function abandonRendererDocument() { + awaitingReady = false; + clearRendererDeadline(); + } function endWithFailure(failure) { + if (stopped) return Promise.resolve(false); if (!terminalFailure) { + abandonRendererDocument(); terminalFailure = (async () => { if (typeof showFailure !== 'function') { await close(failure); @@ -41,28 +67,48 @@ function createStartupRecoveryCoordinator({ load, reload, reveal, showFailure, c return terminalFailure; } - async function recoverRenderer(phase, details) { + function recoverRenderer(phase, details) { + if (stopped) return Promise.resolve(false); if (terminalFailure) return terminalFailure; - if (crashReloads >= 1) { - return endWithFailure({ phase, attempt: crashReloads + 1, details }); - } - crashReloads++; - try { - await reload(); - } catch (error) { - return endWithFailure({ phase: 'renderer-reload', attempt: crashReloads, details, error }); - } + 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; + currentRecovery.then( + () => { + if (recovery === currentRecovery) recovery = undefined; + }, + () => { + if (recovery === currentRecovery) recovery = undefined; + } + ); + return currentRecovery; } return { loadInitial(...args) { + if (stopped) return Promise.resolve(false); if (terminalFailure) return terminalFailure; if (!initialLoad) { initialLoad = (async () => { for (let attempt = 1; attempt <= 2; attempt++) { + if (stopped) return false; try { return await load(...args); } catch (error) { + if (stopped) return false; if (attempt === 2) { await endWithFailure({ phase: 'initial-load', attempt, error }); } @@ -78,11 +124,76 @@ function createStartupRecoveryCoordinator({ load, reload, reveal, showFailure, c rendererInitializationFailed(details) { return recoverRenderer('renderer-initialization', details); }, + rendererLoadStarted() { + if (stopped || terminalFailure) return false; + clearRendererDeadline(); + rendererDocument++; + awaitingReady = true; + return true; + }, + rendererLoaded() { + if (stopped || terminalFailure || !awaitingReady) return false; + clearRendererDeadline(); + const document = rendererDocument; + readyDeadline = scheduleReadyDeadline(() => { + if (stopped || terminalFailure || !awaitingReady || rendererDocument !== document) return false; + readyDeadline = undefined; + return recoverRenderer('renderer-ready-timeout', { timeoutMs: readyTimeoutMs }); + }, readyTimeoutMs); + if (readyDeadline && typeof readyDeadline.unref === 'function') readyDeadline.unref(); + return true; + }, rendererReady() { - if (terminalFailure) return false; + if (stopped || terminalFailure) return false; + abandonRendererDocument(); crashReloads = 0; reveal(); return true; + }, + dispose() { + if (stopped) return false; + stopped = true; + abandonRendererDocument(); + return true; + } + }; +} + +function createStartupRendererHandlers({ window, coordinator, onReady, onInitializationFailed }) { + let disposed = false; + + function accepts(event) { + return !disposed && window && !window.isDestroyed() && event && event.sender === window.webContents; + } + + return { + documentLoadStarted() { + if (disposed) return false; + return coordinator.rendererLoadStarted(); + }, + documentLoaded() { + if (disposed) return false; + return coordinator.rendererLoaded(); + }, + 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; + }, + dispose() { + if (disposed) return false; + disposed = true; + return coordinator.dispose(); } }; } @@ -105,6 +216,7 @@ module.exports = { configureStartupRenderer, createStartupFailureDocument, createStartupRecoveryCoordinator, + createStartupRendererHandlers, createStartupWindow, resolveStartupLanguage }; diff --git a/main.js b/main.js index f1b8191..8baccde 100644 --- a/main.js +++ b/main.js @@ -9,6 +9,7 @@ const { configureStartupRenderer, createStartupFailureDocument, createStartupRecoveryCoordinator, + createStartupRendererHandlers, createStartupWindow, resolveStartupLanguage } = require('./lib/startup-renderer'); @@ -135,7 +136,9 @@ const uploadBatchMutationGates = new WeakMap(); const uploadRecoveryStates = new WeakMap(); let lastSessionSummary = null; let startupRecoveryCoordinator = null; +let startupRendererHandlers = null; let sourceDeleteJournal = null; +const RENDERER_READY_TIMEOUT_MS = 15000; const pendingUploadFinalizations = new Map(); function requestUploadFinalization(summary, historyPersisted) { @@ -1462,6 +1465,11 @@ function createWindow() { 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) => { @@ -1509,7 +1517,27 @@ function createWindow() { if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) mainWindow.show(); }, showFailure: () => mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(createStartupFailureDocument(startupLanguage))}`), - close: () => app.exit(1) + close: () => app.exit(1), + readyTimeoutMs: RENDERER_READY_TIMEOUT_MS + }); + startupRendererHandlers = createStartupRendererHandlers({ + window: mainWindow, + coordinator: startupRecoveryCoordinator, + onReady: () => { + closeHandshakeReady = true; + }, + onInitializationFailed: (details) => { + const message = details && typeof details.message === 'string' ? details.message : 'Renderer initialization failed'; + _writeCrashLog('RENDERER INITIALIZATION FAILED', new Error(message), details); + debugLog(`RENDERER INITIALIZATION FAILED: ${message}`); + } + }); + const currentStartupRendererHandlers = startupRendererHandlers; + const currentStartupRecoveryCoordinator = startupRecoveryCoordinator; + mainWindow.once('closed', () => { + currentStartupRendererHandlers.dispose(); + if (startupRendererHandlers === currentStartupRendererHandlers) startupRendererHandlers = null; + if (startupRecoveryCoordinator === currentStartupRecoveryCoordinator) startupRecoveryCoordinator = null; }); void startupRecoveryCoordinator.loadInitial(rendererTarget, rendererOptions); } @@ -2985,18 +3013,11 @@ ipcMain.handle('app:quit', () => { }); ipcMain.on('app:close-handshake-ready', (event) => { - if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) { - closeHandshakeReady = true; - if (startupRecoveryCoordinator) startupRecoveryCoordinator.rendererReady(); - } + if (startupRendererHandlers) startupRendererHandlers.rendererReady(event); }); ipcMain.on('app:renderer-initialization-failed', (event, details) => { - if (!mainWindow || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents) return; - const message = details && typeof details.message === 'string' ? details.message : 'Renderer initialization failed'; - _writeCrashLog('RENDERER INITIALIZATION FAILED', new Error(message), details); - debugLog(`RENDERER INITIALIZATION FAILED: ${message}`); - if (startupRecoveryCoordinator) void startupRecoveryCoordinator.rendererInitializationFailed(details); + if (startupRendererHandlers) void startupRendererHandlers.rendererInitializationFailed(event, details); }); ipcMain.on('app:close-preparation-started', (event, attempt) => { @@ -3481,11 +3502,13 @@ ipcMain.on('remote:capture-log', (_event, msg) => { // IPC: Input events from capture window ipcMain.on('remote:input-event', (_event, data) => { if (!mainWindow || mainWindow.isDestroyed()) return; + if (!data || typeof data !== 'object' || Array.isArray(data)) return; const config = configStore.load(); const remote = config.globalSettings && config.globalSettings.remote; if (!remote || !remote.allowInput) return; if (data.role !== 'admin') return; + if ((data.type === 'keydown' || data.type === 'keyup') && (typeof data.key !== 'string' || data.key.length === 0)) return; // Capture includes window frame (title bar) but NOT invisible DWM borders // sendInputEvent coordinates are relative to web content area diff --git a/tests/main-remote-input.test.js b/tests/main-remote-input.test.js new file mode 100644 index 0000000..bde121f --- /dev/null +++ b/tests/main-remote-input.test.js @@ -0,0 +1,76 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +function loadRemoteInputHandler(sendInputEvent, debugLog = () => {}) { + const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); + const handlerStart = source.indexOf("ipcMain.on('remote:input-event'"); + const handlerEnd = source.indexOf('\nfunction buildModifiers', handlerStart); + const modifiersEnd = source.indexOf('\n// IPC: Get capture source ID', handlerEnd); + assert.notEqual(handlerStart, -1); + assert.notEqual(handlerEnd, -1); + assert.notEqual(modifiersEnd, -1); + + let inputHandler; + const mainWindow = { + isDestroyed: () => false, + getBounds: () => ({ x: 0, y: 0, width: 1100, height: 750 }), + getContentBounds: () => ({ x: 7, y: 30, width: 1086, height: 713 }), + webContents: { sendInputEvent } + }; + const context = vm.createContext({ + ipcMain: { + on(channel, handler) { + if (channel === 'remote:input-event') inputHandler = handler; + } + }, + mainWindow, + configStore: { + load: () => ({ globalSettings: { remote: { allowInput: true } } }) + }, + debugLog, + process: { platform: 'win32' }, + isFinite + }); + + vm.runInContext(source.slice(handlerStart, handlerEnd) + source.slice(handlerEnd, modifiersEnd), context); + assert.equal(typeof inputHandler, 'function'); + return inputHandler; +} + +test('authenticated keyboard input without a string key is discarded without throwing', () => { + const sent = []; + const logs = []; + const handler = loadRemoteInputHandler(event => sent.push(event), (...args) => logs.push(args)); + const invalidPayloads = [ + { role: 'admin', type: 'keydown' }, + { role: 'admin', type: 'keydown', key: null }, + { role: 'admin', type: 'keydown', key: 1 }, + { role: 'admin', type: 'keydown', key: '' }, + { role: 'admin', type: 'keyup' }, + { role: 'admin', type: 'keyup', key: {} } + ]; + + for (const payload of invalidPayloads) { + assert.doesNotThrow(() => handler({}, payload)); + } + + assert.deepEqual(sent, []); + assert.deepEqual(logs, []); +}); + +test('authenticated keyboard input with a string key keeps normal keydown and keyup behavior', () => { + const sent = []; + const handler = loadRemoteInputHandler(event => sent.push(event)); + + handler({}, { role: 'admin', type: 'keydown', key: 'a', ctrl: true }); + handler({}, { role: 'admin', type: 'keyup', key: 'a', ctrl: true }); + + assert.deepEqual(JSON.parse(JSON.stringify(sent)), [ + { type: 'keyDown', keyCode: 'a', modifiers: ['control'] }, + { type: 'char', keyCode: 'a', modifiers: ['control'] }, + { type: 'keyUp', keyCode: 'a', modifiers: ['control'] } + ]); +}); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 721bc16..4b406c2 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -7,6 +7,7 @@ const { configureStartupRenderer, createStartupFailureDocument, createStartupRecoveryCoordinator, + createStartupRendererHandlers, createStartupWindow, resolveStartupLanguage } = require('../lib/startup-renderer'); @@ -28,8 +29,11 @@ test('main process wires bounded startup recovery into real load and crash paths assert.match(source, /createStartupRecoveryCoordinator/); assert.match(source, /startupRecoveryCoordinator\.loadInitial/); assert.match(source, /startupRecoveryCoordinator\.rendererCrashed/); - assert.match(source, /startupRecoveryCoordinator\.rendererInitializationFailed/); - assert.match(source, /startupRecoveryCoordinator\.rendererReady/); + 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/); }); @@ -59,6 +63,35 @@ class TestBrowserWindow extends EventEmitter { } } +function createManualScheduler() { + let nextId = 1; + const pending = new Map(); + + return { + schedule(callback, delay) { + const handle = { id: nextId++, unref() {} }; + pending.set(handle, { callback, delay }); + return handle; + }, + cancel(handle) { + pending.delete(handle); + }, + count() { + return pending.size; + }, + delays() { + return Array.from(pending.values(), entry => entry.delay); + }, + async fireNext() { + const entry = pending.entries().next().value; + assert.ok(entry); + const [handle, timer] = entry; + pending.delete(handle); + await timer.callback(); + } + }; +} + test('configureStartupRenderer leaves hardware acceleration enabled for a local Windows session', () => { let calls = 0; configureStartupRenderer({ disableHardwareAcceleration() { calls++; } }, { SESSIONNAME: 'Console' }, 'win32'); @@ -311,6 +344,174 @@ 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 = {}; + const coordinator = createStartupRecoveryCoordinator({ + load() {}, + async reload() { + reloadCalls++; + }, + reveal() {}, + async showFailure(failure) { + failures.push(failure); + }, + close() {} + }); + const handlers = createStartupRendererHandlers({ + window: { isDestroyed: () => false, webContents }, + coordinator, + onReady() {}, + onInitializationFailed(details) { + reportedFailure = details; + } + }); + const details = { message: 'top-level initialization failed' }; + + await handlers.rendererInitializationFailed({ sender: webContents }, details); + await handlers.rendererInitializationFailed({ sender: webContents }, details); + + assert.equal(reloadCalls, 1); + assert.equal(reportedFailure, details); + assert.deepEqual(failures, [{ + phase: 'renderer-initialization', + attempt: 2, + details + }]); +}); + +test('production startup handlers enforce the Ready deadline after every main document load', 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 handlers = createStartupRendererHandlers({ + window: { isDestroyed: () => false, webContents: {} }, + coordinator, + onReady() {}, + onInitializationFailed() {} + }); + + handlers.documentLoadStarted(); + handlers.documentLoaded(); + + assert.deepEqual(scheduler.delays(), [25]); + await scheduler.fireNext(); + assert.equal(reloadCalls, 1); + + handlers.documentLoadStarted(); + handlers.documentLoaded(); + await scheduler.fireNext(); + + assert.equal(reloadCalls, 1); + assert.deepEqual(failures, [{ + phase: 'renderer-ready-timeout', + attempt: 2, + details: { timeoutMs: 25 } + }]); +}); + +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 coordinator = createStartupRecoveryCoordinator({ + load() {}, + async reload() { + reloadCalls++; + }, + reveal() {}, + close() {}, + readyTimeoutMs: 25, + scheduleReadyDeadline: scheduler.schedule, + cancelReadyDeadline: scheduler.cancel + }); + const handlers = createStartupRendererHandlers({ + window: { isDestroyed: () => false, webContents }, + coordinator, + onReady() { + readyCalls++; + }, + onInitializationFailed() {} + }); + + handlers.documentLoadStarted(); + handlers.documentLoaded(); + const ready = handlers.rendererReady({ sender: webContents }); + + assert.equal(ready, true); + assert.equal(readyCalls, 1); + assert.equal(scheduler.count(), 0); + assert.equal(reloadCalls, 0); +}); + +test('Ready before did-finish-load prevents a stale deadline', () => { + const scheduler = createManualScheduler(); + const webContents = {}; + const coordinator = createStartupRecoveryCoordinator({ + load() {}, + reload() {}, + reveal() {}, + close() {}, + readyTimeoutMs: 25, + scheduleReadyDeadline: scheduler.schedule, + cancelReadyDeadline: scheduler.cancel + }); + const handlers = createStartupRendererHandlers({ + window: { isDestroyed: () => false, webContents }, + coordinator, + onReady() {}, + onInitializationFailed() {} + }); + + handlers.documentLoadStarted(); + handlers.rendererReady({ sender: webContents }); + handlers.documentLoaded(); + + assert.equal(scheduler.count(), 0); +}); + +test('disposing startup handlers cancels a pending Ready deadline', () => { + const scheduler = createManualScheduler(); + const coordinator = createStartupRecoveryCoordinator({ + load() {}, + reload() {}, + reveal() {}, + close() {}, + readyTimeoutMs: 25, + scheduleReadyDeadline: scheduler.schedule, + cancelReadyDeadline: scheduler.cancel + }); + const handlers = createStartupRendererHandlers({ + window: { isDestroyed: () => false, webContents: {} }, + coordinator, + onReady() {}, + onInitializationFailed() {} + }); + + handlers.documentLoadStarted(); + handlers.documentLoaded(); + handlers.dispose(); + + assert.equal(scheduler.count(), 0); +}); + test('a successful renderer ready event reveals content and resets crash recovery', async () => { const crashes = [ { reason: 'crashed', exitCode: 21 },