e19f988c72
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>
95 lines
3.3 KiB
TypeScript
95 lines
3.3 KiB
TypeScript
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)
|
|
})
|
|
})
|