fix(queue): close three more ghost / lost-work holes found by a second adversarial hunt

A 36-agent adversarial sweep over the queue-persistence context surfaced three
NEW, reachable defects (all renderer-only) that the v3.3.80-82 work missed:

#1 retrySelectedJobs (the reuploadBtn / "erneut hochladen" path) is a THIRD
   re-add path that never cleared the completed-dedup guard. _completedUploadKeys
   gains file|hoster when a job reaches done; retry of a done row reset the job to
   pending and re-added its path to selectedFiles but kept the key. On a restart
   before the re-upload re-completes, restoreQueueStateFromConfig re-seeds the key
   and buildQueuePreview then refuses to recreate the row, so the deliberate
   re-upload silently vanished (lost work). Retry now clears the exact file|hoster
   keys it re-activates (per key, NOT per path, so a sibling hosters completed
   ghost is not resurrected).

#7 The folder-monitor pre-selected-hosters branch re-adds a path and calls
   buildQueuePreview WITHOUT going through applyHosterSelection, so the v3.3.82
   key-clear never ran. With removeFromQueueOnDone ON, a re-encoded / re-dropped
   file whose row was auto-removed was suppressed forever (silent no-upload that
   even survived a restart). The branch now clears the dedup keys for the freshly
   re-added paths, matching the manual modal flow.

#4 Deleting one hosters row of a multi-hoster file was silently undone. A
   manually deleted non-completed file|hoster pair is recreated by buildQueuePreview
   the next time it runs (adding another file, or a folder-monitor drop), because
   the recreate guard only consulted _completedUploadKeys, never the per-cell
   deletion. Result: the file got uploaded to a hoster the user explicitly
   removed, burning quota and publishing an unwanted link. Introduces
   _suppressedPreviewKeys: a manual delete of a non-done job whose file stays
   selected (pinned by a sibling job) suppresses that exact file|hoster from
   re-creation; the suppression is persisted in pendingQueue.suppressedKeys and
   re-seeded on restore (mirroring completedKeys), and is cleared whenever the
   user deliberately re-adds the file (modal or folder monitor) so a real re-add
   still works.

The clear-on-re-add logic for both sets is unified into clearDedupKeysForPaths()
and reused by applyHosterSelection and the folder-monitor branch.

Held for a separate decision/round (surfaced to the user): single-instance lock,
the sync-close write-sequence guard (its ghost symptom is masked by the existing
microtask-ordered removal + restore-time ts-gate; its real residual is rotation/
history integrity), and the upload-log timezone/same-basename edges (need a log
format change). 362 tests pass, lint clean, smoke boots identically to baseline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Administrator 2026-06-19 16:19:47 +02:00
parent 1b0be5f817
commit b1ad04d9c2

View File

