Follow-up hardening on the v3.3.80 queue-persistence fix after a deeper adversarial sweep + reviewer pass over the WHOLE subsystem (not just the diff). Two real defects: 1. Basename-collision lost work (regression introduced by v3.3.80 FIX A). restoreQueueStateFromConfig collapses jobs on the FULL path while the ts-gate keys on basename|hoster. Two genuinely different files with the same basename queued to the same hoster from different folders therefore share a gate key: if one was logged after savedAt, the ts-rule dropped BOTH — silently losing the still-pending one. Pre-FIX-A only 'done' jobs were dropped, so a pending file was never at risk. Fix: an ambiguity guard in partitionRestoredJobsByLog — the ts-rule is suppressed when a basename|hoster key maps to more than one distinct file path (the log records only basenames, so it can't say which physical file completed). The done-in-log rule is unchanged. Fails safe: worst case a visible ghost survives, never silent data loss. 2. selectedFiles re-materialization with removeFromQueueOnDone=ON (second mechanism, independent of the stale snapshot). When that setting is on, a completed job is stripped from queueJobs but its path stays in selectedFiles (syncSelectedFilesFromQueue only runs at batch-done, never on a mid-upload close). On restart the ts-gate operates on queueJobs and never sees it, then the startup updateUploadView -> buildQueuePreview re-creates it as a preview ghost AFTER the gate ran, and it re-persists with a fresh savedAt — sticky. Fix: completedSelectionKeys() seeds _completedUploadKeys (the set buildQueuePreview already consults) from the log at startup, keyed on full path, with the same ambiguity guard. Log-based so it survives a hard kill, consistent with FIX A. Also extracts the orphan-tmp sweep decision into lib/orphan-tmp.js (was untested inline code in main.js; behavior-preserving) and adds executable coverage for the paths that were previously only argued from logic: orphan-tmp sweep, config-store pendingQueue+savedAt round-trip, an end-to-end scenario in the exact user-reported shape (300 queued / ~200 finished mid-session), and a 3000+500-iteration property fuzz of the gate invariant including the lost-work guarantee. 359/359 green, ESLint clean, smoke-boot unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
5.2 KiB
JavaScript
115 lines
5.2 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert');
|
|
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
|
|
|
|
function lcg(seed) {
|
|
let s = seed >>> 0;
|
|
return () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 4294967296; };
|
|
}
|
|
|
|
function key(f, h) { return `${String(f).toLowerCase()}|${String(h).toLowerCase()}`; }
|
|
|
|
test('property: removed iff (done && key in log) OR (savedAt finite && key unambiguous && newest matching log ts >= floor(savedAt/1000)*1000)', () => {
|
|
const rnd = lcg(0x9e3779b1);
|
|
const statuses = ['preview', 'done', 'error', 'aborted', 'queued', 'skipped'];
|
|
const hosters = ['voe.sx', 'byse.sx', 'doodstream.com'];
|
|
const names = ['a.mkv', 'b.mp4', 'A.MKV', 'c.mov'];
|
|
const folders = ['C:/A/', 'C:/B/', 'D:/down/'];
|
|
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
|
|
|
|
for (let iter = 0; iter < 3000; iter++) {
|
|
const useSavedAt = rnd() < 0.7;
|
|
const savedAt = useSavedAt ? Math.floor(rnd() * 2_000_000_000_000) : undefined;
|
|
|
|
const jobs = [];
|
|
const nJobs = 1 + Math.floor(rnd() * 6);
|
|
for (let i = 0; i < nJobs; i++) {
|
|
const name = pick(names);
|
|
// Mix shared and distinct paths so ambiguous keys (same name+hoster,
|
|
// different folder) actually occur and exercise the guard.
|
|
jobs.push({ id: `j${i}`, fileName: name, hoster: pick(hosters), status: pick(statuses), file: `${pick(folders)}${name}` });
|
|
}
|
|
|
|
const log = [];
|
|
const nLog = Math.floor(rnd() * 5);
|
|
for (let i = 0; i < nLog; i++) {
|
|
const hasTs = rnd() < 0.8;
|
|
log.push({ fileName: pick(names), hoster: pick(hosters), ts: hasTs ? Math.floor(rnd() * 2_000_000_000_000) : undefined });
|
|
}
|
|
|
|
const logKeys = new Set();
|
|
const maxTs = new Map();
|
|
for (const e of log) {
|
|
const k = key(e.fileName, e.hoster);
|
|
logKeys.add(k);
|
|
if (typeof e.ts === 'number' && isFinite(e.ts)) {
|
|
const prev = maxTs.get(k);
|
|
if (prev === undefined || e.ts > prev) maxTs.set(k, e.ts);
|
|
}
|
|
}
|
|
const filesPerKey = new Map();
|
|
for (const job of jobs) {
|
|
const k = key(job.fileName, job.hoster);
|
|
if (!filesPerKey.has(k)) filesPerKey.set(k, new Set());
|
|
filesPerKey.get(k).add(job.file || '');
|
|
}
|
|
const floor = (typeof savedAt === 'number' && isFinite(savedAt)) ? Math.floor(savedAt / 1000) * 1000 : null;
|
|
|
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
|
|
|
assert.equal(kept.length + removed.length, jobs.length, `iter ${iter}: partition must cover every job exactly once`);
|
|
const keptIds = new Set(kept.map(j => j.id));
|
|
const removedIds = new Set(removed.map(j => j.id));
|
|
assert.equal(keptIds.size + removedIds.size, jobs.length, `iter ${iter}: no job in both partitions`);
|
|
|
|
for (const job of jobs) {
|
|
const k = key(job.fileName, job.hoster);
|
|
const doneInLog = job.status === 'done' && logKeys.has(k);
|
|
const unambiguous = filesPerKey.get(k).size <= 1;
|
|
const afterSnap = floor !== null && unambiguous && maxTs.has(k) && maxTs.get(k) >= floor;
|
|
const shouldRemove = doneInLog || afterSnap;
|
|
assert.equal(removedIds.has(job.id), shouldRemove,
|
|
`iter ${iter}: job ${job.id} (status=${job.status} key=${k} unambig=${unambiguous}) expected removed=${shouldRemove}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
test('property: a genuinely-pending job is NEVER lost to a same-basename sibling completing after the snapshot', () => {
|
|
const rnd = lcg(0x1234abcd);
|
|
for (let iter = 0; iter < 500; iter++) {
|
|
const savedAt = 1_000_000_000_000 + Math.floor(rnd() * 1_000_000);
|
|
// X completed after the snapshot (logged); Y is a DIFFERENT file, same
|
|
// basename + hoster, genuinely pending. Y must survive.
|
|
const jobs = [
|
|
{ id: 'X', fileName: 'clip.mp4', hoster: 'voe.sx', status: 'preview', file: 'C:/A/clip.mp4' },
|
|
{ id: 'Y', fileName: 'clip.mp4', hoster: 'voe.sx', status: 'preview', file: 'C:/B/clip.mp4' }
|
|
];
|
|
const log = [{ fileName: 'clip.mp4', hoster: 'voe.sx', ts: savedAt + 1000 + Math.floor(rnd() * 1000) }];
|
|
const { kept } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
|
assert.ok(kept.some(j => j.id === 'Y'), `iter ${iter}: pending Y must never be silently dropped`);
|
|
}
|
|
});
|
|
|
|
test('property: 2-arg legacy call NEVER removes a non-done job (the v3.3.80 canary, fuzzed)', () => {
|
|
const rnd = lcg(0xdeadbeef);
|
|
const statuses = ['preview', 'error', 'aborted', 'queued', 'skipped'];
|
|
const hosters = ['voe.sx', 'byse.sx'];
|
|
const names = ['a.mkv', 'b.mp4'];
|
|
const pick = (arr) => arr[Math.floor(rnd() * arr.length)];
|
|
|
|
for (let iter = 0; iter < 1000; iter++) {
|
|
const jobs = [];
|
|
const nJobs = 1 + Math.floor(rnd() * 5);
|
|
for (let i = 0; i < nJobs; i++) {
|
|
jobs.push({ id: `j${i}`, fileName: pick(names), hoster: pick(hosters), status: pick(statuses), file: `C:/x/${i}` });
|
|
}
|
|
const log = [];
|
|
const nLog = Math.floor(rnd() * 4);
|
|
for (let i = 0; i < nLog; i++) {
|
|
log.push({ fileName: pick(names), hoster: pick(hosters), ts: Math.floor(rnd() * 2_000_000_000_000) });
|
|
}
|
|
const { removed } = partitionRestoredJobsByLog(jobs, log);
|
|
assert.ok(removed.every(j => j.status === 'done'), `iter ${iter}: legacy 2-arg call must never drop a non-done job`);
|
|
}
|
|
});
|