Closing the app (especially during an active upload or on a hard kill) and reopening sometimes left already-uploaded files sitting in the queue as if still pending. Root cause is three layers stacked: 1. Persist-starvation: persistQueueStateSoon() reset a 10s debounce on every progress event, so during an upload the on-disk queue snapshot was never rewritten and stayed frozen at the pre-upload state (all jobs "preview"). 2. The beforeunload sync flush only covers a clean close; a hard kill / crash leaves that stale snapshot on disk. 3. Startup auto-dedup only dropped jobs with status "done". The completed files were stored as "preview" in the stale snapshot, so they survived and reappeared. Fix (mechanism-independent — holds whether the stale snapshot came from starvation, a mid-upload close race, or a hard kill): FIX A (core, durable): the upload log is the source of truth. Each persisted snapshot is now stamped with savedAt; on restart any restored job whose newest matching log entry is timestamped at/after floor(savedAt) is dropped regardless of status — it provably completed after the snapshot, so a "preview" row for it is a ghost. lib/queue-dedup.js gains an additive 3rd savedAt param; without savedAt or without log timestamps it behaves exactly as before (the 5 canary tests stay green, so intentional re-uploads of older files still survive). FIX B: new lib/throttle-timer.js with a max-wait. During uploads the snapshot is now written at most ~20s into a continuous progress burst instead of never; idle stays a pure debounce. The fallback shim honors max-wait too, so a missing library can never silently reintroduce the starvation. FIX C: the synchronous close-write retries renameSync on EBUSY/EPERM/EACCES and uses a pid-unique tmp (de-conflicts it from config-store._atomicWrite's fixed .tmp). A startup sweep reclaims orphaned <config>.<pid>.tmp files left by a hard kill between write and rename. lib/upload-log.js extracts formatUploadLogLine + parseUploadLogLine from main.js so the real writer -> reader -> gate seam is unit-tested (a future log-format or epoch-basis change can no longer pass green while breaking the fix). Verified: 334/334 tests green (incl. throttle fake-clock starvation/maxWait, ts-gate multi-hoster partial-completion, and the real-format seam tests), ESLint clean, smoke-boot identical to baseline, and an adversarial multi-agent review (15 findings, 14 refuted, 1 low — the tmp orphan, now swept) on the diff. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
7.5 KiB
JavaScript
162 lines
7.5 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert');
|
|
const { partitionRestoredJobsByLog } = require('../lib/queue-dedup');
|
|
|
|
function job(status, fileName, hoster) {
|
|
return { status, fileName, hoster, file: `C:/dl/${fileName}` };
|
|
}
|
|
|
|
test('regression: pending preview jobs are NEVER dropped, even when all match the log', () => {
|
|
// Exact shape of the reproduced bug: 4 preview jobs for one file across 4
|
|
// hosters, every fileName|hoster present in the lifetime upload log.
|
|
const jobs = [
|
|
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'doodstream.com'),
|
|
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'voe.sx'),
|
|
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'vidmoly.me'),
|
|
job('preview', 'Einfach mal die Fresse halten!!!.mp4', 'byse.sx')
|
|
];
|
|
const log = [
|
|
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'doodstream.com' },
|
|
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'voe.sx' },
|
|
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'vidmoly.me' },
|
|
{ fileName: 'Einfach mal die Fresse halten!!!.mp4', hoster: 'byse.sx' }
|
|
];
|
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
|
|
assert.equal(removed.length, 0, 'no pending job may be removed');
|
|
assert.equal(kept.length, 4, 'all 4 pending jobs survive restart/update');
|
|
});
|
|
|
|
test('done jobs in the log are dropped (declutter); pending/error/aborted kept', () => {
|
|
const jobs = [
|
|
job('done', 'a.mkv', 'doodstream.com'),
|
|
job('preview', 'a.mkv', 'voe.sx'),
|
|
job('error', 'b.mkv', 'doodstream.com'),
|
|
job('aborted', 'c.mkv', 'doodstream.com')
|
|
];
|
|
const log = [
|
|
{ fileName: 'a.mkv', hoster: 'doodstream.com' },
|
|
{ fileName: 'a.mkv', hoster: 'voe.sx' },
|
|
{ fileName: 'b.mkv', hoster: 'doodstream.com' },
|
|
{ fileName: 'c.mkv', hoster: 'doodstream.com' }
|
|
];
|
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
|
|
assert.equal(removed.length, 1);
|
|
assert.equal(removed[0].status, 'done');
|
|
assert.equal(removed[0].hoster, 'doodstream.com');
|
|
// The preview a.mkv|voe.sx, error b.mkv, aborted c.mkv all survive.
|
|
assert.equal(kept.length, 3);
|
|
assert.ok(kept.some(j => j.status === 'preview' && j.hoster === 'voe.sx'));
|
|
assert.ok(kept.some(j => j.status === 'error'));
|
|
assert.ok(kept.some(j => j.status === 'aborted'));
|
|
});
|
|
|
|
test('done job NOT in the log is kept (e.g. hoster had logToFile disabled)', () => {
|
|
const jobs = [job('done', 'd.mkv', 'doodstream.com')];
|
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, []);
|
|
assert.equal(removed.length, 0);
|
|
assert.equal(kept.length, 1);
|
|
});
|
|
|
|
test('case-insensitive match on fileName and hoster', () => {
|
|
const jobs = [job('done', 'Movie.MKV', 'DoodStream.com')];
|
|
const log = [{ fileName: 'movie.mkv', hoster: 'doodstream.com' }];
|
|
const { removed } = partitionRestoredJobsByLog(jobs, log);
|
|
assert.equal(removed.length, 1);
|
|
});
|
|
|
|
test('empty/missing inputs do not throw', () => {
|
|
assert.deepEqual(partitionRestoredJobsByLog([], []), { kept: [], removed: [] });
|
|
assert.deepEqual(partitionRestoredJobsByLog(null, null), { kept: [], removed: [] });
|
|
const jobs = [job('done', 'x.mkv', 'voe.sx')];
|
|
assert.equal(partitionRestoredJobsByLog(jobs, undefined).kept.length, 1);
|
|
});
|
|
|
|
const T = (s) => Date.parse(s.replace(' ', 'T'));
|
|
|
|
test('ts-gate: preview job uploaded AFTER the snapshot is dropped (the ghost bug)', () => {
|
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
|
const savedAt = T('2026-06-19 12:00:00');
|
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
|
assert.equal(removed.length, 1, 'completed-after-snapshot preview is a ghost → drop');
|
|
assert.equal(kept.length, 0);
|
|
});
|
|
|
|
test('ts-gate: preview job whose only log entry PREDATES the snapshot is kept (intentional re-upload)', () => {
|
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') }];
|
|
const savedAt = T('2026-06-19 12:00:00');
|
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
|
assert.equal(removed.length, 0, 'old upload + freshly-queued re-upload must survive');
|
|
assert.equal(kept.length, 1);
|
|
});
|
|
|
|
test('ts-gate: same-second completion is dropped (savedAt floored to the second)', () => {
|
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:00') }];
|
|
const savedAt = T('2026-06-19 12:00:00') + 800;
|
|
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
|
assert.equal(removed.length, 1, 'log second-granularity must not let same-second ghosts slip through');
|
|
});
|
|
|
|
test('ts-gate: uses the MAX log ts per key (re-upload after a stale earlier entry)', () => {
|
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
|
const log = [
|
|
{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 11:00:00') },
|
|
{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }
|
|
];
|
|
const savedAt = T('2026-06-19 12:00:00');
|
|
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
|
assert.equal(removed.length, 1, 'newest matching log entry decides');
|
|
});
|
|
|
|
test('ts-gate inactive without savedAt → legacy behavior (preview kept even if ts newer)', () => {
|
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log);
|
|
assert.equal(removed.length, 0);
|
|
assert.equal(kept.length, 1);
|
|
});
|
|
|
|
test('ts-gate inactive when log entry lacks ts → legacy behavior', () => {
|
|
const jobs = [job('preview', 'a.mkv', 'voe.sx')];
|
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx' }];
|
|
const savedAt = T('2026-06-19 12:00:00');
|
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
|
assert.equal(removed.length, 0);
|
|
assert.equal(kept.length, 1);
|
|
});
|
|
|
|
test('ts-gate: done job uploaded after snapshot is dropped via either rule', () => {
|
|
const jobs = [job('done', 'a.mkv', 'voe.sx')];
|
|
const log = [{ fileName: 'a.mkv', hoster: 'voe.sx', ts: T('2026-06-19 12:00:05') }];
|
|
const savedAt = T('2026-06-19 12:00:00');
|
|
const { removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
|
assert.equal(removed.length, 1);
|
|
});
|
|
|
|
test('ts-gate: multi-hoster partial completion — the reported bug shape (drop only the completed hosters)', () => {
|
|
// One file queued to 4 hosters; close mid-upload. After the snapshot, 2 hosters
|
|
// completed (logged), 2 never started. On restart all 4 restore as 'preview'.
|
|
// Must drop EXACTLY the 2 that completed and keep the 2 still-pending. This also
|
|
// pins per-hoster keying: a fileName-only gate would wrongly drop all 4.
|
|
const f = 'Einfach mal die Fresse halten!!!.mp4';
|
|
const jobs = [
|
|
job('preview', f, 'doodstream.com'),
|
|
job('preview', f, 'voe.sx'),
|
|
job('preview', f, 'vidmoly.me'),
|
|
job('preview', f, 'byse.sx')
|
|
];
|
|
const savedAt = T('2026-06-19 12:00:00');
|
|
const log = [
|
|
{ fileName: f, hoster: 'doodstream.com', ts: T('2026-06-19 12:00:08') },
|
|
{ fileName: f, hoster: 'voe.sx', ts: T('2026-06-19 12:00:11') }
|
|
];
|
|
const { kept, removed } = partitionRestoredJobsByLog(jobs, log, savedAt);
|
|
assert.equal(removed.length, 2, 'only the 2 completed-after-snapshot hosters drop');
|
|
assert.ok(removed.every(j => j.hoster === 'doodstream.com' || j.hoster === 'voe.sx'));
|
|
assert.equal(kept.length, 2, 'the 2 never-started hosters survive');
|
|
assert.ok(kept.some(j => j.hoster === 'vidmoly.me'));
|
|
assert.ok(kept.some(j => j.hoster === 'byse.sx'));
|
|
});
|