feat(audit): Batch 15 live-verify — download-engine lifecycle (R4, L65, L139, B2, B6, L143)
The watched live session for the six deferred lifecycle items, each verified against real yt-dlp/Electron behavior on Windows: - R4: cancel now deletes the download's orphaned partials. The engine captures the final output path via a new `--print before_dl:dest|%(filename)s` (empirically %(filepath)s is still 'NA' that early) and the close handler sweeps only unambiguous intermediates (.part / .part-Frag* / .ytdl / <stem>.fNNN.<ext>) via the pure, unit-tested lib/partials.ts — never a bare <stem>.<ext> or another download's files. Live-verified with decoys. - L65: verified-acceptable, no code change — a taskkill /F pause leaves the .part byte-intact and yt-dlp --continue resumes at the exact byte offset. - L139: spawn↔self-update interlock (new ytdlpLock.ts). The updater refuses while any yt-dlp spawn is live; download/terminal IPC handlers hold (await) behind an in-progress exe rewrite instead of failing. - B2: source indexing is cancellable — AbortSignal through the probe walk (kills the in-flight child, live-verified via tasklist), sources:index-cancel IPC, and a Cancel button in the Library add-source row. - B6: app-update download is cancellable — app:update-cancel routes through the existing finish() teardown (abort + destroy + unlink, live-verified on Electron net.request); Cancel button under the update progress bar. The checksum phase is cancelable too. - L143: closing the window mid-download (non-tray mode) now offers a native tray-independent "Quit anyway / Keep downloading" dialog, replacing the passive "use the tray to quit" notification. Peer-review pass caught and fixed: a non-reentrant update lock (concurrent begin clobbered the settle promise), a dead Cancel window during the B6 checksum fetch + a canceller slot-ownership bug (L140 shape), and a persist-after-cancel hole in the index walk. typecheck + 304 tests + eslint + production build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Interlock between the yt-dlp self-update (which REWRITES the managed yt-dlp.exe)
|
||||
* and the download / terminal spawns (which EXECUTE it). On Windows, spawning the
|
||||
* exe while `--update-to` is swapping it in place races into a sharing violation or
|
||||
* launches a half-written binary (L139). This serialises the two:
|
||||
*
|
||||
* - each live yt-dlp process registers via acquireSpawn()/releaseSpawn();
|
||||
* - the updater calls beginYtdlpUpdate(), which REFUSES (returns false) when any
|
||||
* spawn is live — the update is best-effort and daily-throttled, so it simply
|
||||
* skips this cycle rather than rewrite the exe out from under a running process;
|
||||
* - a spawn that arrives while an update holds the lock is HELD (not failed): the
|
||||
* IPC layer awaits whenYtdlpUpdateSettled() before spawning, so the download just
|
||||
* waits out the brief rewrite instead of erroring. isYtdlpUpdating() is the
|
||||
* synchronous backstop for any caller that doesn't await.
|
||||
*
|
||||
* The main process is single-threaded, so these plain counters/flags need no real
|
||||
* locking: a check-then-act sequence (e.g. await whenYtdlpUpdateSettled() → spawn →
|
||||
* acquireSpawn) runs with no await between the settle and the spawn, so no update can
|
||||
* interleave. Pure (no electron/node chain) so it's unit-testable in isolation.
|
||||
*/
|
||||
let updating = false
|
||||
let liveSpawns = 0
|
||||
// Resolved by endYtdlpUpdate(); recreated per update so a held spawn wakes exactly
|
||||
// when the current rewrite finishes. Only meaningful while `updating` is true.
|
||||
let settle: (() => void) | null = null
|
||||
let settledPromise: Promise<void> = Promise.resolve()
|
||||
|
||||
/** True while an update is rewriting yt-dlp.exe — synchronous spawn backstop. */
|
||||
export function isYtdlpUpdating(): boolean {
|
||||
return updating
|
||||
}
|
||||
|
||||
/** Register a live yt-dlp process — call immediately after a successful spawn. */
|
||||
export function acquireSpawn(): void {
|
||||
liveSpawns++
|
||||
}
|
||||
|
||||
/** Deregister a yt-dlp process on settle (close/error). Never drops below zero. */
|
||||
export function releaseSpawn(): void {
|
||||
if (liveSpawns > 0) liveSpawns--
|
||||
}
|
||||
|
||||
/** How many yt-dlp processes are currently live (test seam / diagnostics). */
|
||||
export function liveSpawnCount(): number {
|
||||
return liveSpawns
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once no update is in progress — used to HOLD a spawn until the exe swap
|
||||
* finishes, rather than refuse it. Returns an already-resolved promise when idle, so
|
||||
* the common (non-updating) path adds no delay. Bounded by the caller's update
|
||||
* timeout: endYtdlpUpdate() always runs (finally), so this can't hang forever.
|
||||
*/
|
||||
export function whenYtdlpUpdateSettled(): Promise<void> {
|
||||
return updating ? settledPromise : Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Enter the update critical section. Returns false — and does NOT take the lock — if
|
||||
* any spawn is live OR another update already holds the lock, in which case the
|
||||
* caller must not update. The updating check matters: a second concurrent begin
|
||||
* (startup auto-update racing a manual "Update now") would otherwise replace the
|
||||
* settle promise, letting the FIRST update's end() release spawns held by the
|
||||
* second one mid-rewrite. On true the caller owns the lock until endYtdlpUpdate().
|
||||
*/
|
||||
export function beginYtdlpUpdate(): boolean {
|
||||
if (updating || liveSpawns > 0) return false
|
||||
updating = true
|
||||
settledPromise = new Promise((resolve) => {
|
||||
settle = resolve
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
/** Leave the update critical section, waking any spawn held on whenYtdlpUpdateSettled(). */
|
||||
export function endYtdlpUpdate(): void {
|
||||
updating = false
|
||||
settle?.()
|
||||
settle = null
|
||||
}
|
||||
Reference in New Issue
Block a user