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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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() })
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user