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>
57 lines
2.5 KiB
JavaScript
57 lines
2.5 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert');
|
|
const { selectOrphanTmps } = require('../lib/orphan-tmp');
|
|
|
|
const BASE = 'electron-config.json';
|
|
const aliveSet = new Set([100, 200]);
|
|
const isAlive = (pid) => aliveSet.has(pid);
|
|
|
|
test('selects only dead-pid <base>.<pid>.tmp orphans', () => {
|
|
const files = [
|
|
'electron-config.json',
|
|
'electron-config.json.bak',
|
|
'electron-config.json.tmp',
|
|
'electron-config.json.100.tmp',
|
|
'electron-config.json.200.tmp',
|
|
'electron-config.json.999.tmp',
|
|
'electron-config.json.4242.tmp',
|
|
'something-else.500.tmp',
|
|
'electron-config.json.abc.tmp'
|
|
];
|
|
const orphans = selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive });
|
|
assert.deepEqual(orphans.sort(), ['electron-config.json.4242.tmp', 'electron-config.json.999.tmp']);
|
|
});
|
|
|
|
test('never selects the current process tmp', () => {
|
|
const files = ['electron-config.json.7.tmp'];
|
|
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
|
|
});
|
|
|
|
test('never selects the FIXED <base>.tmp (used by async _atomicWrite)', () => {
|
|
const files = ['electron-config.json.tmp'];
|
|
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
|
|
});
|
|
|
|
test('never selects the live config or its .bak', () => {
|
|
const files = ['electron-config.json', 'electron-config.json.bak'];
|
|
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
|
|
});
|
|
|
|
test('alive pid (incl. EPERM-as-alive) is skipped, preventing deletion of a concurrent instance tmp', () => {
|
|
const files = ['electron-config.json.100.tmp', 'electron-config.json.300.tmp'];
|
|
const orphans = selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: (p) => p === 100 });
|
|
assert.deepEqual(orphans, ['electron-config.json.300.tmp']);
|
|
});
|
|
|
|
test('robust to junk / missing inputs', () => {
|
|
assert.deepEqual(selectOrphanTmps(null, { baseName: BASE, currentPid: 1, isAlive }), []);
|
|
assert.deepEqual(selectOrphanTmps(['x', 42, null, undefined], { baseName: BASE, currentPid: 1, isAlive }), []);
|
|
assert.deepEqual(selectOrphanTmps(['electron-config.json.5.tmp'], {}), []);
|
|
assert.deepEqual(selectOrphanTmps(['electron-config.json.5.tmp'], { baseName: '', currentPid: 1, isAlive }), []);
|
|
});
|
|
|
|
test('does not match a different base that shares a prefix', () => {
|
|
const files = ['electron-config.json.backup.5.tmp'];
|
|
assert.deepEqual(selectOrphanTmps(files, { baseName: BASE, currentPid: 7, isAlive: () => false }), []);
|
|
});
|