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:
@@ -9,6 +9,7 @@ import {
|
||||
collectionOutputTemplate,
|
||||
CROP_SQUARE_PPA,
|
||||
ARIA2C_ARGS,
|
||||
DEST_MARKER,
|
||||
type AccessOptions
|
||||
} from '../src/main/buildArgs'
|
||||
import {
|
||||
@@ -63,6 +64,14 @@ describe('buildArgs scaffolding', () => {
|
||||
expect(argv[argv.length - 2]).toBe('--')
|
||||
expect(argv[argv.length - 1]).toBe(URL)
|
||||
})
|
||||
|
||||
it('prints the before_dl destination (dest|) so a cancel can locate partials (R4)', () => {
|
||||
// download.ts's cancel cleanup depends on this print to know the output stem;
|
||||
// removing it would silently disable orphan-.part deletion.
|
||||
expect(hasSeq(build(opts(), dlo()), '--print', `before_dl:${DEST_MARKER}%(filename)s`)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// --- Network: proxy / rate limit / aria2c -----------------------------------
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
// Pure module — no electron import chain (L37), same as lib/formatters.
|
||||
import { orphanPartials } from '../src/main/lib/partials'
|
||||
|
||||
// The resolved FINAL output path the before_dl `dest|` print gives download.ts.
|
||||
const OUT = 'C:\\Users\\me\\Videos\\Despacito.webm'
|
||||
|
||||
describe('orphanPartials (R4 cancel cleanup)', () => {
|
||||
it('deletes the in-progress .part stream file', () => {
|
||||
expect(orphanPartials(OUT, ['Despacito.f399.mp4.part'])).toEqual(['Despacito.f399.mp4.part'])
|
||||
})
|
||||
|
||||
it('deletes a finished per-stream intermediate (no .part) awaiting merge', () => {
|
||||
// e.g. the video half of a video+audio download whose audio was still in flight.
|
||||
expect(orphanPartials(OUT, ['Despacito.f251.webm'])).toEqual(['Despacito.f251.webm'])
|
||||
})
|
||||
|
||||
it('deletes fragment pieces and the .ytdl fragment-state file', () => {
|
||||
const entries = [
|
||||
'Despacito.f140.m4a.part-Frag0',
|
||||
'Despacito.f140.m4a.part-Frag12',
|
||||
'Despacito.f140.m4a.ytdl'
|
||||
]
|
||||
expect(orphanPartials(OUT, entries)).toEqual(entries)
|
||||
})
|
||||
|
||||
it('KEEPS a pre-existing same-title completed file (video and audio)', () => {
|
||||
// The audit's "delete the wrong file" risk: a bare <stem>.<ext> is never ours.
|
||||
expect(orphanPartials(OUT, ['Despacito.webm', 'Despacito.mp3', 'Despacito.mkv'])).toEqual([])
|
||||
})
|
||||
|
||||
it('KEEPS a different download in the same folder (different stem)', () => {
|
||||
expect(orphanPartials(OUT, ['Other Video.f399.mp4.part', 'Other Video.f251.webm'])).toEqual([])
|
||||
})
|
||||
|
||||
it('does not match a stem that is only a prefix of a longer name', () => {
|
||||
// "Despacito 2.f399.mp4.part" must NOT be swept by the "Despacito" download —
|
||||
// startsWith('Despacito.') excludes it (the dot boundary matters).
|
||||
expect(orphanPartials(OUT, ['Despacito 2.f399.mp4.part', 'Despacito2.webm.part'])).toEqual([])
|
||||
})
|
||||
|
||||
it('handles a title containing dots (only the final extension is the boundary)', () => {
|
||||
const out = 'C:\\Users\\me\\Music\\Track 3.14 - Pi.mp3'
|
||||
const entries = [
|
||||
'Track 3.14 - Pi.webm.part',
|
||||
'Track 3.14 - Pi.mp3',
|
||||
'Track 3.14 - Pi.f251.webm'
|
||||
]
|
||||
// .part + intermediate deleted; the completed .mp3 kept.
|
||||
expect(orphanPartials(out, entries)).toEqual([
|
||||
'Track 3.14 - Pi.webm.part',
|
||||
'Track 3.14 - Pi.f251.webm'
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts nothing / preserves order and mixes correctly in a realistic listing', () => {
|
||||
const entries = [
|
||||
'Despacito.f399.mp4.part', // delete: in-progress video
|
||||
'Despacito.f251.webm', // delete: finished audio intermediate
|
||||
'Despacito.webm', // keep: pre-existing final
|
||||
'Unrelated.f140.m4a.part', // keep: other download
|
||||
'Despacito.f140.m4a.ytdl' // delete: fragment state
|
||||
]
|
||||
expect(orphanPartials(OUT, entries)).toEqual([
|
||||
'Despacito.f399.mp4.part',
|
||||
'Despacito.f251.webm',
|
||||
'Despacito.f140.m4a.ytdl'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns [] when the path has no usable stem', () => {
|
||||
expect(orphanPartials('', ['x.part'])).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
|
||||
// Module-level mutable state — reset to a fresh instance per test (L139 interlock).
|
||||
type Lock = typeof import('../src/main/ytdlpLock')
|
||||
let lock: Lock
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
lock = await import('../src/main/ytdlpLock')
|
||||
})
|
||||
|
||||
describe('ytdlpLock (L139 spawn↔update interlock)', () => {
|
||||
it('starts idle: not updating, zero spawns, settle already resolved', async () => {
|
||||
expect(lock.isYtdlpUpdating()).toBe(false)
|
||||
expect(lock.liveSpawnCount()).toBe(0)
|
||||
await expect(lock.whenYtdlpUpdateSettled()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('acquire/release tracks the live count and clamps at zero', () => {
|
||||
lock.acquireSpawn()
|
||||
lock.acquireSpawn()
|
||||
expect(lock.liveSpawnCount()).toBe(2)
|
||||
lock.releaseSpawn()
|
||||
expect(lock.liveSpawnCount()).toBe(1)
|
||||
lock.releaseSpawn()
|
||||
lock.releaseSpawn() // extra release must not underflow
|
||||
expect(lock.liveSpawnCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('beginYtdlpUpdate refuses (and does NOT take the lock) while a spawn is live', () => {
|
||||
lock.acquireSpawn()
|
||||
expect(lock.beginYtdlpUpdate()).toBe(false)
|
||||
expect(lock.isYtdlpUpdating()).toBe(false)
|
||||
lock.releaseSpawn()
|
||||
expect(lock.beginYtdlpUpdate()).toBe(true)
|
||||
expect(lock.isYtdlpUpdating()).toBe(true)
|
||||
lock.endYtdlpUpdate()
|
||||
expect(lock.isYtdlpUpdating()).toBe(false)
|
||||
})
|
||||
|
||||
it('holds a spawn until endYtdlpUpdate resolves whenYtdlpUpdateSettled', async () => {
|
||||
expect(lock.beginYtdlpUpdate()).toBe(true)
|
||||
let resolved = false
|
||||
const held = lock.whenYtdlpUpdateSettled().then(() => {
|
||||
resolved = true
|
||||
})
|
||||
await Promise.resolve() // flush a microtask tick — still pending mid-update
|
||||
expect(resolved).toBe(false)
|
||||
lock.endYtdlpUpdate()
|
||||
await held
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves immediately (no hold) when no update is in progress', async () => {
|
||||
let resolved = false
|
||||
await lock.whenYtdlpUpdateSettled().then(() => {
|
||||
resolved = true
|
||||
})
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
|
||||
it('refuses a CONCURRENT second update — the first end() must release only its own lock', async () => {
|
||||
expect(lock.beginYtdlpUpdate()).toBe(true)
|
||||
// A racing second begin (startup auto-update vs manual "Update now") must be
|
||||
// refused — accepting it would swap the settle promise, letting the first
|
||||
// update's end() release spawns held by the still-running second update.
|
||||
expect(lock.beginYtdlpUpdate()).toBe(false)
|
||||
expect(lock.isYtdlpUpdating()).toBe(true) // the refusal didn't clobber the lock
|
||||
let resolved = false
|
||||
const held = lock.whenYtdlpUpdateSettled().then(() => {
|
||||
resolved = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(resolved).toBe(false) // still held by the rightful owner
|
||||
lock.endYtdlpUpdate()
|
||||
await held
|
||||
expect(resolved).toBe(true)
|
||||
expect(lock.isYtdlpUpdating()).toBe(false)
|
||||
})
|
||||
|
||||
it('a second update after one settles holds again on a fresh promise', async () => {
|
||||
lock.beginYtdlpUpdate()
|
||||
lock.endYtdlpUpdate()
|
||||
expect(lock.beginYtdlpUpdate()).toBe(true)
|
||||
let resolved = false
|
||||
const held = lock.whenYtdlpUpdateSettled().then(() => {
|
||||
resolved = true
|
||||
})
|
||||
await Promise.resolve()
|
||||
expect(resolved).toBe(false)
|
||||
lock.endYtdlpUpdate()
|
||||
await held
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user