From 3ced148ee8a7753519f7ddb170e02e82d6389b45 Mon Sep 17 00:00:00 2001
From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com>
Date: Thu, 13 Aug 2026 21:11:48 +0200
Subject: [PATCH] Harden upload lifecycle and renderer recovery
Serialize upload starts across durable audit work, drain in-flight batch additions before cleanup, and fail closed when recovery persistence is incomplete. Wire bounded renderer reload recovery with a branded localized failure surface and use merge-safe fallback log persistence.
---
lib/startup-renderer.js | 11 ++
lib/upload-start-reservation.js | 28 ++++
main.js | 199 +++++++++++++++----------
scripts/verify-public-release.mjs | 2 +
tests/batch-mutation-gate.test.js | 15 ++
tests/startup-renderer.test.js | 22 +++
tests/upload-start-reservation.test.js | 57 +++++++
7 files changed, 252 insertions(+), 82 deletions(-)
create mode 100644 lib/upload-start-reservation.js
create mode 100644 tests/upload-start-reservation.test.js
diff --git a/lib/startup-renderer.js b/lib/startup-renderer.js
index 00788d8..209ceaf 100644
--- a/lib/startup-renderer.js
+++ b/lib/startup-renderer.js
@@ -8,6 +8,16 @@ function resolveStartupLanguage(config) {
return config && config.globalSettings && config.globalSettings.language === 'de' ? 'de' : 'en';
}
+function createStartupFailureDocument(language) {
+ const german = language === 'de';
+ const title = german ? 'Oberfläche konnte nicht geladen werden' : 'The interface could not load';
+ const detail = german
+ ? 'Multi Hoster Uploader konnte die Oberfläche nach einem sicheren Wiederherstellungsversuch nicht laden.'
+ : 'Multi Hoster Uploader could not load the interface after a safe recovery attempt.';
+ const close = german ? 'Schließen' : 'Close';
+ return `
Multi Hoster Uploader${title}
${detail}
`;
+}
+
function createStartupRecoveryCoordinator({ load, reload, reveal, showFailure, close }) {
let initialLoad;
let crashReloads = 0;
@@ -86,6 +96,7 @@ function createStartupWindow(BrowserWindow, options) {
module.exports = {
configureStartupRenderer,
+ createStartupFailureDocument,
createStartupRecoveryCoordinator,
createStartupWindow,
resolveStartupLanguage
diff --git a/lib/upload-start-reservation.js b/lib/upload-start-reservation.js
new file mode 100644
index 0000000..2e9c000
--- /dev/null
+++ b/lib/upload-start-reservation.js
@@ -0,0 +1,28 @@
+function createUploadStartReservation() {
+ let active = null;
+
+ return {
+ acquire() {
+ if (active) return null;
+ const state = { cancelled: false, released: false };
+ const lease = {
+ isCancelled: () => state.cancelled,
+ release() {
+ if (state.released) return;
+ state.released = true;
+ if (active && active.lease === lease) active = null;
+ }
+ };
+ active = { lease, state };
+ return lease;
+ },
+ cancel() {
+ if (!active) return false;
+ active.state.cancelled = true;
+ return true;
+ },
+ isActive: () => active !== null
+ };
+}
+
+module.exports = { createUploadStartReservation };
diff --git a/main.js b/main.js
index fa80246..addba14 100644
--- a/main.js
+++ b/main.js
@@ -5,7 +5,13 @@ const path = require('path');
app.setPath('userData', path.join(app.getPath('appData'), 'multi-hoster-uploader'));
app.setName('Multi Hoster Uploader');
app.setAppUserModelId('com.multihoster.uploader');
-const { configureStartupRenderer, createStartupWindow, resolveStartupLanguage } = require('./lib/startup-renderer');
+const {
+ configureStartupRenderer,
+ createStartupFailureDocument,
+ createStartupRecoveryCoordinator,
+ createStartupWindow,
+ resolveStartupLanguage
+} = require('./lib/startup-renderer');
configureStartupRenderer(app);
nativeTheme.themeSource = 'dark';
const fs = require('fs');
@@ -41,6 +47,8 @@ const { createAgent } = require('./lib/diagnostics-agent');
const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report');
const { buildTerminalJobSnapshots } = require('./lib/upload-recovery');
const { selectPublicUploadUrl } = require('./lib/upload-confirmation');
+const { createBatchMutationGate } = require('./lib/batch-mutation-gate');
+const { createUploadStartReservation } = require('./lib/upload-start-reservation');
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
_eventLoopDelay.enable();
@@ -123,7 +131,9 @@ let tray = null;
const configStore = new ConfigStore(app);
configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
let uploadManager = null;
+const uploadBatchMutationGates = new WeakMap();
let lastSessionSummary = null;
+let startupRecoveryCoordinator = null;
let sourceDeleteJournal = null;
const pendingUploadFinalizations = new Map();
@@ -145,7 +155,8 @@ function requestUploadFinalization(summary, historyPersisted) {
});
}
const activeUploadProducerTrackers = new Set();
-const settingsImportGate = createSettingsImportGate(() => !!(uploadManager && uploadManager.running));
+const uploadStartReservation = createUploadStartReservation();
+const settingsImportGate = createSettingsImportGate(() => !!uploadManager || uploadStartReservation.isActive());
let diagnosticAgent = null;
let _diagHandler = null;
@@ -905,10 +916,7 @@ async function _persistFallbackLogPath(workingPath) {
const base = path.basename(workingPath);
toSave = path.join(dir, stripModeStampFromFileName(base));
}
- if (gs.logFilePath === toSave) return true;
- gs.logFilePath = toSave;
- cfg.globalSettings = gs;
- await configStore.save({ globalSettings: gs });
+ await configStore.saveFallbackLogPath(toSave);
_invalidateUploadLogTargetCache();
_invalidateLogSettings();
safeSend('log-path-auto-updated', { logFilePath: toSave });
@@ -1458,26 +1466,7 @@ function createWindow() {
mainWindow.webContents.on('render-process-gone', (_event, details) => {
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
- if (mainWindow && !mainWindow.isDestroyed()) {
- try {
- const choice = dialog.showMessageBoxSync(mainWindow, {
- type: 'error',
- title: shellText('Renderer abgestürzt', 'Renderer crashed'),
- message: shellText(`Der Renderer-Prozess ist abgestürzt (${details.reason}).`, `The renderer process crashed (${details.reason}).`),
- detail: shellText('Bitte Diagnose-Paket exportieren und einsenden. Klick "Neu laden" um die UI wiederherzustellen — laufende Uploads im Main-Process bleiben aktiv.', 'Export and send a diagnostics package. Click "Reload" to restore the interface; uploads running in the main process remain active.'),
- buttons: [shellText('Neu laden', 'Reload'), shellText('Beenden', 'Quit')],
- defaultId: 0,
- cancelId: 1
- });
- if (choice === 0) {
- mainWindow.webContents.reload();
- } else {
- app.exit(1);
- }
- } catch {
- try { mainWindow.webContents.reload(); } catch {}
- }
- }
+ if (startupRecoveryCoordinator) void startupRecoveryCoordinator.rendererCrashed(details);
});
mainWindow.webContents.on('unresponsive', () => {
@@ -1501,10 +1490,27 @@ function createWindow() {
let startupLanguage = 'en';
try { startupLanguage = resolveStartupLanguage(configStore.load()); } catch {}
- startupWindow.load(path.join(__dirname, 'renderer', 'index.html'), (err) => {
- _writeCrashLog('LOAD FILE FAILED', err);
- debugLog(`LOAD FILE FAILED: ${err && err.stack ? err.stack : err}`);
- }, { query: { language: startupLanguage } });
+ const rendererTarget = path.join(__dirname, 'renderer', 'index.html');
+ const rendererOptions = { query: { language: startupLanguage } };
+ const loadRendererSurface = async () => {
+ try {
+ return await mainWindow.loadFile(rendererTarget, rendererOptions);
+ } catch (error) {
+ _writeCrashLog('LOAD FILE FAILED', error);
+ debugLog(`LOAD FILE FAILED: ${error && error.stack ? error.stack : error}`);
+ throw error;
+ }
+ };
+ startupRecoveryCoordinator = createStartupRecoveryCoordinator({
+ load: loadRendererSurface,
+ reload: loadRendererSurface,
+ reveal: () => {
+ if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) mainWindow.show();
+ },
+ showFailure: () => mainWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(createStartupFailureDocument(startupLanguage))}`),
+ close: () => app.exit(1)
+ });
+ void startupRecoveryCoordinator.loadInitial(rendererTarget, rendererOptions);
}
function createTray() {
@@ -2009,7 +2015,13 @@ ipcMain.handle('get-file-sizes', async (_event, paths) => {
ipcMain.handle('start-upload', async (_event, payload) => {
if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' };
- if (uploadManager) return { error: 'Ein Upload wird bereits ausgeführt oder abgeschlossen' };
+ if (uploadManager || uploadStartReservation.isActive()) return { error: 'Ein Upload wird bereits ausgeführt oder abgeschlossen' };
+ const startLease = uploadStartReservation.acquire();
+ if (!startLease) return { error: 'Ein Upload wird bereits ausgeführt oder abgeschlossen' };
+ return executeReservedUploadStart(payload, startLease).finally(() => startLease.release());
+});
+
+async function executeReservedUploadStart(payload, startLease) {
const config = configStore.load();
const files = payload && Array.isArray(payload.files) ? payload.files : [];
const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : [];
@@ -2020,8 +2032,7 @@ ipcMain.handle('start-upload', async (_event, payload) => {
// At 500+ jobs JSON.stringify blew up the debug log with MB-sized lines
// per start-upload and added noticeable delay — log counts only.
- logMarker('BATCH START', batchPlan);
- debugLog(`start-upload: files=${batchPlan.fileCount}, hosters=${batchPlan.destinationCount}, jobs=${batchPlan.plannedUploadCount}`);
+ logMarker('BATCH START');
const pick = makeAccountPicker(config);
const tasks = jobs.length > 0
@@ -2042,15 +2053,15 @@ ipcMain.handle('start-upload', async (_event, payload) => {
debugLog(` skipped ${skippedJobs.length} jobs: ${skippedJobs.map(s => s.hoster).join(', ')}`);
}
- debugLog(` tasks built: ${tasks.length}`);
-
const auditedStart = await runAfterDurableAudit(
() => appendUploadPlanAudit(batchPlan, 'start'),
- () => tasks.length > 0 ? new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)) : null
+ () => true
);
if (!auditedStart.ok) {
return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) };
}
+ if (startLease.isCancelled()) return { error: 'Upload wurde abgebrochen' };
+ if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
persistRotation(pick);
if (tasks.length === 0) {
@@ -2070,7 +2081,9 @@ ipcMain.handle('start-upload', async (_event, payload) => {
return { started: true, taskCount: 0, skippedJobs };
}
- uploadManager = auditedStart.value;
+ uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config));
+ const batchMutationGate = createBatchMutationGate();
+ uploadBatchMutationGates.set(uploadManager, batchMutationGate);
globalThis._mhuUploadManagerRef = uploadManager;
const _thisManager = uploadManager;
@@ -2255,6 +2268,7 @@ ipcMain.handle('start-upload', async (_event, payload) => {
// create a fresh manager which the trailing `uploadManager = null` then
// orphans (cancel/addJobs see null, the new batch keeps running invisibly).
uploadManager.on('batch-done', async (summary) => {
+ const hadActiveBatchMutation = await batchMutationGate.sealAndDrain();
summary = stats.mergeSkippedIntoSummary(summary, skippedJobs);
lastSessionSummary = summary;
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`);
@@ -2281,13 +2295,22 @@ ipcMain.handle('start-upload', async (_event, payload) => {
settledAt: new Date().toISOString(),
terminalJobs: buildTerminalJobSnapshots(summary)
};
- try { await configStore.saveUploadRecovery(recoveryWithTerminalJobs); } catch (error) { debugLog(`upload recovery outcomes could not be saved: ${error.message}`); }
+ let terminalRecoveryPersisted = true;
+ try { await configStore.saveUploadRecovery(recoveryWithTerminalJobs); } catch (error) {
+ terminalRecoveryPersisted = false;
+ debugLog(`upload recovery outcomes could not be saved: ${error.message}`);
+ }
const queuePersisted = await requestUploadFinalization(summary, historyPersisted);
- if (queuePersisted) {
+ if (queuePersisted && terminalRecoveryPersisted) {
try { await configStore.saveUploadRecovery(null); } catch (error) { debugLog(`upload recovery state could not be cleared: ${error.message}`); }
}
if (!queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing');
- await sourceCleanup.finishBatch({ historyPersisted, queuePersisted });
+ if (!terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted');
+ if (hadActiveBatchMutation) debugLog('source cleanup blocked: batch mutation overlapped finalization');
+ await sourceCleanup.finishBatch({
+ historyPersisted,
+ queuePersisted: queuePersisted && terminalRecoveryPersisted && !hadActiveBatchMutation
+ });
_producerTracker.finish();
const fullyAborted = isAllAborted(summary);
@@ -2308,6 +2331,7 @@ ipcMain.handle('start-upload', async (_event, payload) => {
// Defer startBatch to next tick so the IPC response is sent first.
// This ensures webContents.send() calls from upload events
// are not interleaved with the handle() response.
+ if (startLease.isCancelled()) _thisManager.cancel();
setImmediate(() => {
if (uploadManager !== _thisManager) {
debugLog('setImmediate: uploadManager was replaced before startBatch');
@@ -2346,7 +2370,7 @@ ipcMain.handle('start-upload', async (_event, payload) => {
logMemorySnapshot('batch-start');
debugLog(`start-upload returning started=true (startBatch deferred to nextTick)`);
return { started: true, taskCount: tasks.length, skippedJobs, sourceCleanupFingerprints };
-});
+}
// Logged at batch boundaries so we can spot memory growth between batches
// across long sessions (main process side only — the renderer's live view
@@ -2360,6 +2384,7 @@ function logMemorySnapshot(label) {
}
ipcMain.handle('cancel-upload', () => {
+ uploadStartReservation.cancel();
if (uploadManager) {
uploadManager.cancel();
}
@@ -2379,48 +2404,55 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
return { error: 'Kein Upload aktiv' };
}
const batchManager = uploadManager;
- const config = configStore.load();
- const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : [];
- const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : [];
- const pick = makeAccountPicker(config);
- const tasks = buildUploadTasksFromJobs(config, jobs, pick);
- const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
- const skippedJobs = jobs
- .filter(j => j && j.id && !taskJobIds.has(j.id))
- .map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' }));
- if (jobs.length > 0) {
- const auditedAdd = await runAfterDurableAudit(
- () => appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'),
- () => true
+ const batchMutationGate = uploadBatchMutationGates.get(batchManager);
+ const batchMutationLease = batchMutationGate && batchMutationGate.acquire();
+ if (!batchMutationLease) return { error: 'Kein Upload aktiv' };
+ try {
+ const config = configStore.load();
+ const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : [];
+ const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : [];
+ const pick = makeAccountPicker(config);
+ const tasks = buildUploadTasksFromJobs(config, jobs, pick);
+ const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
+ const skippedJobs = jobs
+ .filter(j => j && j.id && !taskJobIds.has(j.id))
+ .map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' }));
+ if (jobs.length > 0) {
+ const auditedAdd = await runAfterDurableAudit(
+ () => appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'),
+ () => true
+ );
+ if (!auditedAdd.ok) return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) };
+ }
+ persistRotation(pick);
+ const sourceCleanupFingerprints = batchManager.sourceFileCleanup
+ ? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups)
+ : {};
+ if (uploadManager !== batchManager || !batchManager.running) {
+ return { error: 'Kein Upload aktiv' };
+ }
+ if (batchManager.sourceFileCleanup) {
+ for (const skipped of skippedJobs) batchManager.sourceFileCleanup.markSkipped(skipped.jobId);
+ }
+
+ if (tasks.length === 0) {
+ debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`);
+ return { added: 0, skippedJobs, alreadyInBatchJobIds: [], sourceCleanupFingerprints };
+ }
+
+ const addResult = batchManager.addJobs(tasks);
+ const added = typeof addResult === 'number' ? addResult : (addResult && addResult.added) || 0;
+ const alreadyInBatchJobIds = (addResult && Array.isArray(addResult.alreadyInBatchJobIds))
+ ? addResult.alreadyInBatchJobIds
+ : [];
+
+ debugLog(
+ `add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${alreadyInBatchJobIds.length} already in batch, ${skippedJobs.length} skipped)`
);
- if (!auditedAdd.ok) return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) };
+ return { added, skippedJobs, alreadyInBatchJobIds, sourceCleanupFingerprints };
+ } finally {
+ batchMutationLease.finish();
}
- persistRotation(pick);
- const sourceCleanupFingerprints = batchManager.sourceFileCleanup
- ? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups)
- : {};
- if (uploadManager !== batchManager || !batchManager.running) {
- return { error: 'Kein Upload aktiv' };
- }
- if (batchManager.sourceFileCleanup) {
- for (const skipped of skippedJobs) batchManager.sourceFileCleanup.markSkipped(skipped.jobId);
- }
-
- if (tasks.length === 0) {
- debugLog(`add-jobs-to-batch: 0 tasks built (${skippedJobs.length} skipped: no account)`);
- return { added: 0, skippedJobs, alreadyInBatchJobIds: [], sourceCleanupFingerprints };
- }
-
- const addResult = batchManager.addJobs(tasks);
- const added = typeof addResult === 'number' ? addResult : (addResult && addResult.added) || 0;
- const alreadyInBatchJobIds = (addResult && Array.isArray(addResult.alreadyInBatchJobIds))
- ? addResult.alreadyInBatchJobIds
- : [];
-
- debugLog(
- `add-jobs-to-batch: ${added} of ${tasks.length} tasks added (${alreadyInBatchJobIds.length} already in batch, ${skippedJobs.length} skipped)`
- );
- return { added, skippedJobs, alreadyInBatchJobIds, sourceCleanupFingerprints };
});
ipcMain.handle('finish-after-active', () => {
@@ -2915,7 +2947,10 @@ ipcMain.handle('app:quit', () => {
});
ipcMain.on('app:close-handshake-ready', (event) => {
- if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) closeHandshakeReady = true;
+ if (mainWindow && !mainWindow.isDestroyed() && event.sender === mainWindow.webContents) {
+ closeHandshakeReady = true;
+ if (startupRecoveryCoordinator) startupRecoveryCoordinator.rendererReady();
+ }
});
ipcMain.on('app:close-preparation-started', (event, attempt) => {
diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs
index a8be8f1..681d970 100644
--- a/scripts/verify-public-release.mjs
+++ b/scripts/verify-public-release.mjs
@@ -66,6 +66,7 @@ const sourceFiles = [
'lib/upload-diagnostics.js',
'lib/upload-manager.js',
'lib/upload-recovery.js',
+ 'lib/upload-start-reservation.js',
'lib/vidmoly-upload.js',
'lib/voe-upload.js',
'lib/webhook-notify.js',
@@ -158,6 +159,7 @@ const sourceFiles = [
'tests/upload-diagnostics.test.js',
'tests/upload-manager.test.js',
'tests/upload-recovery.test.js',
+ 'tests/upload-start-reservation.test.js',
'tests/session-report.test.js',
'tests/validate-credentials.test.js',
'tests/webhook-notify.test.js'
diff --git a/tests/batch-mutation-gate.test.js b/tests/batch-mutation-gate.test.js
index 33f9240..b0a105b 100644
--- a/tests/batch-mutation-gate.test.js
+++ b/tests/batch-mutation-gate.test.js
@@ -1,8 +1,23 @@
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
const { createBatchMutationGate } = require('../lib/batch-mutation-gate');
+it('drains main-process batch mutations before source cleanup finalization', () => {
+ const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
+ const batchDoneStart = source.indexOf("uploadManager.on('batch-done'");
+ const sealAndDrain = source.indexOf('batchMutationGate.sealAndDrain()', batchDoneStart);
+ const cleanupFinish = source.indexOf('sourceCleanup.finishBatch', batchDoneStart);
+ assert.ok(batchDoneStart >= 0);
+ assert.ok(sealAndDrain > batchDoneStart);
+ assert.ok(cleanupFinish > sealAndDrain);
+ assert.match(source.slice(sealAndDrain, cleanupFinish + 300), /!hadActiveBatchMutation/);
+ assert.match(source, /batchMutationGate\.acquire\(\)/);
+ assert.match(source, /finally\s*{\s*batchMutationLease\.finish\(\)/);
+});
+
describe('batch mutation gate', () => {
it('keeps a seal pending until every lease active at seal has finished', async () => {
const gate = createBatchMutationGate();
diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js
index 0204220..022a1f4 100644
--- a/tests/startup-renderer.test.js
+++ b/tests/startup-renderer.test.js
@@ -5,11 +5,33 @@ const fs = require('node:fs');
const path = require('node:path');
const {
configureStartupRenderer,
+ createStartupFailureDocument,
createStartupRecoveryCoordinator,
createStartupWindow,
resolveStartupLanguage
} = require('../lib/startup-renderer');
+test('startup failure document is a localized visible application surface', () => {
+ const english = createStartupFailureDocument('en');
+ const german = createStartupFailureDocument('de');
+
+ assert.match(english, /Multi Hoster Uploader/);
+ assert.match(english, /could not load/);
+ assert.match(english, /Close/);
+ assert.match(german, /konnte nicht geladen werden/);
+ assert.match(german, /Schließen/);
+ assert.doesNotMatch(english, /Electron/);
+});
+
+test('main process wires bounded startup recovery into real load and crash paths', () => {
+ const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
+ assert.match(source, /createStartupRecoveryCoordinator/);
+ assert.match(source, /startupRecoveryCoordinator\.loadInitial/);
+ assert.match(source, /startupRecoveryCoordinator\.rendererCrashed/);
+ assert.match(source, /startupRecoveryCoordinator\.rendererReady/);
+ assert.match(source, /createStartupFailureDocument/);
+});
+
class TestBrowserWindow extends EventEmitter {
constructor(options) {
super();
diff --git a/tests/upload-start-reservation.test.js b/tests/upload-start-reservation.test.js
new file mode 100644
index 0000000..0601f3c
--- /dev/null
+++ b/tests/upload-start-reservation.test.js
@@ -0,0 +1,57 @@
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { createUploadStartReservation } = require('../lib/upload-start-reservation');
+
+test('only one upload start can hold the reservation across asynchronous work', () => {
+ const reservation = createUploadStartReservation();
+ const first = reservation.acquire();
+
+ assert.ok(first);
+ assert.equal(reservation.isActive(), true);
+ assert.equal(reservation.acquire(), null);
+
+ first.release();
+ const second = reservation.acquire();
+ assert.ok(second);
+ assert.notStrictEqual(second, first);
+});
+
+test('cancelling a reserved start is visible until its owner releases it', () => {
+ const reservation = createUploadStartReservation();
+ const lease = reservation.acquire();
+
+ assert.equal(reservation.cancel(), true);
+ assert.equal(lease.isCancelled(), true);
+ assert.equal(reservation.acquire(), null);
+ lease.release();
+ assert.equal(reservation.isActive(), false);
+ assert.equal(reservation.cancel(), false);
+});
+
+test('stale and repeated releases cannot clear a newer reservation', () => {
+ const reservation = createUploadStartReservation();
+ const first = reservation.acquire();
+ first.release();
+ const second = reservation.acquire();
+
+ first.release();
+ assert.equal(reservation.isActive(), true);
+ assert.equal(reservation.acquire(), null);
+ second.release();
+ assert.equal(reservation.isActive(), false);
+});
+
+test('main process reserves starts before audit and exposes cancellation during the wait', () => {
+ const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
+ const start = source.slice(
+ source.indexOf("ipcMain.handle('start-upload'"),
+ source.indexOf("ipcMain.handle('cancel-selected-jobs'")
+ );
+
+ assert.ok(start.indexOf('uploadStartReservation.acquire()') < start.indexOf('appendUploadPlanAudit(batchPlan'));
+ assert.match(start, /executeReservedUploadStart\(payload, startLease\)\.finally\(\(\) => startLease\.release\(\)\)/);
+ assert.match(start, /uploadStartReservation\.cancel\(\)/);
+ assert.match(source, /createSettingsImportGate\(\(\) => !!uploadManager \|\| uploadStartReservation\.isActive\(\)\)/);
+});