feat: coordinate bounded renderer startup recovery
Retry the initial renderer load once, then resolve repeated failures through a visible safe surface or a controlled close. Bound renderer crash reloads until a successful ready signal resets recovery, keep terminal handling idempotent, and cover retries, fallback failures, reload failures, ready resets, and late events with focused tests.
This commit is contained in:
+68
-1
@@ -8,6 +8,68 @@ function resolveStartupLanguage(config) {
|
|||||||
return config && config.globalSettings && config.globalSettings.language === 'de' ? 'de' : 'en';
|
return config && config.globalSettings && config.globalSettings.language === 'de' ? 'de' : 'en';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createStartupRecoveryCoordinator({ load, reload, reveal, showFailure, close }) {
|
||||||
|
let initialLoad;
|
||||||
|
let crashReloads = 0;
|
||||||
|
let terminalFailure;
|
||||||
|
|
||||||
|
function endWithFailure(failure) {
|
||||||
|
if (!terminalFailure) {
|
||||||
|
terminalFailure = (async () => {
|
||||||
|
if (typeof showFailure !== 'function') {
|
||||||
|
await close(failure);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await showFailure(failure);
|
||||||
|
reveal();
|
||||||
|
} catch (surfaceError) {
|
||||||
|
await close({ ...failure, surfaceError });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return terminalFailure;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
loadInitial(...args) {
|
||||||
|
if (terminalFailure) return terminalFailure;
|
||||||
|
if (!initialLoad) {
|
||||||
|
initialLoad = (async () => {
|
||||||
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
|
try {
|
||||||
|
return await load(...args);
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt === 2) {
|
||||||
|
await endWithFailure({ phase: 'initial-load', attempt, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return initialLoad;
|
||||||
|
},
|
||||||
|
async rendererCrashed(details) {
|
||||||
|
if (terminalFailure) return terminalFailure;
|
||||||
|
if (crashReloads >= 1) {
|
||||||
|
return endWithFailure({ phase: 'renderer-crash', attempt: crashReloads + 1, details });
|
||||||
|
}
|
||||||
|
crashReloads++;
|
||||||
|
try {
|
||||||
|
await reload();
|
||||||
|
} catch (error) {
|
||||||
|
return endWithFailure({ phase: 'renderer-reload', attempt: crashReloads, details, error });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rendererReady() {
|
||||||
|
if (terminalFailure) return false;
|
||||||
|
crashReloads = 0;
|
||||||
|
reveal();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
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.once('ready-to-show', () => {
|
||||||
@@ -22,4 +84,9 @@ function createStartupWindow(BrowserWindow, options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { configureStartupRenderer, createStartupWindow, resolveStartupLanguage };
|
module.exports = {
|
||||||
|
configureStartupRenderer,
|
||||||
|
createStartupRecoveryCoordinator,
|
||||||
|
createStartupWindow,
|
||||||
|
resolveStartupLanguage
|
||||||
|
};
|
||||||
|
|||||||
@@ -3,7 +3,12 @@ const assert = require('node:assert/strict');
|
|||||||
const { EventEmitter } = require('node:events');
|
const { EventEmitter } = require('node:events');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { configureStartupRenderer, createStartupWindow, resolveStartupLanguage } = require('../lib/startup-renderer');
|
const {
|
||||||
|
configureStartupRenderer,
|
||||||
|
createStartupRecoveryCoordinator,
|
||||||
|
createStartupWindow,
|
||||||
|
resolveStartupLanguage
|
||||||
|
} = require('../lib/startup-renderer');
|
||||||
|
|
||||||
class TestBrowserWindow extends EventEmitter {
|
class TestBrowserWindow extends EventEmitter {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
@@ -27,7 +32,7 @@ class TestBrowserWindow extends EventEmitter {
|
|||||||
loadFile(target, options) {
|
loadFile(target, options) {
|
||||||
this.startupEvents.push(`load:${target}`);
|
this.startupEvents.push(`load:${target}`);
|
||||||
this.loadOptions = options;
|
this.loadOptions = options;
|
||||||
return Promise.reject(this.loadError);
|
return this.loadError ? Promise.reject(this.loadError) : Promise.resolve('loaded');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,6 +75,7 @@ test('main window uses the branded application icon', () => {
|
|||||||
|
|
||||||
test('startup load registers visibility before navigation and shows only once', async () => {
|
test('startup load registers visibility before navigation and shows only once', async () => {
|
||||||
const startup = createStartupWindow(TestBrowserWindow, {});
|
const startup = createStartupWindow(TestBrowserWindow, {});
|
||||||
|
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, [
|
||||||
@@ -103,3 +109,254 @@ 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 recovery retries the initial load exactly once before succeeding', async () => {
|
||||||
|
const attempts = [];
|
||||||
|
const options = { query: { language: 'de' } };
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
async load(...args) {
|
||||||
|
attempts.push(args);
|
||||||
|
if (attempts.length === 1) throw new Error('first load failed');
|
||||||
|
return 'loaded';
|
||||||
|
},
|
||||||
|
reload() {},
|
||||||
|
reveal() {},
|
||||||
|
close() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await coordinator.loadInitial('renderer/index.html', options);
|
||||||
|
|
||||||
|
assert.equal(result, 'loaded');
|
||||||
|
assert.deepEqual(attempts, [
|
||||||
|
['renderer/index.html', options],
|
||||||
|
['renderer/index.html', options]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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 = [];
|
||||||
|
let loadCalls = 0;
|
||||||
|
let revealCalls = 0;
|
||||||
|
let closeCalls = 0;
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
async load() {
|
||||||
|
throw loadErrors[loadCalls++];
|
||||||
|
},
|
||||||
|
reload() {},
|
||||||
|
reveal() {
|
||||||
|
revealCalls++;
|
||||||
|
},
|
||||||
|
async showFailure(failure) {
|
||||||
|
safeFailures.push(failure);
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
closeCalls++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await coordinator.loadInitial('renderer/index.html');
|
||||||
|
|
||||||
|
assert.equal(loadCalls, 2);
|
||||||
|
assert.deepEqual(safeFailures, [{
|
||||||
|
phase: 'initial-load',
|
||||||
|
attempt: 2,
|
||||||
|
error: loadErrors[1]
|
||||||
|
}]);
|
||||||
|
assert.equal(revealCalls, 1);
|
||||||
|
assert.equal(closeCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup recovery closes when the safe failure surface cannot be shown', async () => {
|
||||||
|
const loadError = new Error('renderer remains unavailable');
|
||||||
|
const surfaceError = new Error('failure surface failed');
|
||||||
|
const closeFailures = [];
|
||||||
|
let revealCalls = 0;
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
async load() {
|
||||||
|
throw loadError;
|
||||||
|
},
|
||||||
|
reload() {},
|
||||||
|
reveal() {
|
||||||
|
revealCalls++;
|
||||||
|
},
|
||||||
|
async showFailure() {
|
||||||
|
throw surfaceError;
|
||||||
|
},
|
||||||
|
async close(failure) {
|
||||||
|
closeFailures.push(failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await coordinator.loadInitial('renderer/index.html');
|
||||||
|
|
||||||
|
assert.equal(revealCalls, 0);
|
||||||
|
assert.deepEqual(closeFailures, [{
|
||||||
|
phase: 'initial-load',
|
||||||
|
attempt: 2,
|
||||||
|
error: loadError,
|
||||||
|
surfaceError
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('startup recovery uses a controlled close when no failure surface is configured', async () => {
|
||||||
|
const loadError = new Error('renderer unavailable');
|
||||||
|
const closeFailures = [];
|
||||||
|
let revealCalls = 0;
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
async load() {
|
||||||
|
throw loadError;
|
||||||
|
},
|
||||||
|
reload() {},
|
||||||
|
reveal() {
|
||||||
|
revealCalls++;
|
||||||
|
},
|
||||||
|
async close(failure) {
|
||||||
|
closeFailures.push(failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await coordinator.loadInitial('renderer/index.html');
|
||||||
|
|
||||||
|
assert.equal(revealCalls, 0);
|
||||||
|
assert.deepEqual(closeFailures, [{
|
||||||
|
phase: 'initial-load',
|
||||||
|
attempt: 2,
|
||||||
|
error: loadError
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer crash recovery reloads once and cannot enter an infinite reload loop', async () => {
|
||||||
|
const crashes = [
|
||||||
|
{ reason: 'crashed', exitCode: 11 },
|
||||||
|
{ reason: 'crashed', exitCode: 12 },
|
||||||
|
{ reason: 'crashed', exitCode: 13 }
|
||||||
|
];
|
||||||
|
const safeFailures = [];
|
||||||
|
let reloadCalls = 0;
|
||||||
|
let revealCalls = 0;
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
load() {},
|
||||||
|
async reload() {
|
||||||
|
reloadCalls++;
|
||||||
|
},
|
||||||
|
reveal() {
|
||||||
|
revealCalls++;
|
||||||
|
},
|
||||||
|
async showFailure(failure) {
|
||||||
|
safeFailures.push(failure);
|
||||||
|
},
|
||||||
|
close() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await coordinator.rendererCrashed(crashes[0]);
|
||||||
|
await coordinator.rendererCrashed(crashes[1]);
|
||||||
|
await coordinator.rendererCrashed(crashes[2]);
|
||||||
|
|
||||||
|
assert.equal(reloadCalls, 1);
|
||||||
|
assert.deepEqual(safeFailures, [{
|
||||||
|
phase: 'renderer-crash',
|
||||||
|
attempt: 2,
|
||||||
|
details: crashes[1]
|
||||||
|
}]);
|
||||||
|
assert.equal(revealCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a successful renderer ready event reveals content and resets crash recovery', async () => {
|
||||||
|
const crashes = [
|
||||||
|
{ reason: 'crashed', exitCode: 21 },
|
||||||
|
{ reason: 'crashed', exitCode: 22 }
|
||||||
|
];
|
||||||
|
let reloadCalls = 0;
|
||||||
|
let revealCalls = 0;
|
||||||
|
let safeFailureCalls = 0;
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
load() {},
|
||||||
|
async reload() {
|
||||||
|
reloadCalls++;
|
||||||
|
},
|
||||||
|
reveal() {
|
||||||
|
revealCalls++;
|
||||||
|
},
|
||||||
|
async showFailure() {
|
||||||
|
safeFailureCalls++;
|
||||||
|
},
|
||||||
|
close() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await coordinator.rendererCrashed(crashes[0]);
|
||||||
|
coordinator.rendererReady();
|
||||||
|
await coordinator.rendererCrashed(crashes[1]);
|
||||||
|
|
||||||
|
assert.equal(reloadCalls, 2);
|
||||||
|
assert.equal(revealCalls, 1);
|
||||||
|
assert.equal(safeFailureCalls, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed crash reload enters the safe failure state without another reload', async () => {
|
||||||
|
const crash = { reason: 'launch-failed', exitCode: 31 };
|
||||||
|
const reloadError = new Error('reload failed');
|
||||||
|
const safeFailures = [];
|
||||||
|
let reloadCalls = 0;
|
||||||
|
let revealCalls = 0;
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
load() {},
|
||||||
|
async reload() {
|
||||||
|
reloadCalls++;
|
||||||
|
throw reloadError;
|
||||||
|
},
|
||||||
|
reveal() {
|
||||||
|
revealCalls++;
|
||||||
|
},
|
||||||
|
async showFailure(failure) {
|
||||||
|
safeFailures.push(failure);
|
||||||
|
},
|
||||||
|
close() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await coordinator.rendererCrashed(crash);
|
||||||
|
await coordinator.rendererCrashed({ reason: 'crashed', exitCode: 32 });
|
||||||
|
|
||||||
|
assert.equal(reloadCalls, 1);
|
||||||
|
assert.deepEqual(safeFailures, [{
|
||||||
|
phase: 'renderer-reload',
|
||||||
|
attempt: 1,
|
||||||
|
details: crash,
|
||||||
|
error: reloadError
|
||||||
|
}]);
|
||||||
|
assert.equal(revealCalls, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminal recovery ignores late ready, load, and crash events', async () => {
|
||||||
|
let loadCalls = 0;
|
||||||
|
let reloadCalls = 0;
|
||||||
|
let revealCalls = 0;
|
||||||
|
let safeFailureCalls = 0;
|
||||||
|
const coordinator = createStartupRecoveryCoordinator({
|
||||||
|
async load() {
|
||||||
|
loadCalls++;
|
||||||
|
},
|
||||||
|
async reload() {
|
||||||
|
reloadCalls++;
|
||||||
|
},
|
||||||
|
reveal() {
|
||||||
|
revealCalls++;
|
||||||
|
},
|
||||||
|
async showFailure() {
|
||||||
|
safeFailureCalls++;
|
||||||
|
},
|
||||||
|
close() {}
|
||||||
|
});
|
||||||
|
|
||||||
|
await coordinator.rendererCrashed({ reason: 'crashed', exitCode: 41 });
|
||||||
|
await coordinator.rendererCrashed({ reason: 'crashed', exitCode: 42 });
|
||||||
|
const readyResult = coordinator.rendererReady();
|
||||||
|
await coordinator.loadInitial('renderer/index.html');
|
||||||
|
await coordinator.rendererCrashed({ reason: 'crashed', exitCode: 43 });
|
||||||
|
|
||||||
|
assert.equal(readyResult, false);
|
||||||
|
assert.equal(loadCalls, 0);
|
||||||
|
assert.equal(reloadCalls, 1);
|
||||||
|
assert.equal(revealCalls, 1);
|
||||||
|
assert.equal(safeFailureCalls, 1);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user