From e58a38a915368d7d80707c29881bd279a8086453 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:58:35 +0200 Subject: [PATCH] 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. --- lib/startup-renderer.js | 69 ++++++++- tests/startup-renderer.test.js | 261 ++++++++++++++++++++++++++++++++- 2 files changed, 327 insertions(+), 3 deletions(-) diff --git a/lib/startup-renderer.js b/lib/startup-renderer.js index aea6694..00788d8 100644 --- a/lib/startup-renderer.js +++ b/lib/startup-renderer.js @@ -8,6 +8,68 @@ function resolveStartupLanguage(config) { 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) { const window = new BrowserWindow({ ...options, show: false }); window.once('ready-to-show', () => { @@ -22,4 +84,9 @@ function createStartupWindow(BrowserWindow, options) { }; } -module.exports = { configureStartupRenderer, createStartupWindow, resolveStartupLanguage }; +module.exports = { + configureStartupRenderer, + createStartupRecoveryCoordinator, + createStartupWindow, + resolveStartupLanguage +}; diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 06d7dbe..0204220 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -3,7 +3,12 @@ const assert = require('node:assert/strict'); const { EventEmitter } = require('node:events'); const fs = require('node:fs'); 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 { constructor(options) { @@ -27,7 +32,7 @@ class TestBrowserWindow extends EventEmitter { loadFile(target, options) { this.startupEvents.push(`load:${target}`); 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 () => { const startup = createStartupWindow(TestBrowserWindow, {}); + startup.window.loadError = null; const loading = startup.load('renderer/index.html', () => {}); 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); }); + +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); +});