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
+10
View File
@@ -68,6 +68,12 @@ function audioQuality(quality: string): string {
// break the parser that splits on it (CL1).
export const PROGRESS_MARKER = 'prog|'
export const FILEPATH_MARKER = 'path|'
// Emitted once at the before_dl stage carrying the resolved FINAL output path, so
// the engine knows the destination stem while the download is still in flight —
// used to delete orphaned .part / .fNNN intermediates when a download is canceled
// (R4). `%(filepath)s` is still unresolved this early (prints 'NA'); `%(filename)s`
// already resolves to the full output path.
export const DEST_MARKER = 'dest|'
// The progress line yt-dlp emits (one per --newline tick). Note the leading
// `download:` is the progress-template TYPE selector and is consumed by yt-dlp
@@ -415,6 +421,10 @@ export function buildArgs(input: BuildArgsInput): string[] {
// Print the final path after post-processing/move so we can open it later.
'--print',
`after_move:${FILEPATH_MARKER}%(filepath)s`,
// Print the resolved destination up front (before_dl) so a cancel can find and
// delete this download's orphaned partials by stem, mid-flight (R4).
'--print',
`before_dl:${DEST_MARKER}%(filename)s`,
'--no-simulate'
]
+70 -6
View File
@@ -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
+43 -23
View File
@@ -1,4 +1,4 @@
import { app, shell, BrowserWindow, nativeTheme, Notification, Menu } from 'electron'
import { app, shell, BrowserWindow, nativeTheme, dialog, Menu } from 'electron'
import { join, resolve } from 'path'
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
import { IpcChannels } from '@shared/ipc'
@@ -10,7 +10,6 @@ import {
getSystemThemeInfo
} from './ipc'
import { runStartupYtdlpAutoUpdate } from './ytdlp'
import { getAppIconImage } from './binaries'
import { hasActiveDownloads } from './download'
import { getSettings, applyLaunchAtStartup, migrateSecretsAtRest } from './settings'
import { ensureMediaDirs } from './paths'
@@ -64,15 +63,39 @@ let mainWindow: BrowserWindow | null = null
// Tell the user (once per run) that closing the window left AeroFetch running so
// an in-progress download could finish — shown only when they haven't already
// opted into tray mode, so a window that "won't close" doesn't read as a bug.
let notifiedBackground = false
function notifyBackgroundOnce(): void {
if (notifiedBackground || !Notification.isSupported()) return
notifiedBackground = true
new Notification({
title: 'AeroFetch is still running',
body: 'Your download is finishing in the background. Use the tray icon to reopen or quit.',
icon: getAppIconImage()
}).show()
// L143: an in-window, tray-INDEPENDENT way to quit at the moment the window "won't
// close" because a download is running — without it the only exit is the tray menu
// (and Task Manager if the tray ever failed to create). Guarded so hammering the X
// can't stack dialogs. Only used when the user did NOT opt into tray mode; a
// deliberate minimize-to-tray close stays a silent hide.
let quitPromptOpen = false
function promptQuitWhileDownloading(win: BrowserWindow): void {
if (quitPromptOpen) return
quitPromptOpen = true
dialog
.showMessageBox(win, {
type: 'question',
buttons: ['Keep downloading', 'Quit anyway'],
defaultId: 0,
cancelId: 0,
title: 'Download in progress',
message: 'A download is still in progress.',
detail:
'AeroFetch will keep downloading in the background — reopen it from the tray icon. Quit anyway to stop the download and exit now.'
})
.then(({ response }) => {
quitPromptOpen = false
if (response === 1) {
markQuitting() // lets the close handler through + runs the before-quit teardown
app.quit()
} else {
win.hide() // keep downloading in the background
}
})
.catch(() => {
quitPromptOpen = false
win.hide()
})
}
// Web permissions a download manager never needs. They're denied for the app
@@ -155,11 +178,15 @@ function createWindow(): void {
const downloadsRunning = hasActiveDownloads()
if (getSettings().minimizeToTray || downloadsRunning) {
e.preventDefault()
win.hide()
// If we're only staying alive because a download is running (the user
// didn't opt into tray mode), tell them once — otherwise a window that
// won't close looks like a bug.
if (downloadsRunning && !getSettings().minimizeToTray) notifyBackgroundOnce()
// A download (not tray mode) is the only thing holding the app open: offer a
// tray-independent "Quit anyway" instead of silently hiding — otherwise a
// window that won't close looks like a bug, with no in-window way out (L143).
// A tray-mode close is the user's explicit choice, so that stays a silent hide.
if (downloadsRunning && !getSettings().minimizeToTray) {
promptQuitWhileDownloading(win)
} else {
win.hide()
}
}
})
@@ -167,13 +194,6 @@ function createWindow(): void {
if (mainWindow === win) mainWindow = null
})
// When the user re-opens the window (via tray, second-instance, or deeplink)
// reset the background-notify latch so they're informed again if they close
// while a download is still running (L68).
win.on('show', () => {
notifiedBackground = false
})
// The OS may have launched us with an aerofetch:// link or a "Send to" .url
// file on the command line — hand it to DownloadBar's link-suggestion banner
// once the page (and its IPC listener) is actually ready to receive it.
+53 -9
View File
@@ -46,11 +46,13 @@ interface FlatInfo {
* flat upload list can be a few MB of JSON; the timeout is generous for the same
* reason. `--` terminates option parsing so the URL can't be read as a flag.
*/
async function probeFlat(url: string): Promise<FlatInfo> {
async function probeFlat(url: string, signal?: AbortSignal): Promise<FlatInfo> {
const r = await execFileAsync(
getYtdlpPath(),
['-J', '--flat-playlist', '--no-warnings', '--', url],
{ maxBuffer: INDEX_MAX_BUFFER, timeout: INDEX_TIMEOUT_MS }
// `signal` lets a Cancel abort the in-flight probe child mid-walk (B2); Node
// kills the process and the call rejects, which the caller treats as canceled.
{ maxBuffer: INDEX_MAX_BUFFER, timeout: INDEX_TIMEOUT_MS, signal }
)
if (!r.ok) {
throw new Error(
@@ -67,8 +69,36 @@ async function probeFlat(url: string): Promise<FlatInfo> {
}
/** Probe a channel tab (e.g. /videos, /playlists); never throws — returns null. */
function probeTab(url: string): Promise<FlatInfo | null> {
return probeFlat(url).catch(() => null)
function probeTab(url: string, signal?: AbortSignal): Promise<FlatInfo | null> {
return probeFlat(url, signal).catch(() => null)
}
// Active user-initiated index walks. `cancelIndexing()` aborts them so an
// `index:cancel` IPC can stop the walk and kill the in-flight probe child (B2).
// Background sync (sync.ts) calls indexSource without a signal, so it isn't
// user-cancelable — it's throttled and unattended, not a blocking UI action.
const activeIndexAborts = new Set<AbortController>()
/** Abort every in-flight user index/reindex walk (the add-source "Cancel" button). */
export function cancelIndexing(): void {
for (const c of activeIndexAborts) c.abort()
}
/**
* indexSource wrapped with a fresh AbortController registered for cancelIndexing().
* The IPC layer calls this; sync.ts calls the raw indexSource (not cancelable).
*/
export async function indexSourceCancelable(
url: string,
onProgress: (p: IndexProgress) => void
): Promise<IndexSourceResult> {
const controller = new AbortController()
activeIndexAborts.add(controller)
try {
return await indexSource(url, onProgress, controller.signal)
} finally {
activeIndexAborts.delete(controller)
}
}
/**
@@ -78,8 +108,14 @@ function probeTab(url: string): Promise<FlatInfo | null> {
*/
export async function indexSource(
url: string,
onProgress: (p: IndexProgress) => void
onProgress: (p: IndexProgress) => void,
signal?: AbortSignal
): Promise<IndexSourceResult> {
// Returned whenever a Cancel arrives mid-walk. It deliberately does NOT push a
// progress event: the renderer drives the cancel and resets its own indexing UI,
// so a late 'error' event can't re-surface as a failure (B2).
const canceled: IndexSourceResult = { ok: false, error: 'Indexing canceled.' }
let normalizedUrl: string
try {
normalizedUrl = assertHttpUrl(url) // normalised form for spawning (audit F5)
@@ -106,11 +142,13 @@ export async function indexSource(
// 1. Enumerate the channel's playlists, then each playlist's videos.
onProgress({ url, phase: 'playlists', message: 'Finding playlists…' })
const playlistTab = await probeTab(`${cls.base}/playlists`)
const playlistTab = await probeTab(`${cls.base}/playlists`, signal)
if (signal?.aborted) return canceled
const playlistEntries = playlistTab?.entries ?? []
const total = playlistEntries.length
let done = 0
for (const pe of playlistEntries) {
if (signal?.aborted) return canceled // stop before the next probe
done++
const purl = entryUrl(pe)
if (!purl) continue
@@ -121,7 +159,7 @@ export async function indexSource(
current: done,
total
})
const pdata = await probeTab(purl)
const pdata = await probeTab(purl, signal)
if (pdata?.entries?.length) {
playlists.push({ title: pe.title || 'Playlist', entries: pdata.entries })
}
@@ -129,7 +167,8 @@ export async function indexSource(
// 2. The full uploads feed — the catch-all for videos in no playlist.
onProgress({ url, phase: 'uploads', message: 'Indexing channel uploads…' })
const videoTab = await probeTab(`${cls.base}/videos`)
const videoTab = await probeTab(`${cls.base}/videos`, signal)
if (signal?.aborted) return canceled
uploads = videoTab?.entries ?? []
title =
@@ -144,7 +183,8 @@ export async function indexSource(
// resolve to a playlist when probed. A lone video has no entries → error.
kind = cls?.kind ?? 'playlist'
onProgress({ url, phase: 'uploads', message: 'Indexing playlist…' })
const data = await probeFlat(cls?.base ?? normalizedUrl)
const data = await probeFlat(cls?.base ?? normalizedUrl, signal)
if (signal?.aborted) return canceled
const entries = data.entries ?? []
if (entries.length === 0) {
return {
@@ -166,6 +206,9 @@ export async function indexSource(
if (fresh.length === 0) {
return { ok: false, error: 'No downloadable videos found for this source.' }
}
// A cancel that landed after the final probe must not persist the source —
// otherwise a "canceled" add still pops into the library later (B2).
if (signal?.aborted) return canceled
// Incremental merge: preserve the downloaded state of anything already on
// disk, and report how many videos are new since the last index.
@@ -196,6 +239,7 @@ export async function indexSource(
})
return { ok: true, source, itemCount: items.length, newCount }
} catch (e) {
if (signal?.aborted) return canceled // the throw was our own abort — not an error
const msg = e instanceof Error ? e.message : String(e)
onProgress({ url, phase: 'error', message: msg })
return { ok: false, error: msg }
+17 -8
View File
@@ -27,9 +27,10 @@ import {
import { PAGE_BACKGROUND } from '@shared/theme'
import { getYtdlpVersion, updateYtdlp } from './ytdlp'
import { getFfmpegVersions } from './ffmpeg'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate } from './updater'
import { checkForAppUpdate, downloadAppUpdate, runAppUpdate, cancelAppUpdate } from './updater'
import { probeMedia } from './probe'
import { startDownload, cancelDownload, pauseDownload, previewCommand } from './download'
import { whenYtdlpUpdateSettled } from './ytdlpLock'
import { runTerminal, cancelTerminal } from './terminal'
import { getSettings, setSettings } from './settings'
import { listHistory, addHistory, removeHistory, removeManyHistory, clearHistory } from './history'
@@ -47,7 +48,7 @@ import {
setMediaItemDownloaded,
setSourceWatched
} from './sources'
import { indexSource } from './indexer'
import { indexSourceCancelable, cancelIndexing } from './indexer'
import { syncWatchedSources } from './sync'
import { getScheduledSync, setScheduledSync } from './schedule'
import { getActiveBadge, getErrorBadge } from './badge'
@@ -102,6 +103,7 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
ipcMain.handle(IpcChannels.appUpdateDownload, (e, url: string) =>
downloadAppUpdate(url, e.sender)
)
ipcMain.handle(IpcChannels.appUpdateCancel, () => cancelAppUpdate())
ipcMain.handle(IpcChannels.appUpdateRun, (_e, filePath: string) => runAppUpdate(filePath))
ipcMain.handle(IpcChannels.ytdlpVersion, () => getYtdlpVersion())
@@ -110,7 +112,11 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
ipcMain.handle(IpcChannels.probe, (_e, url: string) => probeMedia(url))
ipcMain.handle(IpcChannels.downloadStart, (e, opts: StartDownloadOptions) => {
ipcMain.handle(IpcChannels.downloadStart, async (e, opts: StartDownloadOptions) => {
// L139: hold the spawn until any in-progress yt-dlp self-update has finished
// swapping the exe, so a launch-time (auto-resumed) download waits it out instead
// of racing the rewrite. Resolves immediately when no update is running.
await whenYtdlpUpdateSettled()
const result = startDownload(e.sender, opts)
// Pre-spawn failures (missing yt-dlp.exe, bad URL, duplicate id) never reach
// download.ts's own close/error handlers, so log them here instead — unless the
@@ -128,9 +134,10 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
})
ipcMain.handle(IpcChannels.downloadCancel, (_e, id: string) => cancelDownload(id))
ipcMain.handle(IpcChannels.downloadPause, (_e, id: string) => pauseDownload(id))
ipcMain.handle(IpcChannels.terminalRun, (e, id: string, args: string) =>
runTerminal(e.sender, id, args)
)
ipcMain.handle(IpcChannels.terminalRun, async (e, id: string, args: string) => {
await whenYtdlpUpdateSettled() // L139: hold behind an in-progress exe rewrite
return runTerminal(e.sender, id, args)
})
ipcMain.handle(IpcChannels.terminalCancel, (_e, id: string) => cancelTerminal(id))
ipcMain.handle(IpcChannels.chooseFolder, async (e, current?: string) => {
@@ -241,17 +248,19 @@ export function registerIpcHandlers(getMainWindow: () => BrowserWindow | null):
// Indexing pushes live progress to the requesting renderer over `indexProgress`
// and resolves with the final result.
ipcMain.handle(IpcChannels.sourceIndex, (e, url: string) =>
indexSource(url, (p) => {
indexSourceCancelable(url, (p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
})
)
ipcMain.handle(IpcChannels.sourceReindex, (e, id: string) => {
const src = getSource(id)
if (!src) return { ok: false, error: 'Source not found.' }
return indexSource(src.url, (p) => {
return indexSourceCancelable(src.url, (p) => {
if (!e.sender.isDestroyed()) e.sender.send(IpcChannels.indexProgress, p)
})
})
// Abort the in-flight index/reindex walk (kills the current probe child). (B2)
ipcMain.handle(IpcChannels.sourceIndexCancel, () => cancelIndexing())
ipcMain.handle(IpcChannels.sourceSetWatched, (_e, id: string, watched: boolean) =>
setSourceWatched(id, watched)
)
+39
View File
@@ -0,0 +1,39 @@
/**
* Pure identification of a download's orphaned yt-dlp intermediate files (R4).
*
* When a running download is canceled, yt-dlp is tree-killed and leaves partial
* files behind in the output folder: the growing `.part`, fragment pieces
* (`.part-Frag*`) and the `.ytdl` fragment-state file for fragmented formats, plus
* any already-finished per-stream file (`<stem>.fNNN.<ext>` — e.g. the video half
* of a video+audio download whose audio was still in flight). A plain cancel never
* removes these, so they accumulate. download.ts captures the resolved FINAL output
* path from the before_dl `dest|` print; this module turns that path + a folder
* listing into the exact set to delete.
*
* Kept free of any electron/node-runtime chain (only `path.parse`) so it's
* unit-testable in isolation, the same posture as lib/formatters.ts (L37).
*
* SAFETY: it returns only unambiguous yt-dlp intermediates, matched on the output
* stem. It never returns a bare `<stem>.<ext>` completed file — so a pre-existing
* same-title download the user kept in the same folder is safe — nor any file
* belonging to a different download (different stem). This is the guard against the
* audit's "delete the wrong file if the destination capture is even slightly off".
*/
import { parse } from 'path'
export function orphanPartials(outputPath: string, entries: string[]): string[] {
// `name` is the basename without the final extension; a title containing dots is
// preserved (only the trailing `.<ext>` is stripped), so the stem stays exact.
const stem = parse(outputPath).name
if (!stem) return []
const stemDot = `${stem}.`
return entries.filter((entry) => {
if (!entry.startsWith(stemDot)) return false
return (
entry.endsWith('.part') || // <stem>….part — in-progress stream or merge
entry.includes('.part-Frag') || // fragment pieces (HLS/DASH)
entry.endsWith('.ytdl') || // fragment-download state file
/\.f\d+\.[^.]+$/i.test(entry) // finished per-stream file, e.g. <stem>.f399.mp4
)
})
}
+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()
+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
+35 -9
View File
@@ -6,6 +6,7 @@ import { cleanError } from './log'
import { VERSION_TIMEOUT_MS, YTDLP_UPDATE_TIMEOUT_MS } from './constants'
import { getSettings, setSettings } from './settings'
import { shouldAutoCheckYtdlp } from './ytdlpPolicy'
import { beginYtdlpUpdate, endYtdlpUpdate, liveSpawnCount } from './ytdlpLock'
import {
isYtdlpUpdateChannel,
type YtdlpVersionResult,
@@ -78,16 +79,31 @@ export async function updateYtdlp(channel: YtdlpUpdateChannel): Promise<YtdlpUpd
return { ok: false, error: YTDLP_MISSING_MSG }
}
const r = await execFileAsync(ytdlpPath, ['--update-to', channel], {
timeout: YTDLP_UPDATE_TIMEOUT_MS
})
if (!r.ok) {
const msg = r.timedOut
? 'Timed out updating yt-dlp.'
: cleanError(r.stderr) || r.error?.message || ''
return { ok: false, error: msg }
// L139: `--update-to` rewrites yt-dlp.exe in place. Take the spawn interlock so a
// download/terminal can't execute the exe mid-swap (Windows sharing violation /
// half-written binary). If a spawn (or another update) is already live, defer
// rather than fight it — the update is best-effort; the startup path retries next
// launch and the Settings "Update now" button surfaces this message for a manual retry.
if (!beginYtdlpUpdate()) {
return {
ok: false,
error: 'yt-dlp is busy (a download or another update is running) — try again shortly.'
}
}
try {
const r = await execFileAsync(ytdlpPath, ['--update-to', channel], {
timeout: YTDLP_UPDATE_TIMEOUT_MS
})
if (!r.ok) {
const msg = r.timedOut
? 'Timed out updating yt-dlp.'
: cleanError(r.stderr) || r.error?.message || ''
return { ok: false, error: msg }
}
return { ok: true, output: r.stdout.trim() }
} finally {
endYtdlpUpdate()
}
return { ok: true, output: r.stdout.trim() }
}
/**
@@ -109,11 +125,21 @@ export async function runStartupYtdlpAutoUpdate(
if (!shouldAutoCheckYtdlp(settings.autoUpdateYtdlp, settings.ytdlpLastUpdateCheck, Date.now())) {
return
}
// L139: never rewrite the exe while a download/terminal is live (e.g. an auto-
// resumed persisted-queue item). Skip WITHOUT recording the check so the daily
// throttle isn't burned on a deferral — retry next launch. Re-checked right before
// the rewrite too, since the version probe below awaits.
if (liveSpawnCount() > 0) return
const channel = settings.ytdlpChannel
send({ phase: 'checking', channel })
const before = await getYtdlpVersion()
if (liveSpawnCount() > 0) {
// A spawn started during the version probe — defer to next launch (no record).
send({ phase: 'current', channel, version: before.ok ? before.version : undefined })
return
}
const result = await updateYtdlp(channel)
setSettings({ ytdlpLastUpdateCheck: Date.now() })
+80
View File
@@ -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
}