diff --git a/lib/startup-renderer.js b/lib/startup-renderer.js index 209ceaf..1a921ca 100644 --- a/lib/startup-renderer.js +++ b/lib/startup-renderer.js @@ -41,6 +41,19 @@ function createStartupRecoveryCoordinator({ load, reload, reveal, showFailure, c return terminalFailure; } + async function recoverRenderer(phase, details) { + 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 }); + } + } + return { loadInitial(...args) { if (terminalFailure) return terminalFailure; @@ -59,17 +72,11 @@ function createStartupRecoveryCoordinator({ load, reload, reveal, showFailure, c } 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 }); - } + rendererCrashed(details) { + return recoverRenderer('renderer-crash', details); + }, + rendererInitializationFailed(details) { + return recoverRenderer('renderer-initialization', details); }, rendererReady() { if (terminalFailure) return false; diff --git a/main.js b/main.js index aa846fb..f1b8191 100644 --- a/main.js +++ b/main.js @@ -2093,17 +2093,6 @@ async function executeReservedUploadStart(payload, startLease) { startedAt: new Date().toISOString(), jobIds: tasks.map(task => task.jobId).filter(Boolean) }; - try { - await configStore.saveUploadRecovery(recovery); - } catch (error) { - debugLog(`upload recovery state could not be saved: ${error.message}`); - if (uploadManager === _thisManager) { - uploadManager = null; - globalThis._mhuUploadManagerRef = null; - } - return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' }; - } - uploadRecoveryStates.set(_thisManager, recovery); // Pre-resolve a fallback for every hoster that has one. Lets the upload // manager break out of the retry loop after a single generic failure and @@ -2148,6 +2137,17 @@ async function executeReservedUploadStart(payload, startLease) { } return { error: `Quelldatei-Schutz konnte nicht vorbereitet werden: ${error.message}` }; } + try { + await configStore.saveUploadRecovery(recovery); + } catch (error) { + debugLog(`upload recovery state could not be saved: ${error.message}`); + if (uploadManager === _thisManager) { + uploadManager = null; + globalThis._mhuUploadManagerRef = null; + } + return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' }; + } + uploadRecoveryStates.set(_thisManager, recovery); for (const skipped of skippedJobs) sourceCleanup.markSkipped(skipped.jobId); _thisManager.sourceFileCleanup = sourceCleanup; const _producerTracker = trackUploadProducer(_thisManager); @@ -2991,6 +2991,14 @@ ipcMain.on('app:close-handshake-ready', (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); +}); + ipcMain.on('app:close-preparation-started', (event, attempt) => { if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents && isClosePreparationActive(attempt)) { armCloseFlushTimer(attempt, 3000); diff --git a/preload.js b/preload.js index 10b6a0c..8a73ad6 100644 --- a/preload.js +++ b/preload.js @@ -27,6 +27,7 @@ contextBridge.exposeInMainWorld('api', { }); }, signalCloseHandshakeReady: () => ipcRenderer.send('app:close-handshake-ready'), + signalRendererInitializationFailed: (details) => ipcRenderer.send('app:renderer-initialization-failed', details), // Always on top setAlwaysOnTop: (value) => ipcRenderer.invoke('set-always-on-top', value), diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index f42fdc0..8641813 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -150,8 +150,10 @@ test('close readiness is signaled only after the renderer explicitly finishes in assert.deepEqual(sent, [['app:close-preparation-started', 7]]); exposedApi.signalCloseHandshakeReady(); + exposedApi.signalRendererInitializationFailed({ message: 'init failed' }); assert.deepEqual(sent, [ ['app:close-preparation-started', 7], - ['app:close-handshake-ready'] + ['app:close-handshake-ready'], + ['app:renderer-initialization-failed', { message: 'init failed' }] ]); }); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 022a1f4..721bc16 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -28,6 +28,7 @@ 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, /createStartupFailureDocument/); }); @@ -284,6 +285,32 @@ test('renderer crash recovery reloads once and cannot enter an infinite reload l assert.equal(revealCalls, 1); }); +test('renderer initialization failures share the bounded recovery path', async () => { + const failures = []; + let reloadCalls = 0; + const coordinator = createStartupRecoveryCoordinator({ + load() {}, + async reload() { + reloadCalls++; + }, + reveal() {}, + async showFailure(failure) { + failures.push(failure); + }, + close() {} + }); + + await coordinator.rendererInitializationFailed({ message: 'first failure' }); + await coordinator.rendererInitializationFailed({ message: 'second failure' }); + + assert.equal(reloadCalls, 1); + assert.deepEqual(failures, [{ + phase: 'renderer-initialization', + attempt: 2, + details: { message: 'second failure' } + }]); +}); + test('a successful renderer ready event reveals content and resets crash recovery', async () => { const crashes = [ { reason: 'crashed', exitCode: 21 }, diff --git a/tests/upload-recovery.test.js b/tests/upload-recovery.test.js index 01d4738..78836af 100644 --- a/tests/upload-recovery.test.js +++ b/tests/upload-recovery.test.js @@ -90,6 +90,7 @@ test('main and renderer keep recovery evidence until final queue persistence suc assert.match(startFailure, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveUploadRecovery\(null\)/); const startHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('start-upload'"), mainSource.indexOf("ipcMain.handle('cancel-upload'")); assert.match(startHandler, /await configStore\.saveUploadRecovery\(recovery\)/); + assert.ok(startHandler.indexOf('sourceCleanup.registerGroups(sourceCleanupGroups)') < startHandler.indexOf('saveUploadRecovery(recovery)')); assert.match(startHandler, /catch \(error\)[\s\S]*return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' }/); const addHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"), mainSource.indexOf("ipcMain.handle('finish-after-active'")); assert.match(addHandler, /await configStore\.saveUploadRecovery\(nextRecovery\)/);