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.
This commit is contained in:
@@ -8,6 +8,16 @@ function resolveStartupLanguage(config) {
|
|||||||
return config && config.globalSettings && config.globalSettings.language === 'de' ? 'de' : 'en';
|
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 `<!doctype html><html lang="${german ? 'de' : 'en'}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Multi Hoster Uploader</title><style>html,body{height:100%;margin:0;background:#1f1f1f;color:#f4f4f4;font:15px system-ui,sans-serif}body{display:grid;place-items:center}.card{width:min(520px,calc(100% - 48px));padding:28px;border:1px solid #444;border-radius:12px;background:#292929;box-shadow:0 18px 50px #0008}h1{margin:0 0 12px;font-size:22px}p{margin:0 0 22px;color:#c8c8c8;line-height:1.5}button{min-height:38px;padding:0 18px;border:1px solid #555;border-radius:7px;background:#363636;color:#fff;font-weight:650;cursor:pointer}button:hover{background:#414141}</style></head><body><main class="card"><h1>${title}</h1><p>${detail}</p><button type="button" onclick="window.close()">${close}</button></main></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
function createStartupRecoveryCoordinator({ load, reload, reveal, showFailure, close }) {
|
function createStartupRecoveryCoordinator({ load, reload, reveal, showFailure, close }) {
|
||||||
let initialLoad;
|
let initialLoad;
|
||||||
let crashReloads = 0;
|
let crashReloads = 0;
|
||||||
@@ -86,6 +96,7 @@ function createStartupWindow(BrowserWindow, options) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
configureStartupRenderer,
|
configureStartupRenderer,
|
||||||
|
createStartupFailureDocument,
|
||||||
createStartupRecoveryCoordinator,
|
createStartupRecoveryCoordinator,
|
||||||
createStartupWindow,
|
createStartupWindow,
|
||||||
resolveStartupLanguage
|
resolveStartupLanguage
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -5,7 +5,13 @@ const path = require('path');
|
|||||||
app.setPath('userData', path.join(app.getPath('appData'), 'multi-hoster-uploader'));
|
app.setPath('userData', path.join(app.getPath('appData'), 'multi-hoster-uploader'));
|
||||||
app.setName('Multi Hoster Uploader');
|
app.setName('Multi Hoster Uploader');
|
||||||
app.setAppUserModelId('com.multihoster.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);
|
configureStartupRenderer(app);
|
||||||
nativeTheme.themeSource = 'dark';
|
nativeTheme.themeSource = 'dark';
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
@@ -41,6 +47,8 @@ const { createAgent } = require('./lib/diagnostics-agent');
|
|||||||
const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report');
|
const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report');
|
||||||
const { buildTerminalJobSnapshots } = require('./lib/upload-recovery');
|
const { buildTerminalJobSnapshots } = require('./lib/upload-recovery');
|
||||||
const { selectPublicUploadUrl } = require('./lib/upload-confirmation');
|
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 });
|
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
|
||||||
_eventLoopDelay.enable();
|
_eventLoopDelay.enable();
|
||||||
@@ -123,7 +131,9 @@ let tray = null;
|
|||||||
const configStore = new ConfigStore(app);
|
const configStore = new ConfigStore(app);
|
||||||
configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
|
configStore.setPerfLog((m) => { try { logInfo(m); } catch {} });
|
||||||
let uploadManager = null;
|
let uploadManager = null;
|
||||||
|
const uploadBatchMutationGates = new WeakMap();
|
||||||
let lastSessionSummary = null;
|
let lastSessionSummary = null;
|
||||||
|
let startupRecoveryCoordinator = null;
|
||||||
let sourceDeleteJournal = null;
|
let sourceDeleteJournal = null;
|
||||||
const pendingUploadFinalizations = new Map();
|
const pendingUploadFinalizations = new Map();
|
||||||
|
|
||||||
@@ -145,7 +155,8 @@ function requestUploadFinalization(summary, historyPersisted) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const activeUploadProducerTrackers = new Set();
|
const activeUploadProducerTrackers = new Set();
|
||||||
const settingsImportGate = createSettingsImportGate(() => !!(uploadManager && uploadManager.running));
|
const uploadStartReservation = createUploadStartReservation();
|
||||||
|
const settingsImportGate = createSettingsImportGate(() => !!uploadManager || uploadStartReservation.isActive());
|
||||||
let diagnosticAgent = null;
|
let diagnosticAgent = null;
|
||||||
let _diagHandler = null;
|
let _diagHandler = null;
|
||||||
|
|
||||||
@@ -905,10 +916,7 @@ async function _persistFallbackLogPath(workingPath) {
|
|||||||
const base = path.basename(workingPath);
|
const base = path.basename(workingPath);
|
||||||
toSave = path.join(dir, stripModeStampFromFileName(base));
|
toSave = path.join(dir, stripModeStampFromFileName(base));
|
||||||
}
|
}
|
||||||
if (gs.logFilePath === toSave) return true;
|
await configStore.saveFallbackLogPath(toSave);
|
||||||
gs.logFilePath = toSave;
|
|
||||||
cfg.globalSettings = gs;
|
|
||||||
await configStore.save({ globalSettings: gs });
|
|
||||||
_invalidateUploadLogTargetCache();
|
_invalidateUploadLogTargetCache();
|
||||||
_invalidateLogSettings();
|
_invalidateLogSettings();
|
||||||
safeSend('log-path-auto-updated', { logFilePath: toSave });
|
safeSend('log-path-auto-updated', { logFilePath: toSave });
|
||||||
@@ -1458,26 +1466,7 @@ function createWindow() {
|
|||||||
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
mainWindow.webContents.on('render-process-gone', (_event, details) => {
|
||||||
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
||||||
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
|
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (startupRecoveryCoordinator) void startupRecoveryCoordinator.rendererCrashed(details);
|
||||||
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 {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
mainWindow.webContents.on('unresponsive', () => {
|
mainWindow.webContents.on('unresponsive', () => {
|
||||||
@@ -1501,10 +1490,27 @@ function createWindow() {
|
|||||||
|
|
||||||
let startupLanguage = 'en';
|
let startupLanguage = 'en';
|
||||||
try { startupLanguage = resolveStartupLanguage(configStore.load()); } catch {}
|
try { startupLanguage = resolveStartupLanguage(configStore.load()); } catch {}
|
||||||
startupWindow.load(path.join(__dirname, 'renderer', 'index.html'), (err) => {
|
const rendererTarget = path.join(__dirname, 'renderer', 'index.html');
|
||||||
_writeCrashLog('LOAD FILE FAILED', err);
|
const rendererOptions = { query: { language: startupLanguage } };
|
||||||
debugLog(`LOAD FILE FAILED: ${err && err.stack ? err.stack : err}`);
|
const loadRendererSurface = async () => {
|
||||||
}, { query: { language: startupLanguage } });
|
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() {
|
function createTray() {
|
||||||
@@ -2009,7 +2015,13 @@ ipcMain.handle('get-file-sizes', async (_event, paths) => {
|
|||||||
ipcMain.handle('start-upload', async (_event, payload) => {
|
ipcMain.handle('start-upload', async (_event, payload) => {
|
||||||
if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
|
if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
|
||||||
if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' };
|
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 config = configStore.load();
|
||||||
const files = payload && Array.isArray(payload.files) ? payload.files : [];
|
const files = payload && Array.isArray(payload.files) ? payload.files : [];
|
||||||
const hosters = payload && Array.isArray(payload.hosters) ? payload.hosters : [];
|
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
|
// At 500+ jobs JSON.stringify blew up the debug log with MB-sized lines
|
||||||
// per start-upload and added noticeable delay — log counts only.
|
// per start-upload and added noticeable delay — log counts only.
|
||||||
logMarker('BATCH START', batchPlan);
|
logMarker('BATCH START');
|
||||||
debugLog(`start-upload: files=${batchPlan.fileCount}, hosters=${batchPlan.destinationCount}, jobs=${batchPlan.plannedUploadCount}`);
|
|
||||||
|
|
||||||
const pick = makeAccountPicker(config);
|
const pick = makeAccountPicker(config);
|
||||||
const tasks = jobs.length > 0
|
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(` skipped ${skippedJobs.length} jobs: ${skippedJobs.map(s => s.hoster).join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
debugLog(` tasks built: ${tasks.length}`);
|
|
||||||
|
|
||||||
const auditedStart = await runAfterDurableAudit(
|
const auditedStart = await runAfterDurableAudit(
|
||||||
() => appendUploadPlanAudit(batchPlan, 'start'),
|
() => appendUploadPlanAudit(batchPlan, 'start'),
|
||||||
() => tasks.length > 0 ? new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)) : null
|
() => true
|
||||||
);
|
);
|
||||||
if (!auditedStart.ok) {
|
if (!auditedStart.ok) {
|
||||||
return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) };
|
return { error: getUploadAuditFailureMessage(getConfiguredLanguage()) };
|
||||||
}
|
}
|
||||||
|
if (startLease.isCancelled()) return { error: 'Upload wurde abgebrochen' };
|
||||||
|
if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' };
|
||||||
persistRotation(pick);
|
persistRotation(pick);
|
||||||
|
|
||||||
if (tasks.length === 0) {
|
if (tasks.length === 0) {
|
||||||
@@ -2070,7 +2081,9 @@ ipcMain.handle('start-upload', async (_event, payload) => {
|
|||||||
return { started: true, taskCount: 0, skippedJobs };
|
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;
|
globalThis._mhuUploadManagerRef = uploadManager;
|
||||||
const _thisManager = 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
|
// create a fresh manager which the trailing `uploadManager = null` then
|
||||||
// orphans (cancel/addJobs see null, the new batch keeps running invisibly).
|
// orphans (cancel/addJobs see null, the new batch keeps running invisibly).
|
||||||
uploadManager.on('batch-done', async (summary) => {
|
uploadManager.on('batch-done', async (summary) => {
|
||||||
|
const hadActiveBatchMutation = await batchMutationGate.sealAndDrain();
|
||||||
summary = stats.mergeSkippedIntoSummary(summary, skippedJobs);
|
summary = stats.mergeSkippedIntoSummary(summary, skippedJobs);
|
||||||
lastSessionSummary = summary;
|
lastSessionSummary = summary;
|
||||||
debugLog(`batch-done: total=${summary.total} ok=${summary.succeeded} fail=${summary.failed}`);
|
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(),
|
settledAt: new Date().toISOString(),
|
||||||
terminalJobs: buildTerminalJobSnapshots(summary)
|
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);
|
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}`); }
|
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');
|
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();
|
_producerTracker.finish();
|
||||||
|
|
||||||
const fullyAborted = isAllAborted(summary);
|
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.
|
// Defer startBatch to next tick so the IPC response is sent first.
|
||||||
// This ensures webContents.send() calls from upload events
|
// This ensures webContents.send() calls from upload events
|
||||||
// are not interleaved with the handle() response.
|
// are not interleaved with the handle() response.
|
||||||
|
if (startLease.isCancelled()) _thisManager.cancel();
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
if (uploadManager !== _thisManager) {
|
if (uploadManager !== _thisManager) {
|
||||||
debugLog('setImmediate: uploadManager was replaced before startBatch');
|
debugLog('setImmediate: uploadManager was replaced before startBatch');
|
||||||
@@ -2346,7 +2370,7 @@ ipcMain.handle('start-upload', async (_event, payload) => {
|
|||||||
logMemorySnapshot('batch-start');
|
logMemorySnapshot('batch-start');
|
||||||
debugLog(`start-upload returning started=true (startBatch deferred to nextTick)`);
|
debugLog(`start-upload returning started=true (startBatch deferred to nextTick)`);
|
||||||
return { started: true, taskCount: tasks.length, skippedJobs, sourceCleanupFingerprints };
|
return { started: true, taskCount: tasks.length, skippedJobs, sourceCleanupFingerprints };
|
||||||
});
|
}
|
||||||
|
|
||||||
// Logged at batch boundaries so we can spot memory growth between batches
|
// Logged at batch boundaries so we can spot memory growth between batches
|
||||||
// across long sessions (main process side only — the renderer's live view
|
// across long sessions (main process side only — the renderer's live view
|
||||||
@@ -2360,6 +2384,7 @@ function logMemorySnapshot(label) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ipcMain.handle('cancel-upload', () => {
|
ipcMain.handle('cancel-upload', () => {
|
||||||
|
uploadStartReservation.cancel();
|
||||||
if (uploadManager) {
|
if (uploadManager) {
|
||||||
uploadManager.cancel();
|
uploadManager.cancel();
|
||||||
}
|
}
|
||||||
@@ -2379,48 +2404,55 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => {
|
|||||||
return { error: 'Kein Upload aktiv' };
|
return { error: 'Kein Upload aktiv' };
|
||||||
}
|
}
|
||||||
const batchManager = uploadManager;
|
const batchManager = uploadManager;
|
||||||
const config = configStore.load();
|
const batchMutationGate = uploadBatchMutationGates.get(batchManager);
|
||||||
const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : [];
|
const batchMutationLease = batchMutationGate && batchMutationGate.acquire();
|
||||||
const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : [];
|
if (!batchMutationLease) return { error: 'Kein Upload aktiv' };
|
||||||
const pick = makeAccountPicker(config);
|
try {
|
||||||
const tasks = buildUploadTasksFromJobs(config, jobs, pick);
|
const config = configStore.load();
|
||||||
const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
|
const jobs = payload && Array.isArray(payload.jobs) ? payload.jobs : [];
|
||||||
const skippedJobs = jobs
|
const sourceCleanupGroups = payload && Array.isArray(payload.sourceCleanupGroups) ? payload.sourceCleanupGroups : [];
|
||||||
.filter(j => j && j.id && !taskJobIds.has(j.id))
|
const pick = makeAccountPicker(config);
|
||||||
.map(j => ({ jobId: j.id, hoster: j.hoster, reason: 'Kein gültiger Account für diesen Hoster' }));
|
const tasks = buildUploadTasksFromJobs(config, jobs, pick);
|
||||||
if (jobs.length > 0) {
|
const taskJobIds = new Set(tasks.map(t => t.jobId).filter(Boolean));
|
||||||
const auditedAdd = await runAfterDurableAudit(
|
const skippedJobs = jobs
|
||||||
() => appendUploadPlanAudit(summarizeBatchPlan({ jobs }), 'add'),
|
.filter(j => j && j.id && !taskJobIds.has(j.id))
|
||||||
() => true
|
.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', () => {
|
ipcMain.handle('finish-after-active', () => {
|
||||||
@@ -2915,7 +2947,10 @@ ipcMain.handle('app:quit', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('app:close-handshake-ready', (event) => {
|
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) => {
|
ipcMain.on('app:close-preparation-started', (event, attempt) => {
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ const sourceFiles = [
|
|||||||
'lib/upload-diagnostics.js',
|
'lib/upload-diagnostics.js',
|
||||||
'lib/upload-manager.js',
|
'lib/upload-manager.js',
|
||||||
'lib/upload-recovery.js',
|
'lib/upload-recovery.js',
|
||||||
|
'lib/upload-start-reservation.js',
|
||||||
'lib/vidmoly-upload.js',
|
'lib/vidmoly-upload.js',
|
||||||
'lib/voe-upload.js',
|
'lib/voe-upload.js',
|
||||||
'lib/webhook-notify.js',
|
'lib/webhook-notify.js',
|
||||||
@@ -158,6 +159,7 @@ const sourceFiles = [
|
|||||||
'tests/upload-diagnostics.test.js',
|
'tests/upload-diagnostics.test.js',
|
||||||
'tests/upload-manager.test.js',
|
'tests/upload-manager.test.js',
|
||||||
'tests/upload-recovery.test.js',
|
'tests/upload-recovery.test.js',
|
||||||
|
'tests/upload-start-reservation.test.js',
|
||||||
'tests/session-report.test.js',
|
'tests/session-report.test.js',
|
||||||
'tests/validate-credentials.test.js',
|
'tests/validate-credentials.test.js',
|
||||||
'tests/webhook-notify.test.js'
|
'tests/webhook-notify.test.js'
|
||||||
|
|||||||
@@ -1,8 +1,23 @@
|
|||||||
const { describe, it } = require('node:test');
|
const { describe, it } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
const { createBatchMutationGate } = require('../lib/batch-mutation-gate');
|
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', () => {
|
describe('batch mutation gate', () => {
|
||||||
it('keeps a seal pending until every lease active at seal has finished', async () => {
|
it('keeps a seal pending until every lease active at seal has finished', async () => {
|
||||||
const gate = createBatchMutationGate();
|
const gate = createBatchMutationGate();
|
||||||
|
|||||||
@@ -5,11 +5,33 @@ const fs = require('node:fs');
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const {
|
const {
|
||||||
configureStartupRenderer,
|
configureStartupRenderer,
|
||||||
|
createStartupFailureDocument,
|
||||||
createStartupRecoveryCoordinator,
|
createStartupRecoveryCoordinator,
|
||||||
createStartupWindow,
|
createStartupWindow,
|
||||||
resolveStartupLanguage
|
resolveStartupLanguage
|
||||||
} = require('../lib/startup-renderer');
|
} = 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 {
|
class TestBrowserWindow extends EventEmitter {
|
||||||
constructor(options) {
|
constructor(options) {
|
||||||
super();
|
super();
|
||||||
|
|||||||
@@ -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\(\)\)/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user