Close startup and recovery transaction gaps

This commit is contained in:
Sucukdeluxe
2026-08-13 21:37:00 +02:00
parent c1003074cd
commit 1dd0e32709
6 changed files with 69 additions and 23 deletions
+18 -11
View File
@@ -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;
+19 -11
View File
@@ -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);
+1
View File
@@ -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),
+3 -1
View File
@@ -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' }]
]);
});
+27
View File
@@ -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 },
+1
View File
@@ -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\)/);