Fix C1/C2 + reliability cluster (L140/L141/L148, R6/R7)

Critical:
- C1: single source of truth for IPC mock + Settings defaults. DEFAULT_SETTINGS
  in @shared/ipc; main DEFAULTS, renderer FALLBACK, and preview MOCK_SETTINGS all
  derive from it. Whole browser mock collapsed into one typed mockApi.ts; main.tsx
  shrinks to window.api = mockApi. PREVIEW check single-sourced in isPreview.ts.
- C2: break the downloads<->sources circular import via a typed event bus
  (store/coordinator.ts). App eagerly imports sources for side-effects so startup
  load + scheduled --sync + downloadCompleted subscription still run.

Reliability:
- L140/L148: cancel/pause release the active slot synchronously; identity-guarded
  releaseActive; per-spawn cookie jar so a same-id retry/resume is never rejected
  by a stale entry nor run over the concurrency cap.
- L141: renderer history capped at 500 + url de-dup to match main.
- R6: writeJsonAtomic reports/logs write failures and keeps data dirty for retry
  instead of an empty catch.
- R7: encryptSecret warns before the plaintext fallback.

typecheck + 243 tests + eslint green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 20:25:49 -04:00
parent 6c19899f75
commit 7e3e5af52d
20 changed files with 596 additions and 413 deletions
+34 -5
View File
@@ -47,6 +47,21 @@ interface ActiveDownload {
const active = new Map<string, ActiveDownload>()
// Per-spawn sequence, so a retry/resume that reuses the item id still gets a
// unique transient cookie file (below) — otherwise a superseded download's
// teardown could unlink the new spawn's jar mid-read.
let spawnSeq = 0
// Remove an item from the active map, but only if THIS rec still owns the slot.
// cancel/pause release the slot synchronously (so the renderer's just-promoted
// next item isn't rejected by the maxConcurrent guard while the killed tree is
// still tearing down); the doomed child's later 'close'/'error' then runs this as
// a no-op. The identity check matters because a retry/resume reuses the item id,
// so a stale teardown must not evict the newer same-id spawn. (L140/L148)
function releaseActive(id: string, rec: ActiveDownload): void {
if (active.get(id) === rec) active.delete(id)
}
// Backstop for B1: if a spawned yt-dlp goes completely silent — no stdout or
// stderr — for this long, treat it as wedged, kill it, and error the item so the
// concurrency slot frees instead of leaking forever. Generous on purpose so a
@@ -269,7 +284,9 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
// settles, so the plaintext never lingers at rest.
let cookiesFile: string | undefined
if (getSettings().cookieSource === 'login' && hasStoredCookies()) {
const tmp = join(tmpdir(), `aerofetch-cookies-${opts.id}.txt`)
// Unique per spawn (not just per id): a retry/resume reuses opts.id, and the
// superseded download's cleanupCookies() must not unlink the new spawn's jar.
const tmp = join(tmpdir(), `aerofetch-cookies-${opts.id}-${++spawnSeq}.txt`)
if (materializeCookies(tmp)) cookiesFile = tmp
}
function cleanupCookies(): void {
@@ -337,11 +354,14 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
function bumpWatchdog(): void {
clearWatchdog()
stallTimer = setTimeout(() => {
if (settled) return
// A cancel/pause races the timer: it already released the slot and the
// child's close stays silent, so don't fire a spurious stall error (mirrors
// the canceled/paused guards on the close handler below).
if (settled || rec.canceled || rec.paused) return
settled = true
clearWatchdog()
cleanupCookies()
active.delete(opts.id)
releaseActive(opts.id, rec)
killTree(rec)
const msg = `Download stalled — no activity for ${Math.round(STALL_TIMEOUT_MS / 60_000)} min. Stopped; retry to resume.`
send(wc, { type: 'error', id: opts.id, error: msg })
@@ -385,7 +405,7 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
settled = true
clearWatchdog()
cleanupCookies()
active.delete(opts.id)
releaseActive(opts.id, rec)
// A paused download was killed on purpose — stay silent, like a cancel.
if (!rec.canceled && !rec.paused) {
send(wc, { type: 'error', id: opts.id, error: err.message })
@@ -399,7 +419,7 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
settled = true
clearWatchdog()
cleanupCookies()
active.delete(opts.id)
releaseActive(opts.id, rec)
// Canceled: renderer already showed 'canceled'. Paused: renderer showed
// 'paused' and keeps the .part for a later resume. Either way, no event.
if (rec.canceled || rec.paused) return
@@ -438,6 +458,11 @@ export function cancelDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
rec.canceled = true
// Free the slot now (not on the async 'close') so the renderer's just-promoted
// next item isn't rejected by the maxConcurrent guard — or run briefly over the
// cap — while taskkill tears down this tree. The child's later 'close' stays
// silent (rec.canceled) and releaseActive() no-ops. (L148)
active.delete(id)
killTree(rec)
}
@@ -452,5 +477,9 @@ export function pauseDownload(id: string): void {
const rec = active.get(id)
if (!rec) return
rec.paused = true
// Free the slot now (see cancelDownload / L148). The partial .part stays on disk
// for a later resume, which reuses this id — releaseActive()'s identity check
// keeps the doomed child's 'close' from evicting that fresh spawn. (L140)
active.delete(id)
killTree(rec)
}