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.
This commit is contained in:
Sucukdeluxe
2026-08-13 22:25:28 +02:00
parent d5ff45644c
commit a4d0854e76
4 changed files with 436 additions and 24 deletions
+76
View File
@@ -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'] }
]);
});
+203 -2
View File
@@ -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 },