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:
2026-07-01 21:36:22 -04:00
parent b3e5e4f309
commit e19f988c72
20 changed files with 737 additions and 98 deletions
+9
View File
@@ -13,6 +13,7 @@ import { type WebContents } from 'electron'
import { getYtdlpPath, getBinDir, getSystem32Path } from './binaries'
import { getSettings } from './settings'
import { ensureManagedYtdlp } from './ytdlp'
import { isYtdlpUpdating, acquireSpawn, releaseSpawn } from './ytdlpLock'
import { parseExtraArgs } from './buildArgs'
import { createLineBuffer } from './lib/lineBuffer'
import { IpcChannels, type TerminalEvent, type TerminalRunResult } from '@shared/ipc'
@@ -36,6 +37,11 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
return { ok: false, error: 'yt-dlp.exe is missing and could not be restored.' }
}
if (active.has(id)) return { ok: false, error: 'A command is already running.' }
// L139: same exe-rewrite interlock as downloads — don't launch yt-dlp while a
// self-update is replacing the binary.
if (isYtdlpUpdating()) {
return { ok: false, error: 'yt-dlp is updating — try again in a moment.' }
}
// Always pin ffmpeg so post-processing works; the rest is whatever the user typed.
const args = ['--ffmpeg-location', getBinDir(), ...parseExtraArgs(argsRaw)]
@@ -46,6 +52,7 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
return { ok: false, error: (e as Error).message }
}
active.set(id, child)
acquireSpawn() // L139: live yt-dlp process — the updater must defer to it
// Buffer each stream and emit on newline boundaries (flush the remainder on close).
const lineBufs = {
@@ -60,12 +67,14 @@ export function runTerminal(wc: WebContents, id: string, argsRaw: string): Termi
if (settled) return
settled = true
active.delete(id)
releaseSpawn()
send(wc, { type: 'error', id, error: err.message })
})
child.on('close', (code) => {
if (settled) return
settled = true
active.delete(id)
releaseSpawn()
// Flush any trailing partial lines that never hit a newline.
lineBufs.stdout.flush()
lineBufs.stderr.flush()