@ -71,6 +71,7 @@ let _sessionUploadedBytes = 0; // Bytes fully uploaded this session (done jobs)
const _sessionTrackedJobs = new Set(); // Job IDs already counted for totalBytes const _sessionTrackedJobs = new Set(); // Job IDs already counted for totalBytes
const _sessionDoneJobs = new Set(); // Job IDs already counted for uploadedBytes const _sessionDoneJobs = new Set(); // Job IDs already counted for uploadedBytes
const _completedUploadKeys = new Set(); // 'filepath|hoster' keys for done uploads (survives removeFromQueueOnDone) const _completedUploadKeys = new Set(); // 'filepath|hoster' keys for done uploads (survives removeFromQueueOnDone)
const _suppressedPreviewKeys = new Set();
const _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks) const _deletedJobIds = new Set(); // IDs of jobs explicitly deleted by user (prevents re-creation from stale progress callbacks)
// Coalesce removeFromQueueOnDone removals into one filter pass per microtask // Coalesce removeFromQueueOnDone removals into one filter pass per microtask
// to avoid O(N²) behaviour when a burst of jobs finish at once. Logic now // to avoid O(N²) behaviour when a burst of jobs finish at once. Logic now
@ -215,6 +216,7 @@ async function init() {
} }
if (newFiles.length > 0) { if (newFiles.length > 0) {
const newPaths = new Set(newFiles.map(f => f.path)); const newPaths = new Set(newFiles.map(f => f.path));
clearDedupKeysForPaths(newPaths);
selectedFiles.push(...newFiles); selectedFiles.push(...newFiles);
buildQueuePreview(); buildQueuePreview();
updateUploadView(); updateUploadView();
@ -671,12 +673,7 @@ function applyHosterSelection() {
selectedFiles.push(..._pendingFiles); selectedFiles.push(..._pendingFiles);
_pendingFiles = []; _pendingFiles = [];
} }
if (pendingPaths.size > 0) { clearDedupKeysForPaths(pendingPaths);
for (const key of [..._completedUploadKeys]) {
const sep = key.lastIndexOf('|');
if (sep > 0 && pendingPaths.has(key.slice(0, sep))) _completedUploadKeys.delete(key);
}
}
renderHosterSummary(); renderHosterSummary();
// During an active upload, build preview jobs for the new files and inject // During an active upload, build preview jobs for the new files and inject
@ -725,6 +722,12 @@ function restoreQueueStateFromConfig() {
} }
} }
if (Array.isArray(pending.suppressedKeys)) {
for (const k of pending.suppressedKeys) {
if (typeof k === 'string' && k) _suppressedPreviewKeys.add(k);
}
}
selectedUploadHosters = Array.isArray(pending.selectedUploadHosters) selectedUploadHosters = Array.isArray(pending.selectedUploadHosters)
? pending.selectedUploadHosters.filter(Boolean) ? pending.selectedUploadHosters.filter(Boolean)
: selectedUploadHosters; : selectedUploadHosters;
@ -802,11 +805,17 @@ function buildPersistedQueueState() {
const sep = k.lastIndexOf('|'); const sep = k.lastIndexOf('|');
if (sep > 0 && selectedFileMap.has(k.slice(0, sep))) completedKeys.push(k); if (sep > 0 && selectedFileMap.has(k.slice(0, sep))) completedKeys.push(k);
} }
const suppressedKeys = [];
for (const k of _suppressedPreviewKeys) {
const sep = k.lastIndexOf('|');
if (sep > 0 && selectedFileMap.has(k.slice(0, sep))) suppressedKeys.push(k);
}
return { return {
savedAt: Date.now(), savedAt: Date.now(),
selectedUploadHosters: getSelectedHosters(), selectedUploadHosters: getSelectedHosters(),
selectedFiles: Array.from(selectedFileMap.values()), selectedFiles: Array.from(selectedFileMap.values()),
completedKeys, completedKeys,
suppressedKeys,
queueJobs: queueJobs.map(job => { queueJobs: queueJobs.map(job => {
const isTerminal = TERMINAL.has(job.status); const isTerminal = TERMINAL.has(job.status);
return { return {
@ -1062,6 +1071,25 @@ function updateQueueActionButtons() {
if (moveBottomBtn) moveBottomBtn.disabled = !hasMovableSelection; if (moveBottomBtn) moveBottomBtn.disabled = !hasMovableSelection;
} }
function clearDedupKeysForPaths(pathSet) {
if (!pathSet || pathSet.size === 0) return;
for (const set of [_completedUploadKeys, _suppressedPreviewKeys]) {
for (const key of [...set]) {
const sep = key.lastIndexOf('|');
if (sep > 0 && pathSet.has(key.slice(0, sep))) set.delete(key);
}
}
}
function suppressPreviewKeysStillSelected(keys) {
if (!keys || keys.length === 0) return;
const stillSelected = new Set(selectedFiles.map(f => f.path));
for (const key of keys) {
const sep = key.lastIndexOf('|');
if (sep > 0 && stillSelected.has(key.slice(0, sep))) _suppressedPreviewKeys.add(key);
}
}
// Build preview jobs from selected files x selected hosters (before upload starts) // Build preview jobs from selected files x selected hosters (before upload starts)
function buildQueuePreview() { function buildQueuePreview() {
const hosters = getSelectedHosters(); const hosters = getSelectedHosters();
@ -1084,7 +1112,7 @@ function buildQueuePreview() {
for (const file of selectedFiles) { for (const file of selectedFiles) {
for (const hoster of hosters) { for (const hoster of hosters) {
const key = `${file.path}|${hoster}`; const key = `${file.path}|${hoster}`;
if (!existingKeys.has(key) && !_completedUploadKeys.has(key)) { if (!existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key)) {
const job = { const job = {
id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
file: file.path, fileName: file.name, hoster, file: file.path, fileName: file.name, hoster,
@ -1870,12 +1898,18 @@ document.addEventListener('keydown', (e) => {
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) window.api.cancelSelectedJobs(activeIds); if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds);
const _deletedKeys = [];
queueJobs = queueJobs.filter(j => { queueJobs = queueJobs.filter(j => {
if (selectedJobIds.has(j.id)) { removeJobFromIndex(j); return false; } if (selectedJobIds.has(j.id)) {
if (j.file && j.hoster && j.status !== 'done') _deletedKeys.push(`${j.file}|${j.hoster}`);
removeJobFromIndex(j);
return false;
}
return true; return true;
}); });
selectedJobIds.clear(); selectedJobIds.clear();
syncSelectedFilesFromQueue(); syncSelectedFilesFromQueue();
suppressPreviewKeysStillSelected(_deletedKeys);
renderQueueTable(); renderQueueTable();
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
updateStatusBar(); updateStatusBar();
@ -1911,8 +1945,10 @@ 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) window.api.cancelSelectedJobs(activeIds); if (activeIds.length > 0) window.api.cancelSelectedJobs(activeIds);
const _deletedKeys = [];
queueJobs = queueJobs.filter(j => { queueJobs = queueJobs.filter(j => {
if (selectedJobIds.has(j.id)) { if (selectedJobIds.has(j.id)) {
if (j.file && j.hoster && j.status !== 'done') _deletedKeys.push(`${j.file}|${j.hoster}`);
removeJobFromIndex(j); removeJobFromIndex(j);
return false; return false;
} }
@ -1920,6 +1956,7 @@ async function handleContextAction(action) {
}); });
selectedJobIds.clear(); selectedJobIds.clear();
syncSelectedFilesFromQueue(); syncSelectedFilesFromQueue();
suppressPreviewKeysStillSelected(_deletedKeys);
renderQueueTable(); renderQueueTable();
if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); } if (queueJobs.length === 0) { selectedFiles = []; updateUploadView(); }
updateStatusBar(); updateStatusBar();
@ -2604,6 +2641,12 @@ async function retrySelectedJobs() {
} }
}); });
if (retryJobs.length === 0) return; if (retryJobs.length === 0) return;
for (const j of retryJobs) {
if (j.file && j.hoster) {
_completedUploadKeys.delete(`${j.file}|${j.hoster}`);
_suppressedPreviewKeys.delete(`${j.file}|${j.hoster}`);
}
}
// Select the retry jobs and start them immediately. // Select the retry jobs and start them immediately.
// No renderQueueTable / updateQueueActionButtons / updateStatusBar here: // No renderQueueTable / updateQueueActionButtons / updateStatusBar here: