fix(downloads): release timed out file probes

This commit is contained in:
4gray committed 2026-08-02 21:15:13 +02:00
1 parent 9b4e3433ed
commit e8640996e9
4 files changed
+111 -23

No files matched your search

+7 -4
View File
@@ -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`
@@ -21,10 +21,14 @@ type DownloadAsyncLstat = (
filePath: string
) => Promise<Pick<Stats, 'isFile' | 'isSymbolicLink'>>;
type DownloadFileAvailabilityProbe = (filePath: string) => Promise<boolean>;
type DownloadFileAvailabilityProbeResult = boolean | 'unknown';
type DownloadFileAvailabilityProbe = (
filePath: string
) => Promise<DownloadFileAvailabilityProbeResult>;
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<string, Promise<boolean>>();
const inFlight = new Map<
string,
Promise<DownloadFileAvailabilityProbeResult>
>();
let active = 0;
const acquire = (): Promise<void> => {
@@ -57,14 +64,28 @@ function createDownloadFileAvailabilityProbe(
}
};
const inspect = async (filePath: string): Promise<boolean> => {
const inspect = async (
filePath: string
): Promise<DownloadFileAvailabilityProbeResult> => {
await acquire();
let timeout: ReturnType<typeof setTimeout> | 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<BoundedDownloadFileAvailability> {
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<typeof setTimeout> | 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 {
@@ -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`,
+6 -2
View File
@@ -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.