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:
+43
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user