Harden terminal upload finalization recovery

This commit is contained in:
Sucukdeluxe
2026-08-14 00:07:13 +02:00
parent e681338631
commit 28094414a8
7 changed files with 476 additions and 94 deletions
+1 -2
View File
@@ -2,7 +2,6 @@
'use strict'; 'use strict';
const pathApi = typeof require === 'function' ? require('path') : null; const pathApi = typeof require === 'function' ? require('path') : null;
const protectedStatuses = new Set(['done', 'error', 'aborted', 'skipped']);
const metadataVersion = 2; const metadataVersion = 2;
function normalizeFile(file, platform) { function normalizeFile(file, platform) {
@@ -247,7 +246,7 @@
} }
function removeRequirement(queueJobs, job, platform) { function removeRequirement(queueJobs, job, platform) {
if (!job || protectedStatuses.has(job.status)) return []; if (!job || job.status !== 'preview' || job.interrupted) return [];
const siblings = relatedJobs(queueJobs, job, platform); const siblings = relatedJobs(queueJobs, job, platform);
const removedHoster = normalizeHoster(job.hoster); const removedHoster = normalizeHoster(job.hoster);
for (const sibling of siblings) { for (const sibling of siblings) {
+200 -78
View File
@@ -149,25 +149,184 @@ const startupExternalRevealBindings = createStartupExternalRevealBindings({
getRevealGate: () => startupRevealGate, getRevealGate: () => startupRevealGate,
sendDroppedFiles: paths => safeSend('drop-target:files', paths) sendDroppedFiles: paths => safeSend('drop-target:files', paths)
}); });
const pendingUploadFinalizations = new Map();
function createUploadFinalizationCoordinator({
send,
saveQueue,
schedule = setTimeout,
cancelSchedule = clearTimeout,
createFinalizationId = () => `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
createDeliveryId = () => `delivery-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
timeoutMs = 15000
}) {
const pending = new Map();
let rendererGeneration = 0;
let rendererIsReady = false;
function settle(entry, value) {
if (!entry || entry.settled) return;
entry.settled = true;
if (entry.timer) cancelSchedule(entry.timer);
pending.delete(entry.finalizationId);
entry.resolve(value);
}
function armTimeout(entry) {
if (entry.timer) cancelSchedule(entry.timer);
entry.timer = schedule(() => settle(entry, false), timeoutMs);
}
function deliver(entry) {
if (!rendererIsReady || entry.settled || entry.completing || entry.deliveredGeneration === rendererGeneration) return false;
const deliveryId = createDeliveryId();
const delivered = send({
summary: entry.summary,
finalizationId: entry.finalizationId,
deliveryId,
historyPersisted: entry.historyPersisted
});
if (delivered === false) return false;
entry.deliveryId = deliveryId;
entry.deliveredGeneration = rendererGeneration;
return true;
}
function request(summary, historyPersisted) {
const finalizationId = createFinalizationId();
return new Promise((resolve) => {
const entry = {
finalizationId,
summary,
historyPersisted: historyPersisted === true,
deliveryId: null,
deliveredGeneration: 0,
completing: false,
settled: false,
timer: null,
resolve
};
pending.set(finalizationId, entry);
armTimeout(entry);
deliver(entry);
});
}
function rendererBlocked() {
rendererIsReady = false;
for (const entry of pending.values()) armTimeout(entry);
}
function rendererReady() {
if (rendererIsReady) return rendererGeneration;
rendererIsReady = true;
rendererGeneration += 1;
for (const entry of pending.values()) deliver(entry);
return rendererGeneration;
}
async function complete(payload) {
const entry = payload?.finalizationId && pending.get(payload.finalizationId);
if (!entry || entry.settled || entry.completing || !payload.deliveryId || payload.deliveryId !== entry.deliveryId) return false;
entry.completing = true;
if (payload.ready === false) {
settle(entry, false);
return false;
}
try {
await saveQueue(payload.pendingQueue ?? null);
settle(entry, true);
return true;
} catch {
settle(entry, false);
return false;
}
}
return { request, rendererBlocked, rendererReady, complete };
}
function createUploadFinalizationBarrier({
appendHistory,
saveRecovery,
requestFinalization,
buildTerminalSnapshots,
now = () => new Date().toISOString(),
onError = () => {}
}) {
async function finalize(summary, recovery) {
let historyPersisted = true;
try {
await appendHistory(summary);
} catch (error) {
historyPersisted = false;
onError('history', error);
}
const terminalRecovery = {
...recovery,
settledAt: now(),
terminalJobs: buildTerminalSnapshots(summary)
};
let terminalRecoveryPersisted = true;
try {
await saveRecovery(terminalRecovery);
} catch (error) {
terminalRecoveryPersisted = false;
onError('recovery', error);
}
let queuePersisted = false;
try {
queuePersisted = (await requestFinalization(summary, historyPersisted)) === true;
} catch (error) {
onError('queue', error);
}
let recoveryCleared = false;
if (queuePersisted && terminalRecoveryPersisted) {
try {
await saveRecovery(null);
recoveryCleared = true;
} catch (error) {
onError('recovery-clear', error);
}
}
return {
historyPersisted,
queuePersisted,
terminalRecoveryPersisted,
recoveryCleared,
terminalRecovery
};
}
return { finalize };
}
function requestUploadFinalization(summary, historyPersisted) { function requestUploadFinalization(summary, historyPersisted) {
const finalizationId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; return uploadFinalizationCoordinator.request(summary, historyPersisted);
return new Promise((resolve) => {
const timer = setTimeout(() => {
pendingUploadFinalizations.delete(finalizationId);
resolve(false);
}, 15000);
pendingUploadFinalizations.set(finalizationId, {
resolve(value) {
clearTimeout(timer);
pendingUploadFinalizations.delete(finalizationId);
resolve(value);
}
});
safeSend('upload-batch-done', { summary, finalizationId, historyPersisted: historyPersisted === true });
});
} }
const uploadFinalizationCoordinator = createUploadFinalizationCoordinator({
send: payload => safeSend('upload-batch-done', payload),
saveQueue: async pendingQueue => {
try {
await configStore.savePendingQueue(pendingQueue);
} catch (error) {
debugLog(`upload finalization queue save failed: ${error.message}`);
throw error;
}
},
timeoutMs: RENDERER_READY_TIMEOUT_MS * 3
});
const uploadFinalizationBarrier = createUploadFinalizationBarrier({
appendHistory: summary => configStore.appendHistory(summary),
saveRecovery: recovery => configStore.saveUploadRecovery(recovery),
requestFinalization: requestUploadFinalization,
buildTerminalSnapshots: buildTerminalJobSnapshots,
onError: (phase, error) => debugLog(`upload finalization ${phase} failed: ${error.message}`)
});
const activeUploadProducerTrackers = new Set(); const activeUploadProducerTrackers = new Set();
const uploadStartReservation = createUploadStartReservation(); const uploadStartReservation = createUploadStartReservation();
const settingsImportGate = createSettingsImportGate(() => !!uploadManager || uploadStartReservation.isActive()); const settingsImportGate = createSettingsImportGate(() => !!uploadManager || uploadStartReservation.isActive());
@@ -1520,12 +1679,17 @@ function createWindow() {
window: mainWindow, window: mainWindow,
ipcMain, ipcMain,
coordinator: startupRecoveryCoordinator, coordinator: startupRecoveryCoordinator,
onDocumentLoadStarted: currentStartupRevealGate.block, onDocumentLoadStarted: () => {
currentStartupRevealGate.block();
uploadFinalizationCoordinator.rendererBlocked();
},
onRendererCrashed: (details) => { onRendererCrashed: (details) => {
uploadFinalizationCoordinator.rendererBlocked();
_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}`);
}, },
onReady: () => { onReady: () => {
uploadFinalizationCoordinator.rendererReady();
startupExternalRevealBindings.rendererReady(startupWindow.window); startupExternalRevealBindings.rendererReady(startupWindow.window);
closeHandshakeReady = true; closeHandshakeReady = true;
}, },
@@ -2106,11 +2270,21 @@ async function executeReservedUploadStart(payload, startLease) {
skipped: 0, skipped: 0,
files: [] files: []
}, skippedJobs); }, skippedJobs);
try { await configStore.appendHistory(skippedSummary); } catch (error) { lastSessionSummary = skippedSummary;
debugLog(`appendHistory for skipped jobs failed: ${error.message}`); const finalization = await uploadFinalizationBarrier.finalize(skippedSummary, {
} id: `skipped-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
setImmediate(() => safeSend('upload-batch-done', skippedSummary)); startedAt: new Date().toISOString(),
return { started: true, taskCount: 0, skippedJobs }; jobIds: skippedJobs.map(job => job.jobId).filter(Boolean)
});
if (!finalization.queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing');
if (!finalization.terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted');
return {
started: true,
taskCount: 0,
skippedJobs,
finalized: true,
historyPersisted: finalization.historyPersisted
};
} }
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)); uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config));
@@ -2319,11 +2493,6 @@ async function executeReservedUploadStart(payload, startLease) {
const _batchDurationSec = _thisManager && _thisManager.startTime const _batchDurationSec = _thisManager && _thisManager.startTime
? Math.round((Date.now() - _thisManager.startTime) / 1000) ? Math.round((Date.now() - _thisManager.startTime) / 1000)
: 0; : 0;
let historyPersisted = true;
try { await configStore.appendHistory(summary); } catch (err) {
historyPersisted = false;
debugLog(`appendHistory failed: ${err.message}`);
}
if (_progressFlushTimer) { if (_progressFlushTimer) {
clearTimeout(_progressFlushTimer); clearTimeout(_progressFlushTimer);
_progressFlushTimer = null; _progressFlushTimer = null;
@@ -2332,20 +2501,8 @@ async function executeReservedUploadStart(payload, startLease) {
for (const value of _progressByJob.values()) finalProgressBatch.push(value); for (const value of _progressByJob.values()) finalProgressBatch.push(value);
_progressByJob.clear(); _progressByJob.clear();
if (finalProgressBatch.length) safeSend('upload-progress-batch', finalProgressBatch); if (finalProgressBatch.length) safeSend('upload-progress-batch', finalProgressBatch);
const recoveryWithTerminalJobs = { const finalization = await uploadFinalizationBarrier.finalize(summary, recovery);
...recovery, const { historyPersisted, queuePersisted, terminalRecoveryPersisted } = finalization;
settledAt: new Date().toISOString(),
terminalJobs: buildTerminalJobSnapshots(summary)
};
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 && 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'); if (!queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing');
if (!terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted'); if (!terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted');
if (hadActiveBatchMutation) debugLog('source cleanup blocked: batch mutation overlapped finalization'); if (hadActiveBatchMutation) debugLog('source cleanup blocked: batch mutation overlapped finalization');
@@ -2394,27 +2551,7 @@ async function executeReservedUploadStart(payload, startLease) {
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
await batchMutationGate.sealAndDrain(); await batchMutationGate.sealAndDrain();
const errorSummary = buildFailedUploadSummary(tasks, 'Upload konnte nicht gestartet werden'); const errorSummary = buildFailedUploadSummary(tasks, 'Upload konnte nicht gestartet werden');
let historyPersisted = true; await uploadFinalizationBarrier.finalize(errorSummary, recovery);
try { await configStore.appendHistory(errorSummary); } catch (historyError) {
historyPersisted = false;
debugLog(`appendHistory after start failure failed: ${historyError.message}`);
}
const terminalRecovery = {
...recovery,
settledAt: new Date().toISOString(),
terminalJobs: buildTerminalJobSnapshots(errorSummary)
};
let terminalRecoveryPersisted = true;
try { await configStore.saveUploadRecovery(terminalRecovery); } catch (recoveryError) {
terminalRecoveryPersisted = false;
debugLog(`upload recovery outcomes could not be saved after start failure: ${recoveryError.message}`);
}
const queuePersisted = await requestUploadFinalization(errorSummary, historyPersisted);
if (queuePersisted && terminalRecoveryPersisted) {
try { await configStore.saveUploadRecovery(null); } catch (clearError) {
debugLog(`upload recovery state could not be cleared after start failure: ${clearError.message}`);
}
}
_producerTracker.finish(); _producerTracker.finish();
if (!isAutoRetry) sendBatchWebhook(errorSummary, 0); if (!isAutoRetry) sendBatchWebhook(errorSummary, 0);
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
@@ -3085,22 +3222,7 @@ ipcMain.handle('save-pending-queue', async (_event, pendingQueue) => {
}); });
ipcMain.handle('complete-upload-finalization', async (_event, payload) => { ipcMain.handle('complete-upload-finalization', async (_event, payload) => {
const finalizationId = payload && payload.finalizationId; return uploadFinalizationCoordinator.complete(payload);
const pending = finalizationId && pendingUploadFinalizations.get(finalizationId);
if (!pending) return false;
if (payload.ready === false) {
pending.resolve(false);
return false;
}
try {
await configStore.savePendingQueue(payload.pendingQueue ?? null);
pending.resolve(true);
return true;
} catch (error) {
debugLog(`upload finalization queue save failed: ${error.message}`);
pending.resolve(false);
return false;
}
}); });
ipcMain.handle('save-global-settings', async (_event, globalSettings) => { ipcMain.handle('save-global-settings', async (_event, globalSettings) => {
+19 -3
View File
@@ -2968,6 +2968,11 @@ document.getElementById('contextMenu').addEventListener('click', (e) => {
handleContextAction(action); handleContextAction(action);
}); });
function removeSourceCleanupRequirements(jobs) {
if (!window.SourceCleanupPolicy || !Array.isArray(jobs)) return;
for (const job of jobs) window.SourceCleanupPolicy.removeRequirement(queueJobs, job, 'win32');
}
async function handleContextAction(action) { async function handleContextAction(action) {
_normalizeQueueSelectionToVisible(); _normalizeQueueSelectionToVisible();
if (action === 'start-selected') { if (action === 'start-selected') {
@@ -2987,6 +2992,8 @@ async function handleContextAction(action) {
return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server'); return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server');
}); });
if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds); if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds);
const removedJobs = queueJobs.filter(j => selectedJobIds.has(j.id));
removeSourceCleanupRequirements(removedJobs);
const _deletedKeys = []; const _deletedKeys = [];
queueJobs = queueJobs.filter(j => { queueJobs = queueJobs.filter(j => {
if (selectedJobIds.has(j.id)) { if (selectedJobIds.has(j.id)) {
@@ -3014,6 +3021,7 @@ async function handleContextAction(action) {
await window.api.cancelUpload(); await window.api.cancelUpload();
uploading = false; uploading = false;
} }
removeSourceCleanupRequirements(queueJobs);
queueJobs.forEach(j => removeJobFromIndex(j)); queueJobs.forEach(j => removeJobFromIndex(j));
queueJobs = []; queueJobs = [];
selectedJobIds.clear(); selectedJobIds.clear();
@@ -3036,6 +3044,7 @@ async function handleContextAction(action) {
.filter(j => j.hoster === hoster && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview')) .filter(j => j.hoster === hoster && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview'))
.map(j => j.id); .map(j => j.id);
if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds); if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds);
removeSourceCleanupRequirements(queueJobs.filter(j => j.hoster === hoster));
// Remove ALL jobs for this hoster // Remove ALL jobs for this hoster
queueJobs = queueJobs.filter(j => { queueJobs = queueJobs.filter(j => {
if (j.hoster === hoster) { removeJobFromIndex(j); return false; } if (j.hoster === hoster) { removeJobFromIndex(j); return false; }
@@ -3111,12 +3120,19 @@ async function completeSourceCleanupFinalization(data) {
const persist = async () => { const persist = async () => {
if (!writesReady) { if (!writesReady) {
try { try {
await window.api.completeUploadFinalization({ finalizationId: data.finalizationId, ready: false }); await window.api.completeUploadFinalization({
finalizationId: data.finalizationId,
deliveryId: data.deliveryId,
historyPersisted: data.historyPersisted === true,
ready: false
});
} catch {} } catch {}
return false; return false;
} }
return window.api.completeUploadFinalization({ return window.api.completeUploadFinalization({
finalizationId: data.finalizationId, finalizationId: data.finalizationId,
deliveryId: data.deliveryId,
historyPersisted: data.historyPersisted === true,
pendingQueue: data.historyPersisted !== true || queueJobs.some((job) => !['done', 'skipped'].includes(job.status)) pendingQueue: data.historyPersisted !== true || queueJobs.some((job) => !['done', 'skipped'].includes(job.status))
? buildPersistedQueueState({ historyPersisted: data.historyPersisted === true }) ? buildPersistedQueueState({ historyPersisted: data.historyPersisted === true })
: null : null
@@ -3195,7 +3211,7 @@ async function startUpload(opts) {
window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
} }
_markSkippedJobs(result); _markSkippedJobs(result);
persistQueueStateSoon(); if (result?.finalized !== true) persistQueueStateSoon();
if (result && result.error) { if (result && result.error) {
await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen'); await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen');
@@ -3319,7 +3335,7 @@ async function startSelectedUpload(explicitJobs) {
window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
} }
_markSkippedJobs(result); _markSkippedJobs(result);
persistQueueStateSoon(); if (result?.finalized !== true) persistQueueStateSoon();
if (result && result.error) { if (result && result.error) {
await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen'); await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen');
+41
View File
@@ -178,6 +178,47 @@ test('markCompleted keeps successful hosters provisional for the current round',
} }
}); });
test('removeRequirement never relaxes requirements for started or interrupted jobs', () => {
for (const candidate of [
{ status: 'queued' },
{ status: 'getting-server' },
{ status: 'uploading' },
{ status: 'retrying' },
{ status: 'preview', interrupted: true }
]) {
const queueJobs = [
{
id: 'job-complete',
file: 'C:\\Uploads\\Protected.mkv',
hoster: 'doodstream.com',
status: 'done',
sourceCleanupMetadataVersion: 2,
sourceCleanupToken: 'cleanup-protected',
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
sourceCleanupConfirmedHosters: ['doodstream.com']
},
{
id: 'job-candidate',
file: 'C:\\Uploads\\Protected.mkv',
hoster: 'voe.sx',
sourceCleanupMetadataVersion: 2,
sourceCleanupToken: 'cleanup-protected',
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
sourceCleanupConfirmedHosters: ['doodstream.com'],
...candidate
}
];
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[1], 'win32');
assert.deepEqual(touchedJobs, []);
assert.deepEqual(queueJobs.map(job => job.sourceCleanupRequiredHosters), [
['doodstream.com', 'voe.sx'],
['doodstream.com', 'voe.sx']
]);
}
});
test('applyFingerprints attaches Main fingerprints by token and includes them in payloads', () => { test('applyFingerprints attaches Main fingerprints by token and includes them in payloads', () => {
const queueJobs = queueFixture(); const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32'); policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
+52 -5
View File
@@ -803,6 +803,12 @@ setTimeout(async () => {
const removeAllDanger = await wc.executeJavaScript('(() => { const item = document.querySelector("#contextMenu [data-action=delete-all]"); const channels = getComputedStyle(item).color.match(/[0-9.]+/g)?.map(Number) || []; return Boolean(item && channels.length >= 3 && channels[0] > channels[1] * 1.2 && channels[0] > channels[2] * 1.15); })()'); const removeAllDanger = await wc.executeJavaScript('(() => { const item = document.querySelector("#contextMenu [data-action=delete-all]"); const channels = getComputedStyle(item).color.match(/[0-9.]+/g)?.map(Number) || []; return Boolean(item && channels.length >= 3 && channels[0] > channels[1] * 1.2 && channels[0] > channels[2] * 1.15); })()');
check('Remove all is visually marked as a destructive queue action', removeAllDanger === true); check('Remove all is visually marked as a destructive queue action', removeAllDanger === true);
await wc.executeJavaScript('(() => { queuePersistThrottle.cancel(); queueJobs = [{ id: "ui-cleanup-survivor", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-cleanup-remove-preview", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); selectedJobIds.add("ui-cleanup-remove-preview"); rebuildJobIndex(); renderQueueTable(); window.__uiCleanupPreviewRemoval = handleContextAction("delete-selected"); return true; })()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
const previewCleanupRemoval = await wc.executeJavaScript('window.__uiCleanupPreviewRemoval.then(() => { delete window.__uiCleanupPreviewRemoval; queuePersistThrottle.cancel(); const survivor = queueJobs[0]; const result = { length: queueJobs.length, id: survivor?.id, required: survivor?.sourceCleanupRequiredHosters || [], confirmed: survivor?.sourceCleanupConfirmedHosters || [], finalizing: sourceCleanupFinalizationPending }; queueJobs = []; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); return result; })');
check('Removing an unstarted destination drops only that cleanup prerequisite', previewCleanupRemoval.length === 1 && previewCleanupRemoval.id === 'ui-cleanup-survivor' && previewCleanupRemoval.required.join('|') === 'doodstream.com' && previewCleanupRemoval.confirmed.join('|') === 'doodstream.com' && previewCleanupRemoval.finalizing === false);
const queueTelemetryState = await wc.executeJavaScript(\`(async () => { const queueTelemetryState = await wc.executeJavaScript(\`(async () => {
queueJobs = [ queueJobs = [
{ id: 'telemetry-done-a', status: 'done' }, { id: 'telemetry-done-a', status: 'done' },
@@ -833,14 +839,15 @@ setTimeout(async () => {
let releaseSelectedQueueCancel = null; let releaseSelectedQueueCancel = null;
ipcMain.removeHandler('cancel-selected-jobs'); ipcMain.removeHandler('cancel-selected-jobs');
ipcMain.handle('cancel-selected-jobs', () => new Promise(resolve => { releaseSelectedQueueCancel = () => resolve(true); })); ipcMain.handle('cancel-selected-jobs', () => new Promise(resolve => { releaseSelectedQueueCancel = () => resolve(true); }));
await wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-delete-selected", file: "C:/ui/delete-selected.bin", fileName: "delete-selected.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 }]; selectedJobIds.clear(); selectedJobIds.add("ui-delete-selected"); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteSelectedPromise = handleContextAction("delete-selected"); return true; })()'); await wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-delete-selected-survivor", file: "C:/ui/delete-selected.bin", fileName: "delete-selected.bin", hoster: "doodstream.com", status: "done", bytesUploaded: 100, bytesTotal: 100, progress: 1, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-selected-token", sourceCleanupRequiredHosters: ["doodstream.com", "byse.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-selected", file: "C:/ui/delete-selected.bin", fileName: "delete-selected.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-selected-token", sourceCleanupRequiredHosters: ["doodstream.com", "byse.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); selectedJobIds.add("ui-delete-selected"); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteSelectedPromise = handleContextAction("delete-selected"); return true; })()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"')); await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()'); await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
await waitUntil(() => releaseSelectedQueueCancel); await waitUntil(() => releaseSelectedQueueCancel);
const selectedQueueStillPresent = await wc.executeJavaScript('queueJobs.length'); const selectedQueueStillPresent = await wc.executeJavaScript('queueJobs.length');
releaseSelectedQueueCancel(); releaseSelectedQueueCancel();
const selectedQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteSelectedPromise.then(() => { delete window.__uiDeleteSelectedPromise; return queueJobs.length; })'); const selectedQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteSelectedPromise.then(() => { delete window.__uiDeleteSelectedPromise; const result = { length: queueJobs.length, required: queueJobs[0]?.sourceCleanupRequiredHosters || [] }; queueJobs = []; rebuildJobIndex(); return result; })');
check('Removing selected uploads waits for the main-process cancellation acknowledgement', selectedQueueStillPresent === 1 && selectedQueueAfterCancel === 0); check('Removing selected uploads waits for the main-process cancellation acknowledgement', selectedQueueStillPresent === 2 && selectedQueueAfterCancel.length === 1);
check('Cancelling a started destination never relaxes cleanup prerequisites', selectedQueueAfterCancel.required.join('|') === 'doodstream.com|byse.sx');
restoreInitialIpcHandler('cancel-selected-jobs'); restoreInitialIpcHandler('cancel-selected-jobs');
let releaseFullQueueCancel = null; let releaseFullQueueCancel = null;
@@ -864,6 +871,18 @@ setTimeout(async () => {
restoreInitialIpcHandler('cancel-upload'); restoreInitialIpcHandler('cancel-upload');
restoreInitialIpcHandler('cancel-selected-jobs'); restoreInitialIpcHandler('cancel-selected-jobs');
await wc.executeJavaScript('(() => { uploading = false; queuePersistThrottle.cancel(); const required = ["doodstream.com", "voe.sx", "byse.sx"]; window.__uiDeleteAllCleanupJobs = [{ id: "ui-delete-all-cleanup-done", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-voe", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-byse", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; queueJobs = window.__uiDeleteAllCleanupJobs; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllCleanupPromise = handleContextAction("delete-all"); return true; })()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
const deleteAllCleanupState = await wc.executeJavaScript('window.__uiDeleteAllCleanupPromise.then(() => { delete window.__uiDeleteAllCleanupPromise; queuePersistThrottle.cancel(); const result = { queueLength: queueJobs.length, required: window.__uiDeleteAllCleanupJobs.map(job => job.sourceCleanupRequiredHosters || []), confirmed: window.__uiDeleteAllCleanupJobs.map(job => job.sourceCleanupConfirmedHosters || []) }; delete window.__uiDeleteAllCleanupJobs; return result; })');
check('Remove all drops only never-started cleanup destinations without authorizing deletion', deleteAllCleanupState.queueLength === 0 && deleteAllCleanupState.required.every(hosters => hosters.join('|') === 'doodstream.com') && deleteAllCleanupState.confirmed.every(hosters => hosters.join('|') === 'doodstream.com'));
await wc.executeJavaScript('(() => { const required = ["doodstream.com", "voe.sx", "byse.sx"]; queueJobs = [{ id: "ui-delete-hoster-cleanup-done", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-voe", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-byse", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteHosterCleanupPromise = handleContextAction("delete-hoster:voe.sx"); return true; })()');
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
const deleteHosterCleanupState = await wc.executeJavaScript('window.__uiDeleteHosterCleanupPromise.then(() => { delete window.__uiDeleteHosterCleanupPromise; queuePersistThrottle.cancel(); const result = { ids: queueJobs.map(job => job.id), required: queueJobs.map(job => job.sourceCleanupRequiredHosters || []), confirmed: queueJobs.map(job => job.sourceCleanupConfirmedHosters || []) }; queueJobs = []; rebuildJobIndex(); return result; })');
check('Removing one unstarted host destination preserves every remaining cleanup prerequisite', deleteHosterCleanupState.ids.join('|') === 'ui-delete-hoster-cleanup-done|ui-delete-hoster-cleanup-byse' && deleteHosterCleanupState.required.every(hosters => hosters.join('|') === 'doodstream.com|byse.sx') && deleteHosterCleanupState.confirmed.every(hosters => hosters.join('|') === 'doodstream.com'));
const keyboardTab = await wc.executeJavaScript('document.getElementById("upload-tab").focus(); document.getElementById("upload-tab").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); document.querySelector(".tab.active")?.textContent?.trim() + "|" + document.activeElement?.id'); const keyboardTab = await wc.executeJavaScript('document.getElementById("upload-tab").focus(); document.getElementById("upload-tab").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); document.querySelector(".tab.active")?.textContent?.trim() + "|" + document.activeElement?.id');
check('Arrow keys move and activate main tabs', keyboardTab === 'Accounts|accounts-tab'); check('Arrow keys move and activate main tabs', keyboardTab === 'Accounts|accounts-tab');
@@ -2022,11 +2041,11 @@ setTimeout(async () => {
sourceCleanupFinalizationPayload = payload; sourceCleanupFinalizationPayload = payload;
return false; return false;
}); });
const sourceCleanupRollback = await wc.executeJavaScript('(async () => { queuePersistThrottle.cancel(); await flushConfigWrites(); queueJobs = [{ id: "ui-cleanup-voe", file: "C:/ui/cleanup-round.bin", fileName: "cleanup-round.bin", hoster: "voe.sx", status: "preview", bytesTotal: 10 }, { id: "ui-cleanup-byse", file: "C:/ui/cleanup-round.bin", fileName: "cleanup-round.bin", hoster: "byse.sx", status: "error", bytesTotal: 10 }]; rebuildJobIndex(); window.SourceCleanupPolicy.prepareGroups(queueJobs, queueJobs, () => "ui-cleanup-token", "win32"); queueJobs[0].status = "done"; window.SourceCleanupPolicy.markCompleted(queueJobs, queueJobs[0], "win32"); const before = buildPersistedQueueState(); if (typeof completeSourceCleanupFinalization !== "function") return { available: false, before }; const result = await completeSourceCleanupFinalization({ finalizationId: "ui-cleanup-finalization", historyPersisted: true }); const after = buildPersistedQueueState(); return { available: true, result, before, after }; })()'); const sourceCleanupRollback = await wc.executeJavaScript('(async () => { queuePersistThrottle.cancel(); await flushConfigWrites(); queueJobs = [{ id: "ui-cleanup-voe", file: "C:/ui/cleanup-round.bin", fileName: "cleanup-round.bin", hoster: "voe.sx", status: "preview", bytesTotal: 10 }, { id: "ui-cleanup-byse", file: "C:/ui/cleanup-round.bin", fileName: "cleanup-round.bin", hoster: "byse.sx", status: "error", bytesTotal: 10 }]; rebuildJobIndex(); window.SourceCleanupPolicy.prepareGroups(queueJobs, queueJobs, () => "ui-cleanup-token", "win32"); queueJobs[0].status = "done"; window.SourceCleanupPolicy.markCompleted(queueJobs, queueJobs[0], "win32"); const before = buildPersistedQueueState(); if (typeof completeSourceCleanupFinalization !== "function") return { available: false, before }; const result = await completeSourceCleanupFinalization({ finalizationId: "ui-cleanup-finalization", deliveryId: "ui-cleanup-delivery", historyPersisted: true }); const after = buildPersistedQueueState(); return { available: true, result, before, after }; })()');
const sourceCleanupBeforeJobs = sourceCleanupRollback.before?.queueJobs || []; const sourceCleanupBeforeJobs = sourceCleanupRollback.before?.queueJobs || [];
const sourceCleanupAfterJobs = sourceCleanupRollback.after?.queueJobs || []; const sourceCleanupAfterJobs = sourceCleanupRollback.after?.queueJobs || [];
const sourceCleanupPromotedJobs = sourceCleanupFinalizationPayload?.pendingQueue?.queueJobs || []; const sourceCleanupPromotedJobs = sourceCleanupFinalizationPayload?.pendingQueue?.queueJobs || [];
const sourceCleanupRollbackOk = sourceCleanupRollback.available === true && sourceCleanupRollback.result === false && sourceCleanupBeforeJobs.length === 2 && sourceCleanupBeforeJobs.every(job => !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupProvisionalHosters') && !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupCompletedHosters') && (job.sourceCleanupConfirmedHosters || []).length === 0) && sourceCleanupPromotedJobs.length === 2 && sourceCleanupPromotedJobs.every(job => (job.sourceCleanupConfirmedHosters || []).join('|') === 'voe.sx') && sourceCleanupAfterJobs.length === 2 && sourceCleanupAfterJobs.every(job => (job.sourceCleanupConfirmedHosters || []).length === 0); const sourceCleanupRollbackOk = sourceCleanupRollback.available === true && sourceCleanupRollback.result === false && sourceCleanupFinalizationPayload?.deliveryId === 'ui-cleanup-delivery' && sourceCleanupBeforeJobs.length === 2 && sourceCleanupBeforeJobs.every(job => !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupProvisionalHosters') && !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupCompletedHosters') && (job.sourceCleanupConfirmedHosters || []).length === 0) && sourceCleanupPromotedJobs.length === 2 && sourceCleanupPromotedJobs.every(job => (job.sourceCleanupConfirmedHosters || []).join('|') === 'voe.sx') && sourceCleanupAfterJobs.length === 2 && sourceCleanupAfterJobs.every(job => (job.sourceCleanupConfirmedHosters || []).length === 0);
check('Final queue persistence promotes only inside the handshake and rolls back failed saves', sourceCleanupRollbackOk); check('Final queue persistence promotes only inside the handshake and rolls back failed saves', sourceCleanupRollbackOk);
const terminalRecoveryState = await wc.executeJavaScript('(() => { selectedFiles = []; queueJobs = [{ id: "ui-terminal-done", file: "C:/ui/terminal-done.bin", fileName: "terminal-done.bin", hoster: "voe.sx", status: "done", bytesTotal: 41, error: null, result: { download_url: "https://example.invalid/terminal-done", embed_url: "https://example.invalid/embed-terminal-done", file_code: "terminal-code" } }, { id: "ui-terminal-skipped", file: "C:/ui/terminal-skipped.bin", fileName: "terminal-skipped.bin", hoster: "byse.sx", status: "skipped", bytesTotal: 42, error: "Size limit", result: null }]; rebuildJobIndex(); return { normal: buildPersistedQueueState(), recovery: buildPersistedQueueState({ historyPersisted: false }) }; })()'); const terminalRecoveryState = await wc.executeJavaScript('(() => { selectedFiles = []; queueJobs = [{ id: "ui-terminal-done", file: "C:/ui/terminal-done.bin", fileName: "terminal-done.bin", hoster: "voe.sx", status: "done", bytesTotal: 41, error: null, result: { download_url: "https://example.invalid/terminal-done", embed_url: "https://example.invalid/embed-terminal-done", file_code: "terminal-code" } }, { id: "ui-terminal-skipped", file: "C:/ui/terminal-skipped.bin", fileName: "terminal-skipped.bin", hoster: "byse.sx", status: "skipped", bytesTotal: 42, error: "Size limit", result: null }]; rebuildJobIndex(); return { normal: buildPersistedQueueState(), recovery: buildPersistedQueueState({ historyPersisted: false }) }; })()');
const terminalRecoveryJobs = terminalRecoveryState.recovery?.queueJobs || []; const terminalRecoveryJobs = terminalRecoveryState.recovery?.queueJobs || [];
@@ -2078,6 +2097,34 @@ setTimeout(async () => {
check('Live indexed upload IDs retain precedence over legacy visible identity', completionIdentityRaces.liveIndexedUpload.length === 1 && completionIdentityRaces.liveIndexedUpload[0].status === 'done' && completionIdentityRaces.liveIndexedUpload[0].uploadId === 'completion-live-upload' && completionIdentityRaces.liveIndexedUpload[0].link === 'https://example.invalid/live-upload'); check('Live indexed upload IDs retain precedence over legacy visible identity', completionIdentityRaces.liveIndexedUpload.length === 1 && completionIdentityRaces.liveIndexedUpload[0].status === 'done' && completionIdentityRaces.liveIndexedUpload[0].uploadId === 'completion-live-upload' && completionIdentityRaces.liveIndexedUpload[0].link === 'https://example.invalid/live-upload');
check('Legacy completion identity applies only to one unambiguous candidate', completionIdentityRaces.ambiguousLegacy.every(job => job.status !== 'done') && completionIdentityRaces.uniqueLegacy.length === 1 && completionIdentityRaces.uniqueLegacy[0].status === 'done' && completionIdentityRaces.uniqueLegacy[0].link === 'https://example.invalid/unique'); check('Legacy completion identity applies only to one unambiguous candidate', completionIdentityRaces.ambiguousLegacy.every(job => job.status !== 'done') && completionIdentityRaces.uniqueLegacy.length === 1 && completionIdentityRaces.uniqueLegacy[0].status === 'done' && completionIdentityRaces.uniqueLegacy[0].link === 'https://example.invalid/unique');
restoreInitialIpcHandler('complete-upload-finalization'); restoreInitialIpcHandler('complete-upload-finalization');
await wc.executeJavaScript('queuePersistThrottle.cancel(); flushConfigWrites()');
const allSkippedOriginalConfig = await wc.executeJavaScript('structuredClone(config)');
await wc.executeJavaScript('config = { ...config, hosters: { ...(config.hosters || {}), "clouddrop.cc": [] } }; hosterSettings = config.hosterSettings || {}; saveConfigTracked(config)');
const originalAppendHistoryForSkippedBatch = activeConfigStore.appendHistory.bind(activeConfigStore);
let skippedBatchHistoryFailures = 0;
activeConfigStore.appendHistory = async summary => {
if (String(summary?.id || '').startsWith('skipped-')) {
skippedBatchHistoryFailures++;
throw new Error('injected skipped batch history failure');
}
return originalAppendHistoryForSkippedBatch(summary);
};
let allSkippedFinalizationPayload = null;
const initialCompleteUploadFinalization = initialIpcHandlers.get('complete-upload-finalization');
ipcMain.removeHandler('complete-upload-finalization');
ipcMain.handle('complete-upload-finalization', async (event, payload) => {
allSkippedFinalizationPayload = structuredClone(payload);
return initialCompleteUploadFinalization(event, payload);
});
const allSkippedRendererState = await wc.executeJavaScript('(async () => { queuePersistThrottle.cancel(); await flushConfigWrites(); uploading = false; selectedFiles = []; selectedUploadHosters = ["clouddrop.cc"]; queueJobs = [{ id: "ui-all-skipped", file: "C:/ui/all-skipped.bin", fileName: "all-skipped.bin", hoster: "clouddrop.cc", status: "preview", bytesTotal: 64 }]; rebuildJobIndex(); await startUpload(); await new Promise(resolve => setTimeout(resolve, 700)); queuePersistThrottle.flushSync(); await flushConfigWrites(); return { status: queueJobs[0]?.status, uploading }; })()');
const allSkippedPersistedQueue = activeConfigStore.load().globalSettings?.pendingQueue;
activeConfigStore.appendHistory = originalAppendHistoryForSkippedBatch;
restoreInitialIpcHandler('complete-upload-finalization');
await wc.executeJavaScript('config = ' + JSON.stringify(allSkippedOriginalConfig) + '; hosterSettings = config.hosterSettings || {}; selectedFiles = []; selectedUploadHosters = []; queueJobs = []; rebuildJobIndex(); queuePersistThrottle.cancel(); saveConfigTracked(config)');
const allSkippedPayloadJobs = allSkippedFinalizationPayload?.pendingQueue?.queueJobs || [];
const allSkippedPersistedJobs = allSkippedPersistedQueue?.queueJobs || [];
check('A history failure in an all-skipped batch retains the terminal queue through the finalization barrier', skippedBatchHistoryFailures === 1 && allSkippedFinalizationPayload?.historyPersisted === false && typeof allSkippedFinalizationPayload?.deliveryId === 'string' && allSkippedPayloadJobs.length === 1 && allSkippedPayloadJobs[0].id === 'ui-all-skipped' && allSkippedPayloadJobs[0].status === 'skipped' && allSkippedPersistedJobs.length === 1 && allSkippedPersistedJobs[0].id === 'ui-all-skipped' && allSkippedPersistedJobs[0].status === 'skipped' && allSkippedRendererState.status === 'skipped' && allSkippedRendererState.uploading === false);
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")'); await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
console.log('\\n=== History View ==='); console.log('\\n=== History View ===');
@@ -0,0 +1,153 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
function loadMainFunction(name, nextName) {
const start = mainSource.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} must exist`);
const end = mainSource.indexOf(`\nfunction ${nextName}(`, start + name.length);
assert.notEqual(end, -1, `${nextName} must follow ${name}`);
const context = vm.createContext({});
vm.runInContext(`${mainSource.slice(start, end)}\nthis.loaded = ${name};`, context);
return context.loaded;
}
test('terminal finalization is delivered once per ready renderer generation and rejects stale acknowledgements', async () => {
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
const sent = [];
const savedQueues = [];
const scheduled = new Set();
let deliverySequence = 0;
const coordinator = createCoordinator({
send: payload => {
sent.push(JSON.parse(JSON.stringify(payload)));
return true;
},
saveQueue: async pendingQueue => {
savedQueues.push(JSON.parse(JSON.stringify(pendingQueue)));
},
schedule: callback => {
const token = { callback };
scheduled.add(token);
return token;
},
cancelSchedule: token => scheduled.delete(token),
createFinalizationId: () => 'finalization-1',
createDeliveryId: () => `delivery-${++deliverySequence}`,
timeoutMs: 15000
});
const completion = coordinator.request({ id: 'batch-1', files: [] }, true);
let settled = false;
completion.then(() => { settled = true; });
assert.equal(sent.length, 0);
assert.equal(coordinator.rendererReady(), 1);
assert.equal(coordinator.rendererReady(), 1);
assert.equal(sent.length, 1);
assert.equal(sent[0].finalizationId, 'finalization-1');
assert.equal(sent[0].deliveryId, 'delivery-1');
assert.equal(sent[0].historyPersisted, true);
coordinator.rendererBlocked();
assert.equal(coordinator.rendererReady(), 2);
assert.equal(sent.length, 2);
assert.equal(sent[1].deliveryId, 'delivery-2');
const stale = await coordinator.complete({
finalizationId: 'finalization-1',
deliveryId: 'delivery-1',
pendingQueue: { queueJobs: [{ id: 'stale' }] }
});
await Promise.resolve();
assert.equal(stale, false);
assert.equal(settled, false);
assert.equal(savedQueues.length, 0);
const accepted = await coordinator.complete({
finalizationId: 'finalization-1',
deliveryId: 'delivery-2',
pendingQueue: { queueJobs: [{ id: 'terminal' }] }
});
assert.equal(accepted, true);
assert.equal(await completion, true);
assert.deepEqual(savedQueues, [{ queueJobs: [{ id: 'terminal' }] }]);
assert.equal(scheduled.size, 0);
coordinator.rendererBlocked();
coordinator.rendererReady();
assert.equal(sent.length, 2);
});
test('history failure remains visible to the durable terminal finalization barrier', async () => {
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
const recoveries = [];
const finalizationCalls = [];
const errors = [];
const barrier = createBarrier({
appendHistory: async () => { throw new Error('history unavailable'); },
saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); },
requestFinalization: async (summary, historyPersisted) => {
finalizationCalls.push({ summary: JSON.parse(JSON.stringify(summary)), historyPersisted });
return false;
},
buildTerminalSnapshots: summary => summary.files[0].results.map(result => ({ jobId: result.jobId, status: result.status })),
now: () => '2026-08-13T12:00:00.000Z',
onError: (phase, error) => errors.push([phase, error.message])
});
const summary = {
id: 'skipped-batch',
files: [{ name: 'missing-account.bin', results: [{ jobId: 'skip-1', status: 'skipped' }] }]
};
const result = await barrier.finalize(summary, {
id: 'recovery-skipped',
startedAt: '2026-08-13T11:59:59.000Z',
jobIds: ['skip-1']
});
assert.equal(result.historyPersisted, false);
assert.equal(result.queuePersisted, false);
assert.equal(result.terminalRecoveryPersisted, true);
assert.deepEqual(finalizationCalls, [{ summary, historyPersisted: false }]);
assert.deepEqual(recoveries, [{
id: 'recovery-skipped',
startedAt: '2026-08-13T11:59:59.000Z',
jobIds: ['skip-1'],
settledAt: '2026-08-13T12:00:00.000Z',
terminalJobs: [{ jobId: 'skip-1', status: 'skipped' }]
}]);
assert.deepEqual(errors, [['history', 'history unavailable']]);
});
test('terminal recovery clears only after history-independent queue acknowledgement', async () => {
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
const recoveries = [];
const barrier = createBarrier({
appendHistory: async () => true,
saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); },
requestFinalization: async (_summary, historyPersisted) => historyPersisted,
buildTerminalSnapshots: () => [{ jobId: 'done-1', status: 'done' }],
now: () => '2026-08-13T12:01:00.000Z',
onError: () => {}
});
const result = await barrier.finalize({ id: 'done-batch', files: [] }, {
id: 'recovery-done',
startedAt: '2026-08-13T12:00:00.000Z',
jobIds: ['done-1']
});
assert.equal(result.historyPersisted, true);
assert.equal(result.queuePersisted, true);
assert.equal(result.terminalRecoveryPersisted, true);
assert.equal(result.recoveryCleared, true);
assert.equal(recoveries.length, 2);
assert.equal(recoveries[1], null);
});
+10 -6
View File
@@ -79,17 +79,20 @@ test('main and renderer keep recovery evidence until final queue persistence suc
const rendererSource = fs.readFileSync(path.join(root, 'renderer', 'app.js'), 'utf8'); const rendererSource = fs.readFileSync(path.join(root, 'renderer', 'app.js'), 'utf8');
const indexSource = fs.readFileSync(path.join(root, 'renderer', 'index.html'), 'utf8'); const indexSource = fs.readFileSync(path.join(root, 'renderer', 'index.html'), 'utf8');
const batchDone = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf("ipcMain.handle('cancel-upload'")); const batchDone = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf("ipcMain.handle('cancel-upload'"));
const barrier = mainSource.slice(mainSource.indexOf('function createUploadFinalizationBarrier'), mainSource.indexOf('function requestUploadFinalization'));
assert.match(batchDone, /buildTerminalJobSnapshots\(summary\)/); assert.match(mainSource, /buildTerminalSnapshots: buildTerminalJobSnapshots/);
assert.match(batchDone, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveUploadRecovery\(null\)/); assert.ok(barrier.indexOf('appendHistory(summary)') < barrier.indexOf('saveRecovery(terminalRecovery)'));
assert.ok(batchDone.indexOf('saveUploadRecovery(recoveryWithTerminalJobs)') < batchDone.indexOf('requestUploadFinalization(summary, historyPersisted)')); assert.ok(barrier.indexOf('saveRecovery(terminalRecovery)') < barrier.indexOf('requestFinalization(summary, historyPersisted)'));
assert.match(barrier, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveRecovery\(null\)/);
assert.match(batchDone, /uploadFinalizationBarrier\.finalize\(summary, recovery\)/);
const startFailure = batchDone.slice(batchDone.indexOf('startBatch(tasks')); const startFailure = batchDone.slice(batchDone.indexOf('startBatch(tasks'));
assert.match(startFailure, /buildFailedUploadSummary\(tasks/); assert.match(startFailure, /buildFailedUploadSummary\(tasks/);
assert.match(startFailure, /saveUploadRecovery\(terminalRecovery\)/); assert.match(startFailure, /uploadFinalizationBarrier\.finalize\(errorSummary, recovery\)/);
assert.match(startFailure, /requestUploadFinalization\(errorSummary, historyPersisted\)/);
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'")); const startHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('start-upload'"), mainSource.indexOf("ipcMain.handle('cancel-upload'"));
assert.match(startHandler, /await configStore\.saveUploadRecovery\(recovery\)/); assert.match(startHandler, /await configStore\.saveUploadRecovery\(recovery\)/);
assert.match(startHandler, /uploadFinalizationBarrier\.finalize\(skippedSummary/);
assert.match(startHandler, /finalized: true/);
assert.ok(startHandler.indexOf('sourceCleanup.registerGroups(sourceCleanupGroups)') < startHandler.indexOf('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' }/); 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'")); const addHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"), mainSource.indexOf("ipcMain.handle('finish-after-active'"));
@@ -97,5 +100,6 @@ test('main and renderer keep recovery evidence until final queue persistence suc
assert.ok(addHandler.indexOf('saveUploadRecovery(nextRecovery)') < addHandler.indexOf('batchManager.addJobs(tasks)')); assert.ok(addHandler.indexOf('saveUploadRecovery(nextRecovery)') < addHandler.indexOf('batchManager.addJobs(tasks)'));
assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/); assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/);
assert.match(rendererSource, /data\.historyPersisted !== true/); assert.match(rendererSource, /data\.historyPersisted !== true/);
assert.match(rendererSource, /deliveryId: data\.deliveryId/);
assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js')); assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js'));
}); });