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:
+34
-5
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user