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:
+70
-6
@@ -1,7 +1,7 @@
|
||||
import { spawn, execFile, type ChildProcess } from 'child_process'
|
||||
import { existsSync, unlinkSync } from 'fs'
|
||||
import { existsSync, unlinkSync, readdirSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { join, parse } from 'path'
|
||||
import { BrowserWindow, Notification, type WebContents } from 'electron'
|
||||
import {
|
||||
getYtdlpPath,
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
import { getSettings } from './settings'
|
||||
import { execFileAsync } from './lib/exec'
|
||||
import { createLineBuffer } from './lib/lineBuffer'
|
||||
import { orphanPartials } from './lib/partials'
|
||||
import { isYtdlpUpdating, acquireSpawn, releaseSpawn } from './ytdlpLock'
|
||||
import { getDownloadArchivePath, getDefaultMediaDir } from './paths'
|
||||
import { ensureManagedYtdlp } from './ytdlp'
|
||||
import { materializeCookies, hasStoredCookies } from './cookies'
|
||||
@@ -28,7 +30,8 @@ import {
|
||||
formatCommandLine,
|
||||
collectionOutputTemplate,
|
||||
PROGRESS_MARKER,
|
||||
FILEPATH_MARKER
|
||||
FILEPATH_MARKER,
|
||||
DEST_MARKER
|
||||
} from './buildArgs'
|
||||
import { cleanError } from './log'
|
||||
import { addErrorLog } from './errorlog'
|
||||
@@ -288,6 +291,16 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
|
||||
if (active.size >= maxConcurrent) {
|
||||
return { ok: false, error: 'Max concurrent downloads reached. Wait for a slot to free up.' }
|
||||
}
|
||||
// L139: don't launch the managed yt-dlp while a self-update is rewriting the exe —
|
||||
// on Windows that races into a sharing violation or a half-written binary. The
|
||||
// updater backs off whenever a download is live and only holds the lock for the
|
||||
// brief rewrite, so this is a rare, retryable refusal.
|
||||
if (isYtdlpUpdating()) {
|
||||
return {
|
||||
ok: false,
|
||||
error: 'yt-dlp is updating in the background — try this download again in a moment.'
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt the stored cookie jar to a short-lived per-download temp file (H7).
|
||||
// yt-dlp reads --cookies once at startup; we delete it the moment the download
|
||||
@@ -319,6 +332,9 @@ export function startDownload(wc: WebContents, opts: StartDownloadOptions): Star
|
||||
cleanupCookies()
|
||||
return { ok: false, error: (e as Error).message }
|
||||
}
|
||||
// L139: a live yt-dlp process now exists — the updater must defer to it until the
|
||||
// matching releaseSpawn() on settle (close/error/stall) below.
|
||||
acquireSpawn()
|
||||
|
||||
const rec: ActiveDownload = { child, canceled: false, paused: false }
|
||||
active.set(opts.id, rec)
|
||||
@@ -375,6 +391,10 @@ function wireChildProcess(params: {
|
||||
|
||||
let stderrTail = ''
|
||||
let filePath: string | undefined
|
||||
// The resolved FINAL output path (from the before_dl `dest|` print), captured
|
||||
// while the download is still running so a cancel can delete this download's
|
||||
// orphaned partials by stem (R4). Undefined until the first stream starts.
|
||||
let outputPath: string | undefined
|
||||
// Latched once the first download stream reports 'finished'; flags later
|
||||
// progress as the merge/post-processing "finishing" phase (SR7).
|
||||
let finishing = false
|
||||
@@ -401,6 +421,7 @@ function wireChildProcess(params: {
|
||||
clearWatchdog()
|
||||
cleanup()
|
||||
releaseActive(opts.id, rec)
|
||||
releaseSpawn()
|
||||
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 })
|
||||
@@ -432,6 +453,10 @@ function wireChildProcess(params: {
|
||||
}
|
||||
} else if (line.startsWith(FILEPATH_MARKER)) {
|
||||
filePath = line.slice(FILEPATH_MARKER.length).trim()
|
||||
} else if (line.startsWith(DEST_MARKER)) {
|
||||
// before_dl fires once per stream; every emission carries the same final
|
||||
// path, so the last-write-wins overwrite is harmless.
|
||||
outputPath = line.slice(DEST_MARKER.length).trim()
|
||||
}
|
||||
})
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
@@ -450,6 +475,10 @@ function wireChildProcess(params: {
|
||||
clearWatchdog()
|
||||
cleanup()
|
||||
releaseActive(opts.id, rec)
|
||||
releaseSpawn()
|
||||
// A canceled download's partials are now orphans — remove them (R4). A paused
|
||||
// download was also killed on purpose, but keeps its .part for a later resume.
|
||||
if (rec.canceled) cleanupPartials(outputPath)
|
||||
// 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 })
|
||||
@@ -469,9 +498,16 @@ function wireChildProcess(params: {
|
||||
clearWatchdog()
|
||||
cleanup()
|
||||
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
|
||||
releaseSpawn()
|
||||
// Canceled: renderer already showed 'canceled'; the process is now gone (its
|
||||
// file handles released), so delete this download's orphaned partials (R4).
|
||||
// Paused: renderer showed 'paused' and KEEPS the .part for a later resume.
|
||||
// Either way, no event.
|
||||
if (rec.canceled) {
|
||||
cleanupPartials(outputPath)
|
||||
return
|
||||
}
|
||||
if (rec.paused) return
|
||||
if (code === 0) {
|
||||
send(wc, { type: 'done', id: opts.id, filePath })
|
||||
notify(
|
||||
@@ -511,6 +547,34 @@ function killTree(rec: ActiveDownload): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a canceled download's orphaned intermediate files (R4). `outputPath` is the
|
||||
* resolved FINAL destination captured from the before_dl `dest|` print; orphanPartials
|
||||
* (pure, unit-tested in lib/partials.ts) turns its dir listing into the exact set of
|
||||
* this download's `.part` / `.part-Frag*` / `.ytdl` / `.fNNN.<ext>` intermediates —
|
||||
* never a completed `<stem>.<ext>` or another download's files. Best-effort: a
|
||||
* still-locked or already-gone entry is skipped. Called from the child's close/error
|
||||
* handler, so the process (and its file handles) are already gone.
|
||||
*/
|
||||
function cleanupPartials(outputPath: string | undefined): void {
|
||||
if (!outputPath) return
|
||||
const { dir } = parse(outputPath)
|
||||
if (!dir) return
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
} catch {
|
||||
return // folder vanished / unreadable — nothing to clean
|
||||
}
|
||||
for (const entry of orphanPartials(outputPath, entries)) {
|
||||
try {
|
||||
unlinkSync(join(dir, entry))
|
||||
} catch {
|
||||
/* best-effort: locked or already removed */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function cancelDownload(id: string): void {
|
||||
const rec = active.get(id)
|
||||
if (!rec) return
|
||||
|
||||
Reference in New Issue
Block a user