mirror of
https://github.com/nimbold/Firelink.git
synced 2026-09-01 13:38:01 +00:00
fix(downloads): harden live lifecycle projection
- remove non-queued lifecycle rows from the frontend pending projection - validate live progress telemetry before updating store state - add lifecycle and malformed-input regression coverage
This commit is contained in:
@@ -92,6 +92,127 @@ describe('useDownloadProgressStore', () => {
|
|||||||
release();
|
release();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('removes a row from backend pending order when its lifecycle becomes active or retrying', async () => {
|
||||||
|
const handlers: Record<string, (event: any) => void> = {};
|
||||||
|
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||||
|
handlers[event] = handler as (event: any) => void;
|
||||||
|
return Promise.resolve(vi.fn());
|
||||||
|
});
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'pending-transition',
|
||||||
|
url: 'https://example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
status: 'queued',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: ''
|
||||||
|
}],
|
||||||
|
pendingOrder: ['pending-transition']
|
||||||
|
});
|
||||||
|
|
||||||
|
const release = await initDownloadListener();
|
||||||
|
handlers['download-state']({ payload: {
|
||||||
|
id: 'pending-transition',
|
||||||
|
status: 'downloading'
|
||||||
|
} });
|
||||||
|
expect(useDownloadStore.getState().pendingOrder).toEqual([]);
|
||||||
|
|
||||||
|
useDownloadStore.getState().updateDownload('pending-transition', { status: 'downloading' });
|
||||||
|
useDownloadStore.setState({ pendingOrder: ['pending-transition'] });
|
||||||
|
handlers['download-state']({ payload: {
|
||||||
|
id: 'pending-transition',
|
||||||
|
status: 'retrying',
|
||||||
|
error: 'network dropped'
|
||||||
|
} });
|
||||||
|
expect(useDownloadStore.getState().pendingOrder).toEqual([]);
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects malformed live progress values at the event boundary', async () => {
|
||||||
|
const handlers: Record<string, (event: any) => void> = {};
|
||||||
|
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||||
|
handlers[event] = handler as (event: any) => void;
|
||||||
|
return Promise.resolve(vi.fn());
|
||||||
|
});
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'malformed-progress',
|
||||||
|
url: 'https://example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
status: 'downloading',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: ''
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const release = await initDownloadListener();
|
||||||
|
handlers['download-progress']({ payload: {
|
||||||
|
id: 'malformed-progress',
|
||||||
|
fraction: 0.5,
|
||||||
|
speed: '1 MB/s',
|
||||||
|
eta: '1s',
|
||||||
|
size: '1 MB',
|
||||||
|
size_is_final: false,
|
||||||
|
downloaded_bytes: -1,
|
||||||
|
total_bytes: Number.NaN,
|
||||||
|
active_connections: -2
|
||||||
|
} });
|
||||||
|
|
||||||
|
expect(useDownloadProgressStore.getState().progressMap['malformed-progress'])
|
||||||
|
.not.toHaveProperty('downloaded_bytes');
|
||||||
|
expect(useDownloadProgressStore.getState().progressMap['malformed-progress'])
|
||||||
|
.not.toHaveProperty('total_bytes');
|
||||||
|
expect(useDownloadStore.getState().downloads[0]).not.toHaveProperty('downloadedBytes');
|
||||||
|
expect(useDownloadStore.getState().downloads[0]).not.toHaveProperty('totalBytes');
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the last valid live frame when a malformed fraction arrives', async () => {
|
||||||
|
const handlers: Record<string, (event: any) => void> = {};
|
||||||
|
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||||
|
handlers[event] = handler as (event: any) => void;
|
||||||
|
return Promise.resolve(vi.fn());
|
||||||
|
});
|
||||||
|
useDownloadStore.setState({
|
||||||
|
downloads: [{
|
||||||
|
id: 'invalid-fraction',
|
||||||
|
url: 'https://example.com/file.bin',
|
||||||
|
fileName: 'file.bin',
|
||||||
|
status: 'downloading',
|
||||||
|
category: 'Other',
|
||||||
|
dateAdded: ''
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const release = await initDownloadListener();
|
||||||
|
handlers['download-progress']({ payload: {
|
||||||
|
id: 'invalid-fraction',
|
||||||
|
fraction: 0.4,
|
||||||
|
speed: '1 MB/s',
|
||||||
|
eta: '1s',
|
||||||
|
size: '1 MB',
|
||||||
|
size_is_final: false,
|
||||||
|
downloaded_bytes: 400
|
||||||
|
} });
|
||||||
|
handlers['download-progress']({ payload: {
|
||||||
|
id: 'invalid-fraction',
|
||||||
|
fraction: 2,
|
||||||
|
speed: '2 MB/s',
|
||||||
|
eta: '0s',
|
||||||
|
size: '1 MB',
|
||||||
|
size_is_final: false,
|
||||||
|
downloaded_bytes: 2000
|
||||||
|
} });
|
||||||
|
|
||||||
|
expect(useDownloadProgressStore.getState().progressMap['invalid-fraction'])
|
||||||
|
.toMatchObject({ fraction: 0.4, downloaded_bytes: 400 });
|
||||||
|
expect(useDownloadStore.getState().downloads[0]).toMatchObject({
|
||||||
|
fraction: 0.4,
|
||||||
|
downloadedBytes: 400
|
||||||
|
});
|
||||||
|
release();
|
||||||
|
});
|
||||||
|
|
||||||
it('projects native allocation events after admission and ignores stale generations', async () => {
|
it('projects native allocation events after admission and ignores stale generations', async () => {
|
||||||
const handlers: Record<string, (event: any) => void> = {};
|
const handlers: Record<string, (event: any) => void> = {};
|
||||||
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
vi.mocked(ipc.listenEvent).mockImplementation((event, handler) => {
|
||||||
|
|||||||
+69
-31
@@ -2,6 +2,7 @@ import type { UnlistenFn } from '@tauri-apps/api/event';
|
|||||||
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
import type { DownloadStatus } from '../bindings/DownloadStatus';
|
||||||
import { listenEvent as listen } from '../ipc';
|
import { listenEvent as listen } from '../ipc';
|
||||||
import type { DownloadItem } from '../bindings/DownloadItem';
|
import type { DownloadItem } from '../bindings/DownloadItem';
|
||||||
|
import type { DownloadProgressEvent } from '../bindings/DownloadProgressEvent';
|
||||||
import { categoryForDownload } from '../utils/downloads';
|
import { categoryForDownload } from '../utils/downloads';
|
||||||
import { useDownloadProgressStore } from './downloadProgressStore';
|
import { useDownloadProgressStore } from './downloadProgressStore';
|
||||||
|
|
||||||
@@ -34,6 +35,39 @@ type ProgressFields = {
|
|||||||
const finiteNonNegative = (value: unknown): value is number =>
|
const finiteNonNegative = (value: unknown): value is number =>
|
||||||
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
||||||
|
|
||||||
|
const sanitizeProgressPayload = (
|
||||||
|
payload: DownloadProgressEvent,
|
||||||
|
): DownloadProgressEvent | null => {
|
||||||
|
if (typeof payload.fraction !== 'number'
|
||||||
|
|| !Number.isFinite(payload.fraction)
|
||||||
|
|| payload.fraction < 0
|
||||||
|
|| payload.fraction > 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = { ...payload };
|
||||||
|
const numericFields: Array<keyof DownloadProgressEvent> = [
|
||||||
|
'downloaded_bytes',
|
||||||
|
'total_bytes',
|
||||||
|
'active_connections',
|
||||||
|
'requested_connections',
|
||||||
|
'effective_connections',
|
||||||
|
'uploaded_bytes',
|
||||||
|
'num_seeders',
|
||||||
|
'torrent_seeded_seconds',
|
||||||
|
];
|
||||||
|
for (const field of numericFields) {
|
||||||
|
if (sanitized[field] !== undefined && !finiteNonNegative(sanitized[field])) {
|
||||||
|
delete sanitized[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (sanitized.total_is_estimate !== undefined
|
||||||
|
&& typeof sanitized.total_is_estimate !== 'boolean') {
|
||||||
|
delete sanitized.total_is_estimate;
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
};
|
||||||
|
|
||||||
const progressFields = (source: unknown): ProgressFields => {
|
const progressFields = (source: unknown): ProgressFields => {
|
||||||
if (!source || typeof source !== 'object') return {};
|
if (!source || typeof source !== 'object') return {};
|
||||||
const value = source as Record<string, unknown>;
|
const value = source as Record<string, unknown>;
|
||||||
@@ -133,51 +167,55 @@ const startDownloadListeners = async () => {
|
|||||||
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
useDownloadProgressStore.getState().clearDownloadProgress(payload.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, payload);
|
const sanitizedPayload = sanitizeProgressPayload(payload);
|
||||||
const shouldUpdateSize = Boolean(payload.size && (!current.isMedia || payload.size_is_final));
|
if (!sanitizedPayload) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
useDownloadProgressStore.getState().updateDownloadProgress(payload.id, sanitizedPayload);
|
||||||
|
const shouldUpdateSize = Boolean(sanitizedPayload.size && (!current.isMedia || sanitizedPayload.size_is_final));
|
||||||
const updates: Partial<DownloadItem> = {};
|
const updates: Partial<DownloadItem> = {};
|
||||||
if (current.status === 'downloading' || current.status === 'processing' || current.status === 'verifying' || current.status === 'seeding') {
|
if (current.status === 'downloading' || current.status === 'processing' || current.status === 'verifying' || current.status === 'seeding') {
|
||||||
updates.fraction = payload.fraction;
|
updates.fraction = sanitizedPayload.fraction;
|
||||||
updates.speed = current.status === 'seeding'
|
updates.speed = current.status === 'seeding'
|
||||||
? payload.upload_speed ?? '-'
|
? sanitizedPayload.upload_speed ?? '-'
|
||||||
: payload.speed;
|
: sanitizedPayload.speed;
|
||||||
updates.eta = current.status === 'seeding' ? '-' : payload.eta;
|
updates.eta = current.status === 'seeding' ? '-' : sanitizedPayload.eta;
|
||||||
}
|
}
|
||||||
if (shouldUpdateSize && current.size !== payload.size) {
|
if (shouldUpdateSize && current.size !== sanitizedPayload.size) {
|
||||||
updates.size = payload.size!;
|
updates.size = sanitizedPayload.size!;
|
||||||
}
|
}
|
||||||
if (payload.downloaded_bytes !== null && payload.downloaded_bytes !== undefined) {
|
if (sanitizedPayload.downloaded_bytes !== null && sanitizedPayload.downloaded_bytes !== undefined) {
|
||||||
updates.downloadedBytes = payload.downloaded_bytes;
|
updates.downloadedBytes = sanitizedPayload.downloaded_bytes;
|
||||||
}
|
}
|
||||||
if (payload.total_bytes !== null && payload.total_bytes !== undefined) {
|
if (sanitizedPayload.total_bytes !== null && sanitizedPayload.total_bytes !== undefined) {
|
||||||
updates.totalBytes = payload.total_bytes;
|
updates.totalBytes = sanitizedPayload.total_bytes;
|
||||||
}
|
}
|
||||||
if (payload.total_is_estimate !== null && payload.total_is_estimate !== undefined) {
|
if (sanitizedPayload.total_is_estimate !== null && sanitizedPayload.total_is_estimate !== undefined) {
|
||||||
updates.totalIsEstimate = payload.total_is_estimate;
|
updates.totalIsEstimate = sanitizedPayload.total_is_estimate;
|
||||||
}
|
}
|
||||||
if (current.isTorrent) {
|
if (current.isTorrent) {
|
||||||
if (payload.uploaded_bytes !== null
|
if (sanitizedPayload.uploaded_bytes !== null
|
||||||
&& payload.uploaded_bytes !== undefined
|
&& sanitizedPayload.uploaded_bytes !== undefined
|
||||||
&& Number.isSafeInteger(payload.uploaded_bytes)
|
&& Number.isSafeInteger(sanitizedPayload.uploaded_bytes)
|
||||||
&& payload.uploaded_bytes >= 0) {
|
&& sanitizedPayload.uploaded_bytes >= 0) {
|
||||||
updates.torrentUploadedBytes = payload.uploaded_bytes;
|
updates.torrentUploadedBytes = sanitizedPayload.uploaded_bytes;
|
||||||
}
|
}
|
||||||
if (payload.torrent_seeded_seconds !== null
|
if (sanitizedPayload.torrent_seeded_seconds !== null
|
||||||
&& payload.torrent_seeded_seconds !== undefined
|
&& sanitizedPayload.torrent_seeded_seconds !== undefined
|
||||||
&& Number.isSafeInteger(payload.torrent_seeded_seconds)
|
&& Number.isSafeInteger(sanitizedPayload.torrent_seeded_seconds)
|
||||||
&& payload.torrent_seeded_seconds >= 0) {
|
&& sanitizedPayload.torrent_seeded_seconds >= 0) {
|
||||||
updates.torrentSeededSeconds = payload.torrent_seeded_seconds;
|
updates.torrentSeededSeconds = sanitizedPayload.torrent_seeded_seconds;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const observedDownloadedBytes = Math.max(
|
const observedDownloadedBytes = Math.max(
|
||||||
current.downloadedBytes ?? 0,
|
current.downloadedBytes ?? 0,
|
||||||
payload.downloaded_bytes ?? 0
|
sanitizedPayload.downloaded_bytes ?? 0
|
||||||
);
|
);
|
||||||
// Older lifecycles may have persisted yt-dlp's temporary fragmented
|
// Older lifecycles may have persisted yt-dlp's temporary fragmented
|
||||||
// estimate (often 1 KiB). Once actual bytes exceed it and the current
|
// estimate (often 1 KiB). Once actual bytes exceed it and the current
|
||||||
// progress frame has no reliable total, discard that stale denominator
|
// progress frame has no reliable total, discard that stale denominator
|
||||||
// so it cannot survive a pause, queue transition, or app restart.
|
// so it cannot survive a pause, queue transition, or app restart.
|
||||||
if (payload.total_bytes == null && hasStaleTemporaryMediaEstimate({
|
if (sanitizedPayload.total_bytes == null && hasStaleTemporaryMediaEstimate({
|
||||||
isMedia: current.isMedia,
|
isMedia: current.isMedia,
|
||||||
downloadedBytes: observedDownloadedBytes,
|
downloadedBytes: observedDownloadedBytes,
|
||||||
totalBytes: current.totalBytes,
|
totalBytes: current.totalBytes,
|
||||||
@@ -375,14 +413,14 @@ const startDownloadListeners = async () => {
|
|||||||
['ready', 'staged', 'paused', 'completed', 'failed'].includes(status);
|
['ready', 'staged', 'paused', 'completed', 'failed'].includes(status);
|
||||||
mainStore.updateDownload(payload.id, updates);
|
mainStore.updateDownload(payload.id, updates);
|
||||||
|
|
||||||
if (status === 'completed' || status === 'failed' || status === 'paused' || status === 'seeding' || status === 'waitingToSeed') {
|
if (status === 'queued') {
|
||||||
useDownloadStore.setState(state => ({
|
|
||||||
pendingOrder: state.pendingOrder.filter(id => id !== payload.id)
|
|
||||||
}));
|
|
||||||
} else if (status === 'queued') {
|
|
||||||
useDownloadStore.setState(state => state.pendingOrder.includes(payload.id)
|
useDownloadStore.setState(state => state.pendingOrder.includes(payload.id)
|
||||||
? {}
|
? {}
|
||||||
: { pendingOrder: [...state.pendingOrder, payload.id] });
|
: { pendingOrder: [...state.pendingOrder, payload.id] });
|
||||||
|
} else {
|
||||||
|
useDownloadStore.setState(state => ({
|
||||||
|
pendingOrder: state.pendingOrder.filter(id => id !== payload.id)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying') {
|
if (status === 'queued' || status === 'downloading' || status === 'processing' || status === 'verifying' || status === 'seeding' || status === 'waitingToSeed' || status === 'retrying') {
|
||||||
|
|||||||
Reference in New Issue
Block a user