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
+43 -4
View File
@@ -199,6 +199,19 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
/** How long the download may stall (no bytes received) before we give up. */
const DOWNLOAD_IDLE_TIMEOUT_MS = 60_000
// The in-flight installer download's canceller, set while a download runs and
// cleared on settle. During the pre-download checksum phase it just flags the
// cancel; during the download phase it routes through `finish()` (abort the
// request + clean up the partial). Each phase clears the slot only if it still
// owns it, so a superseded download's late settle can't knock out a newer
// download's canceller (same ownership rule as L140's releaseActive). (B6)
let cancelActiveDownload: (() => void) | null = null
/** Abort the in-flight app-update installer download, if any (the update card's Cancel). */
export function cancelAppUpdate(): void {
cancelActiveDownload?.()
}
// Integrity: every release MUST attach a checksum asset named `<installer>.sha256`
// (e.g. AeroFetch-Setup-0.5.0.exe.sha256) whose contents include the installer's
// lowercase SHA-256 hex — bare, or in `sha256sum`/`Get-FileHash` form. We refuse
@@ -280,20 +293,37 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
u.pathname += '.sha256'
return u.toString()
})()
// The renderer's Cancel button is live from the moment this is invoked, so the
// checksum phase must be cancelable too — without this, a Cancel during the
// fetch is a silent no-op and the full installer download proceeds anyway (B6).
// This early canceller only flags; nothing is on disk yet, so there's no teardown.
let canceledEarly = false
const earlyCancel = (): void => {
canceledEarly = true
}
cancelActiveDownload = earlyCancel
const settleEarly = (result: AppUpdateDownload): AppUpdateDownload => {
if (cancelActiveDownload === earlyCancel) cancelActiveDownload = null
return result
}
const sum = await fetchTrustedText(checksumUrl)
if (canceledEarly) return settleEarly({ ok: false, error: 'Update download canceled.' })
let expectedSha: string | null = null
if (sum.ok) {
expectedSha = extractSha256(sum.text, safeName)
if (!expectedSha) return { ok: false, error: "The update's checksum file is malformed." }
if (!expectedSha) {
return settleEarly({ ok: false, error: "The update's checksum file is malformed." })
}
} else if (sum.status === 404) {
if (REQUIRE_CHECKSUM) {
return {
return settleEarly({
ok: false,
error: `This release has no checksum (${safeName}.sha256) attached — refusing to install an unverified update.`
}
})
}
} else {
return { ok: false, error: `Couldn't fetch the update's checksum — ${sum.error}.` }
return settleEarly({ ok: false, error: `Couldn't fetch the update's checksum — ${sum.error}.` })
}
return new Promise<AppUpdateDownload>((resolve) => {
@@ -307,6 +337,8 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
const finish = (result: AppUpdateDownload): void => {
if (settled) return
settled = true
// Done — release the cancel slot, but only if this download still owns it (B6).
if (cancelActiveDownload === requestCancel) cancelActiveDownload = null
if (idle) clearTimeout(idle)
if (!result.ok) {
request.abort()
@@ -318,6 +350,13 @@ export async function downloadAppUpdate(url: string, wc: WebContents): Promise<A
resolve(result)
}
// Expose this download's abort so cancelAppUpdate() (the update card's Cancel)
// can route through the same teardown — request.abort() + partial cleanup.
// Named so finish() can release the slot only when this download still owns it,
// and replaces the checksum-phase earlyCancel above (ownership moves here). (B6)
const requestCancel = (): void => finish({ ok: false, error: 'Update download canceled.' })
cancelActiveDownload = requestCancel
// net.request (not fetch) so we can re-validate the host on EVERY redirect
// hop: Gitea 302s a release download to its attachment store, and undici's
// fetch hides the Location header under redirect:'manual', so it can't gate