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>
85 lines
3.4 KiB
JavaScript
85 lines
3.4 KiB
JavaScript
// Startup queue auto-dedup logic. Extracted from renderer/app.js
|
|
// _autoDeduplicateFromLog so the decision can be unit-tested without a DOM or
|
|
// the renderer's module-level state.
|
|
//
|
|
// Loaded both as a CommonJS module (Node tests) and as a browser global
|
|
// (renderer/app.js via index.html script tag) so a single implementation backs
|
|
// runtime and tests — no drift.
|
|
//
|
|
// Behaviour: on launch the restored queue is compared against the lifetime
|
|
// upload log. Two rules drop a job:
|
|
// 1) a 'done' job whose fileName|hoster appears in the log (declutter of
|
|
// already-finished work), and
|
|
// 2) ANY job (incl. preview) whose newest matching log entry is timestamped
|
|
// at/after the snapshot's savedAt — it provably completed AFTER the queue
|
|
// was last persisted, so a restored 'preview' row for it is a stale ghost.
|
|
//
|
|
// Rule 2 only fires when a savedAt is passed AND the log carries timestamps;
|
|
// without them this falls back to rule 1 alone. That fallback is the invariant
|
|
// the canary tests pin: a pending job matching an OLDER log line (ts < savedAt,
|
|
// or no ts at all) is KEPT — it's an intentional re-upload of a file uploaded
|
|
// before, not a ghost. The old code filtered on log-presence alone, regardless
|
|
// of status, so the ENTIRE restored queue vanished on the next restart/update
|
|
// whenever the files had been uploaded previously. Manual log import
|
|
// (importUploadLog) stays separate and explicit for bulk dedup.
|
|
|
|
(function (root) {
|
|
'use strict';
|
|
|
|
function _key(fileName, hoster) {
|
|
return `${String(fileName).toLowerCase()}|${String(hoster).toLowerCase()}`;
|
|
}
|
|
|
|
/**
|
|
* Partition restored queue jobs into kept vs removed, given lifetime log
|
|
* entries. Removes only 'done' jobs whose fileName|hoster is in the log.
|
|
* @param {Array<{status:string,fileName:string,hoster:string}>} jobs
|
|
* @param {Array<{fileName:string,hoster:string}>} logEntries
|
|
* @returns {{ kept: Array, removed: Array }}
|
|
*/
|
|
function partitionRestoredJobsByLog(jobs, logEntries, savedAt) {
|
|
const kept = [];
|
|
const removed = [];
|
|
if (!Array.isArray(jobs) || jobs.length === 0) return { kept, removed };
|
|
|
|
const logKeys = new Set();
|
|
const logMaxTs = new Map();
|
|
for (const e of (Array.isArray(logEntries) ? logEntries : [])) {
|
|
if (e && e.fileName && e.hoster) {
|
|
const k = _key(e.fileName, e.hoster);
|
|
logKeys.add(k);
|
|
if (typeof e.ts === 'number' && isFinite(e.ts)) {
|
|
const prev = logMaxTs.get(k);
|
|
if (prev === undefined || e.ts > prev) logMaxTs.set(k, e.ts);
|
|
}
|
|
}
|
|
}
|
|
|
|
const savedAtFloor = (typeof savedAt === 'number' && isFinite(savedAt))
|
|
? Math.floor(savedAt / 1000) * 1000
|
|
: null;
|
|
|
|
for (const job of jobs) {
|
|
const hasIds = job && job.fileName && job.hoster;
|
|
const k = hasIds ? _key(job.fileName, job.hoster) : null;
|
|
const doneInLog = job && job.status === 'done' && hasIds && logKeys.has(k);
|
|
const uploadedAfterSnapshot = savedAtFloor !== null && k !== null
|
|
&& logMaxTs.has(k) && logMaxTs.get(k) >= savedAtFloor;
|
|
if (doneInLog || uploadedAfterSnapshot) {
|
|
removed.push(job);
|
|
} else {
|
|
kept.push(job);
|
|
}
|
|
}
|
|
return { kept, removed };
|
|
}
|
|
|
|
const api = { partitionRestoredJobsByLog };
|
|
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = api;
|
|
} else if (root) {
|
|
root.QueueDedup = api;
|
|
}
|
|
})(typeof window !== 'undefined' ? window : this);
|