diff --git a/CLAUDE.md b/CLAUDE.md index 5a998c2d5..a41e71e1b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -948,10 +948,13 @@ engine` (restart required) or completed-missing row can be reset, so a file restored after the renderer snapshot is not orphaned or downloaded again. The recheck has a one-second deadline; timeout or probe failure leaves the row untouched and reports a - failed submission so the season loop can continue. Episode and season - download actions require an authoritative global list. A successful snapshot - remains authoritative while a later background refresh is in flight; a - latest refresh failure leaves + failed submission so the season loop can continue. Completed-file list + probes use the same deadline; a timeout releases their coalescing slot and is + reported as missing for that snapshot, so a later refresh performs a new + filesystem check instead of joining the stalled operation. Episode and + season download actions require an authoritative global list. A successful + snapshot remains authoritative while a later background refresh is in + flight; a latest refresh failure leaves loading/empty-state resolution intact but disables starts until another snapshot succeeds. - Episode ownership uses normalized `episode.id` as the canonical `xtreamId` diff --git a/apps/electron-backend/src/app/events/database/download-file-availability.ts b/apps/electron-backend/src/app/events/database/download-file-availability.ts index f7eaae19b..38df74adc 100644 --- a/apps/electron-backend/src/app/events/database/download-file-availability.ts +++ b/apps/electron-backend/src/app/events/database/download-file-availability.ts @@ -21,10 +21,14 @@ type DownloadAsyncLstat = ( filePath: string ) => Promise>; -type DownloadFileAvailabilityProbe = (filePath: string) => Promise; +type DownloadFileAvailabilityProbeResult = boolean | 'unknown'; + +type DownloadFileAvailabilityProbe = ( + filePath: string +) => Promise; const DEFAULT_MAX_CONCURRENT_FILE_PROBES = 4; -const DEFAULT_RESTORED_FILE_PROBE_TIMEOUT_MS = 1_000; +const DEFAULT_FILE_PROBE_TIMEOUT_MS = 1_000; export type BoundedDownloadFileAvailability = ElectronDownloadFileAvailability | 'unknown'; @@ -37,7 +41,10 @@ function createDownloadFileAvailabilityProbe( const pending: Array<() => void> = []; // Coalesce only active probes. Completed results are discarded so an // externally removed file is visible on the next list refresh. - const inFlight = new Map>(); + const inFlight = new Map< + string, + Promise + >(); let active = 0; const acquire = (): Promise => { @@ -57,14 +64,28 @@ function createDownloadFileAvailabilityProbe( } }; - const inspect = async (filePath: string): Promise => { + const inspect = async ( + filePath: string + ): Promise => { await acquire(); + let timeout: ReturnType | undefined; try { - const stats = await asyncLstat(filePath); - return stats.isFile() && !stats.isSymbolicLink(); - } catch { - return false; + const timedOut = new Promise<'unknown'>((resolve) => { + timeout = setTimeout( + () => resolve('unknown'), + DEFAULT_FILE_PROBE_TIMEOUT_MS + ); + }); + return await Promise.race([ + asyncLstat(filePath) + .then((stats) => stats.isFile() && !stats.isSymbolicLink()) + .catch(() => false), + timedOut, + ]); } finally { + if (timeout !== undefined) { + clearTimeout(timeout); + } release(); } }; @@ -89,6 +110,15 @@ function createDownloadFileAvailabilityProbe( const probeDownloadFileAvailability = createDownloadFileAvailabilityProbe(); +function toBoundedDownloadFileAvailability( + available: DownloadFileAvailabilityProbeResult +): BoundedDownloadFileAvailability { + if (available === 'unknown') { + return available; + } + return available ? 'available' : 'missing'; +} + export function isAvailableDownloadFile( filePath: string | null | undefined, lstat: DownloadLstat = lstatSync @@ -130,13 +160,14 @@ export async function getDownloadFileAvailabilityAsync( return 'missing'; } - return (await probe(download.filePath)) ? 'available' : 'missing'; + const available = await probe(download.filePath); + return available === true ? 'available' : 'missing'; } export async function getDownloadFileAvailabilityWithTimeoutAsync( download: DownloadFileRow, - timeoutMs = DEFAULT_RESTORED_FILE_PROBE_TIMEOUT_MS, - probe: DownloadFileAvailabilityProbe = probeDownloadFileAvailability + timeoutMs = DEFAULT_FILE_PROBE_TIMEOUT_MS, + probe?: DownloadFileAvailabilityProbe ): Promise { if (download.status !== 'completed') { return 'not-applicable'; @@ -146,10 +177,16 @@ export async function getDownloadFileAvailabilityWithTimeoutAsync( return 'missing'; } + if (!probe) { + return toBoundedDownloadFileAvailability( + await probeDownloadFileAvailability(download.filePath) + ); + } + const boundedTimeoutMs = Number.isFinite(timeoutMs) && timeoutMs >= 0 ? timeoutMs - : DEFAULT_RESTORED_FILE_PROBE_TIMEOUT_MS; + : DEFAULT_FILE_PROBE_TIMEOUT_MS; let timeout: ReturnType | undefined; const timedOut = new Promise<'unknown'>((resolve) => { timeout = setTimeout(() => resolve('unknown'), boundedTimeoutMs); @@ -160,11 +197,7 @@ export async function getDownloadFileAvailabilityWithTimeoutAsync( probe(download.filePath), timedOut, ]); - return available === 'unknown' - ? available - : available - ? 'available' - : 'missing'; + return toBoundedDownloadFileAvailability(available); } catch { return 'unknown'; } finally { diff --git a/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts b/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts index f6f0ba8d1..9ce044ff4 100644 --- a/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts +++ b/apps/electron-backend/src/app/events/database/downloads-file-availability.events.spec.ts @@ -77,6 +77,54 @@ describe('downloads events: file availability', () => { await expect(response).resolves.toHaveLength(2); }); + it('times out an unresponsive probe and lets the next list refresh recheck the file', async () => { + jest.useFakeTimers(); + try { + const row = { + filePath: '/downloads/unresponsive/movie.mp4', + id: 1, + status: 'completed', + }; + const orderBy = jest.fn().mockResolvedValue([row]); + mockGetDatabase.mockResolvedValue({ + select: jest.fn(() => ({ + from: jest.fn(() => ({ orderBy })), + })), + }); + mockLstat + .mockReturnValueOnce( + new Promise(() => { + // Simulate an unresponsive removable/network mount. + }) + ) + .mockResolvedValueOnce(regularFile()); + + const timedOutRefresh = getHandler('DOWNLOADS_GET_LIST')(null); + await jest.advanceTimersByTimeAsync(1_000); + + await expect(timedOutRefresh).resolves.toEqual([ + { + ...row, + metadataSnapshot: undefined, + fileAvailability: 'missing', + }, + ]); + + await expect( + getHandler('DOWNLOADS_GET_LIST')(null) + ).resolves.toEqual([ + { + ...row, + metadataSnapshot: undefined, + fileAvailability: 'available', + }, + ]); + expect(mockLstat).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }, 500); + it('starts at most four completed-file probes concurrently', async () => { const rows = Array.from({ length: 6 }, (_, index) => ({ filePath: `/downloads/network/movie-${index}.mp4`, diff --git a/docs/architecture/download-manager.md b/docs/architecture/download-manager.md index bca160823..b509e35ef 100644 --- a/docs/architecture/download-manager.md +++ b/docs/architecture/download-manager.md @@ -98,8 +98,12 @@ variants, contextual buttons, and theme-aware styling. `reason: 'already-downloaded'` without mutation; active matches return `reason: 'already-in-progress'`. The recheck has a one-second deadline; timeout or probe failure leaves the row untouched and returns a failed - submission, allowing the sequential season loop to continue. The coordinator - counts both stable duplicate reasons as skipped. There is no batch IPC, + submission, allowing the sequential season loop to continue. Completed-file + probes used by list refreshes have the same deadline. A timed-out probe is + released from the coalescing pool, the snapshot reports the file as missing, + and a later refresh performs a fresh check instead of reusing the stalled + filesystem operation. The coordinator counts both stable duplicate reasons + as skipped. There is no batch IPC, parallel transfer, or queue reordering: destination authorization, persisted header handling, and the backend's one-active-transfer FIFO semantics remain unchanged.