/** * The little bit of real state mockd keeps. * * A pure fixture replayer would be useless to the GUI lane: adding a download and seeing * nothing appear teaches you nothing about your table model. So mockd keeps a task list, * moves tasks between states, and advances byte counters on a timer. Everything else is * answered straight from a golden file. * * This is deliberately not a download engine. Bytes advance on a clock, not from a socket. * * At scale (`--tasks 10000`) two things matter that don't at a handful of tasks: * * 1. `tick()` must not become O(total tasks) every 250 ms. It only ever touches the * bounded *active* set (`activeCap`, default 24) plus whatever retry-wait entries just * came due — never the thousands of tasks sitting in a terminal or queued state. * 2. Progress has to reach a broad slice of the table over the life of a run, not the same * couple of rows forever. As an active task finishes, the next one is promoted from its * queue's FIFO (with the rest of that queue's `queuePosition` renumbered, as a real * scheduler would), and a slice of "finished" active tasks fail instead and go through * `retry_wait` before being promoted again — so the active pool keeps rotating through * new rows for the whole run rather than draining once and going static. */ import { randomUUID } from 'node:crypto'; import type { TaskState, TaskSummary } from '../../../extension/src/shared/protocol/types.js'; import { randomTaskErrorCode, taskErrorFor, type SynthResult } from './synth.js'; export interface MockOptions { readonly progressHz: number; readonly speedBps: number; /** Ceiling on concurrently-"downloading" tasks. Bulk-seeded tasks rotate through this; * a manual download.add is admitted immediately regardless (see add()) — a human * driving the GUI by hand should never be told to wait behind synthetic load. */ readonly activeCap: number; } const ACTIVE: readonly TaskState[] = ['connecting', 'downloading']; // Chance per active task per tick that a healthy transfer hits a transient failure instead // of completing normally — keeps the active pool cycling through retry_wait for the whole // life of a long run rather than just draining the initial 'queued' population once. const TRANSIENT_FAILURE_CHANCE = 0.0015; const RETRY_DELAY_MS: readonly [number, number] = [5_000, 45_000]; export class MockState { readonly tasks = new Map(); limiter = { enabled: false, globalBps: 2097152, applyToRunning: false }; settings: Record = {}; private readonly opts: MockOptions; /** taskIds currently connecting/downloading. Only this set is walked every tick. */ private readonly active = new Set(); /** Per-queue FIFO of taskIds waiting to be promoted, front = next admitted. */ private readonly pendingByQueue = new Map(); /** taskIds with no queueId, or whose queue is unknown to this process (e.g. added via * download.add with startMode 'queue' and a queueId mockd has no fixture-derived queue * for) — a fallback FIFO so nothing is silently unpromotable. */ private readonly pendingUnqueued: string[] = []; private readonly retryWaiting: Array<{ taskId: string; dueAt: number }> = []; private readonly speedFactor = new Map(); constructor(seed: readonly TaskSummary[], opts: MockOptions) { this.opts = opts; for (const t of seed) this.registerNew({ ...t }); } /** Bulk-seed a large synthetic population without re-deriving the pool bookkeeping the * generator already computed — the one place a --tasks 10000 startup does real O(n) * work, and it happens once, not per tick. */ seedSynthetic(result: SynthResult): void { for (const task of result.tasks) this.tasks.set(task.taskId, task); for (const taskId of result.initiallyActive) this.active.add(taskId); for (const [queueId, ids] of result.queuedOrder) this.pendingByQueue.set(queueId, [...ids]); for (const [taskId, factor] of result.speedFactor) this.speedFactor.set(taskId, factor); } list(): TaskSummary[] { return [...this.tasks.values()]; } private registerNew(task: TaskSummary): void { this.tasks.set(task.taskId, task); if (ACTIVE.includes(task.state)) this.active.add(task.taskId); } /** * A manual add (download.add, capture.offer) is admitted immediately regardless of the * active pool — a human testing the GUI by hand must never be told to wait behind * synthetic --tasks load. It participates in tick()'s active set from the moment it is * created, same as any other active task. */ add(partial: Partial & { url: string }): TaskSummary { const now = new Date().toISOString(); const task: TaskSummary = { taskId: randomUUID(), filename: partial.filename ?? filenameFromUrl(partial.url), saveDir: partial.saveDir ?? '/home/sami/Downloads', url: partial.url, effectiveUrl: partial.url, sizeBytes: partial.sizeBytes ?? 734003200, downloadedBytes: 0, state: partial.state ?? 'connecting', speedBps: 0, etaSeconds: null, resumable: true, segments: partial.segments ?? 8, categoryId: partial.categoryId ?? null, queueId: partial.queueId ?? null, queuePosition: partial.queuePosition ?? null, description: partial.description ?? null, createdAt: now, lastTryAt: now, completedAt: null, error: null, }; this.registerNew(task); return task; } transition(taskId: string, state: TaskState): { changed: boolean; task: TaskSummary } | null { const task = this.tasks.get(taskId); if (!task) return null; const changed = task.state !== state; const wasActive = ACTIVE.includes(task.state); task.state = state; task.speedBps = ACTIVE.includes(state) ? this.opts.speedBps : 0; if (!ACTIVE.includes(state)) task.etaSeconds = null; if (state === 'complete') { task.downloadedBytes = task.sizeBytes ?? task.downloadedBytes; task.completedAt = new Date().toISOString(); } const isActive = ACTIVE.includes(state); if (isActive && !wasActive) this.active.add(taskId); else if (!isActive && wasActive) this.active.delete(taskId); return { changed, task }; } remove(taskId: string): boolean { this.active.delete(taskId); return this.tasks.delete(taskId); } private popNextQueued(): string | undefined { for (const [, ids] of this.pendingByQueue) { const taskId = ids.shift(); if (taskId !== undefined) { for (let i = 0; i < ids.length; i += 1) { const t = this.tasks.get(ids[i]!); if (t) t.queuePosition = i + 1; } return taskId; } } return this.pendingUnqueued.shift(); } private admit(taskId: string): void { const task = this.tasks.get(taskId); if (!task) return; // removed while queued — nothing to admit task.state = 'connecting'; task.speedBps = 0; task.queuePosition = null; task.lastTryAt = new Date().toISOString(); this.active.add(taskId); } private fillActiveFromQueues(): void { while (this.active.size < this.opts.activeCap) { const taskId = this.popNextQueued(); if (taskId === undefined) return; this.admit(taskId); } } /** * Advance one progress tick. Returns the tasks that moved and any that just finished * (completed, or newly failed), so the caller can emit event.task.progress and * event.task.state from one place. */ tick(): { moved: TaskSummary[]; completed: TaskSummary[]; failed: TaskSummary[] } { const moved: TaskSummary[] = []; const completed: TaskSummary[] = []; const failed: TaskSummary[] = []; const now = Date.now(); // Retry-wait entries whose backoff elapsed rejoin the active pool directly (they have // already waited their turn once; they don't go back through a queue's FIFO). while (this.retryWaiting.length > 0 && this.retryWaiting[0]!.dueAt <= now) { const { taskId } = this.retryWaiting.shift()!; if (this.active.size < this.opts.activeCap) { this.admit(taskId); const task = this.tasks.get(taskId); // Don't push to `moved` here: admit() leaves the task 'connecting', and the main // loop below visits every active taskId (this one included, since admit() just // added it) and pushes exactly once when it flips to 'downloading'. Pushing here // too would duplicate this task in the same tick's event.task.progress batch. if (task) task.error = null; } else { // Pool is full: park it at the front of the fallback FIFO rather than dropping it. this.pendingUnqueued.unshift(taskId); } } this.fillActiveFromQueues(); const perTickBase = Math.floor(this.opts.speedBps / this.opts.progressHz); for (const taskId of [...this.active]) { const task = this.tasks.get(taskId); if (!task) { this.active.delete(taskId); // stale — removed mid-transfer continue; } if (task.state === 'connecting') { // One settle tick before bytes start moving — also fixes a latent double-push: // falling through to the unconditional moved.push() below would otherwise queue // this task twice in the same event.task.progress batch (once here, once there). task.state = 'downloading'; moved.push(task); continue; } if (task.state !== 'downloading') continue; if (task.downloadedBytes > 0 && Math.random() < TRANSIENT_FAILURE_CHANCE) { const retryable = Math.random() < 0.7; const code = randomTaskErrorCode(Math.random, retryable); task.error = taskErrorFor(Math.random, code, retryable); task.state = retryable ? 'retry_wait' : 'failed'; task.speedBps = 0; task.etaSeconds = null; this.active.delete(taskId); failed.push(task); moved.push(task); if (retryable) { const [lo, hi] = RETRY_DELAY_MS; this.retryWaiting.push({ taskId, dueAt: now + lo + Math.random() * (hi - lo) }); } continue; } const factor = this.speedFactor.get(taskId) ?? 1; const perTick = Math.max(1, Math.round(perTickBase * factor)); task.speedBps = jitter(perTick * this.opts.progressHz); task.downloadedBytes += perTick; const size = task.sizeBytes; if (size !== null && size !== undefined && task.downloadedBytes >= size) { task.downloadedBytes = size; task.state = 'complete'; task.speedBps = 0; task.etaSeconds = null; task.completedAt = new Date().toISOString(); this.active.delete(taskId); completed.push(task); } else if (size !== null && size !== undefined && task.speedBps > 0) { task.etaSeconds = Math.ceil((size - task.downloadedBytes) / task.speedBps); } moved.push(task); } // Backfill whatever just finished, so the pool is at capacity again for the next tick // rather than idling until someone happens to call fillActiveFromQueues(). this.fillActiveFromQueues(); return { moved, completed, failed }; } globalSpeed(): { downBps: number; activeCount: number; queuedCount: number } { let downBps = 0; let activeCount = 0; for (const taskId of this.active) { const task = this.tasks.get(taskId); if (task) { downBps += task.speedBps; activeCount += 1; } } let queuedCount = this.pendingUnqueued.length + this.retryWaiting.length; for (const [, ids] of this.pendingByQueue) queuedCount += ids.length; return { downBps, activeCount, queuedCount }; } } function jitter(bps: number): number { return Math.max(0, Math.round(bps * (0.85 + Math.random() * 0.3))); } function filenameFromUrl(url: string): string { try { const path = new URL(url).pathname; const last = path.split('/').filter(Boolean).pop(); return last && last.length > 0 ? decodeURIComponent(last) : 'download.bin'; } catch { return 'download.bin'; } }