/** * `--tasks N`: a plausible, large synthetic task list. * * GUI's M1 definition of done is "10 000 synthetic rows scroll at 60 fps with flat memory * over 10 minutes (`mockd --tasks 10000`)". That is a claim about a table full of rows that * look like a real download manager's history, not 10 000 copies of one row: varied * states, sizes, speeds, categories and queue positions, drawn from the same category and * queue fixtures the rest of mockd already serves so nothing here can drift from them. * * "Progress advances across the whole set, not a handful of live rows" rules out the * obvious cheap answer — seed a handful of tasks as 'downloading' forever and leave * thousands static. Instead a bounded, rotating pool of concurrently-active downloads * (state.ts's `activeCap`) is fed continuously from the queued population and from * transient retries, so over a ten-minute run hundreds of distinct rows have shown live * progress by the time it ends — while at any single instant the active count stays * realistic, exactly as a real daemon would run it. */ import { randomUUID } from 'node:crypto'; import type { Category, Queue, TaskState, TaskSummary } from '../../../extension/src/shared/protocol/types.js'; import { TASK_ERROR_CODE_VALUES, type TaskErrorCode } from '../../../extension/src/shared/protocol/types.js'; /** Deterministic PRNG (mulberry32) so a given --seed reproduces the same table — useful * when a GUI bug only shows up at a particular row and needs to be reproduced exactly. */ function mulberry32(seed: number): () => number { let a = seed >>> 0; return () => { a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } type Rng = () => number; function pick(rng: Rng, items: readonly T[]): T { return items[Math.floor(rng() * items.length)] as T; } function int(rng: Rng, min: number, max: number): number { return Math.floor(min + rng() * (max - min + 1)); } /** Log-uniform: real download sizes span 50 KB README files to 20 GB disk images, and a * plain uniform draw would make everything look mid-sized. */ function logUniform(rng: Rng, min: number, max: number): number { const lo = Math.log(min); const hi = Math.log(max); return Math.round(Math.exp(lo + rng() * (hi - lo))); } const ADJECTIVES = ['annual', 'final', 'draft', 'archived', 'backup', 'shared', 'personal', 'weekly', 'monthly', 'legacy', 'updated', 'source', 'compiled', 'signed', 'raw', 'edited']; const NOUNS = ['report', 'photos', 'session', 'build', 'release', 'dataset', 'presentation', 'recording', 'mixdown', 'clip', 'manual', 'invoice', 'archive', 'snapshot', 'export', 'project']; function synthFilename(rng: Rng, ext: string): string { const stem = `${pick(rng, ADJECTIVES)}-${pick(rng, NOUNS)}-${int(rng, 1, 9999)}`; return `${stem}.${ext}`; } function synthHost(rng: Rng): string { const hosts = ['cdn.example.org', 'files.example.net', 'mirror.example.com', 'downloads.example.io', 'assets.example.dev', 'releases.example.org']; return pick(rng, hosts); } /** Retryable per B1's taxonomy (`docs/adr/0010`) — matches `x-carriesHttpStatus` roughly: * network hiccups and 5xx are the plausible transient ones a synthetic run should cycle * through; auth/4xx/local-fs errors are terminal and go straight to 'failed'. */ const RETRYABLE_CODES: readonly TaskErrorCode[] = ['resolve_failed', 'connect_failed', 'connection_reset', 'timeout', 'http_server_error']; const TERMINAL_CODES: readonly TaskErrorCode[] = TASK_ERROR_CODE_VALUES.filter((c) => !RETRYABLE_CODES.includes(c) && c !== 'canceled'); export function randomTaskErrorCode(rng: Rng, retryable: boolean): TaskErrorCode { return pick(rng, retryable ? RETRYABLE_CODES : TERMINAL_CODES); } export function taskErrorFor(rng: Rng, code: TaskErrorCode, retryable: boolean): TaskSummary['error'] { const httpCoded: readonly TaskErrorCode[] = ['http_client_error', 'http_server_error', 'auth_required', 'forbidden', 'not_found', 'range_not_satisfiable', 'gone']; return { code, message: `synthetic ${code} for load testing`, httpStatus: httpCoded.includes(code) ? pick(rng, [403, 404, 410, 429, 500, 502, 503]) : null, retryable, cause: null, attempt: int(rng, 1, 4), nextRetryAt: null, }; } export interface SynthOptions { readonly count: number; readonly seed: number; readonly categories: readonly Category[]; readonly queues: readonly Queue[]; readonly baseSpeedBps: number; } export interface SynthResult { readonly tasks: TaskSummary[]; /** taskIds seeded directly into 'downloading', in creation order. */ readonly initiallyActive: string[]; /** taskId -> the ordered position it holds in its queue (1-based), for tasks seeded * 'queued'. Bulk-seeding needs this to rebuild state.ts's per-queue FIFOs without * re-deriving order from creation time. */ readonly queuedOrder: Map; // queueId -> taskIds, front to back /** taskId -> a per-task speed multiplier, so active downloads don't all move in lockstep. */ readonly speedFactor: Map; } // Weights need not sum to any particular total; they are normalised below. Kept as a flat // list (not a Record) so the generator can walk it in one pass while assigning position // within each state's own counter (queuePosition, retry delay, etc). const STATE_WEIGHTS: ReadonlyArray = [ ['complete', 55], ['failed', 8], ['cancelled', 4], ['paused', 6], ['retry_wait', 2], ['queued', 25], ]; export function generateSyntheticTasks(opts: SynthOptions): SynthResult { const rng = mulberry32(opts.seed); const tasks: TaskSummary[] = []; const initiallyActive: string[] = []; const queuedOrder = new Map(); const speedFactor = new Map(); const categories = opts.categories.length > 0 ? opts.categories : FALLBACK_CATEGORIES; const queues = opts.queues.length > 0 ? opts.queues : FALLBACK_QUEUES; for (const q of queues) queuedOrder.set(q.queueId, []); // Reserve a slice of the population to start already 'downloading', mid-transfer, so a // client that connects the instant mockd starts sees live rows immediately rather than // waiting for the first promotion from the queue. const activeSeed = Math.min(opts.count, DEFAULT_ACTIVE_CAP); const totalWeight = STATE_WEIGHTS.reduce((sum, [, w]) => sum + w, 0); const remaining = Math.max(0, opts.count - activeSeed); // How many of the `remaining` tasks get each state, largest remainder method so the // rounding doesn't silently drop or duplicate a task at small N. const quotas = new Map(); let assigned = 0; const remainders: Array<[TaskState, number]> = []; for (const [state, weight] of STATE_WEIGHTS) { const exact = (remaining * weight) / totalWeight; const floor = Math.floor(exact); quotas.set(state, floor); assigned += floor; remainders.push([state, exact - floor]); } remainders.sort((a, b) => b[1] - a[1]); for (let i = 0; i < remaining - assigned; i += 1) { const state = remainders[i % remainders.length]![0]; quotas.set(state, (quotas.get(state) ?? 0) + 1); } const now = Date.now(); const spawn = (state: TaskState): TaskSummary => { const category = pick(rng, categories); const ext = pick(rng, category.extensions.length > 0 ? category.extensions : ['bin']); const filename = synthFilename(rng, ext); const sizeBytes = logUniform(rng, 50_000, 20_000_000_000); const resumable = rng() > 0.08; const segments = resumable ? pick(rng, [1, 2, 4, 4, 8, 8, 8, 16]) : 1; // Spread creation times over the past 30 days so the table doesn't show one instant. const createdAt = new Date(now - int(rng, 0, 30 * 24 * 3600) * 1000).toISOString(); const task: TaskSummary = { taskId: randomUUID(), filename, saveDir: category.saveDir, url: `https://${synthHost(rng)}/${category.categoryId}/${filename}`, effectiveUrl: null, sizeBytes, downloadedBytes: 0, state, speedBps: 0, etaSeconds: null, resumable, segments, categoryId: category.categoryId, queueId: null, queuePosition: null, description: rng() > 0.85 ? pick(rng, NOUNS) : null, createdAt, lastTryAt: null, completedAt: null, error: null, }; speedFactor.set(task.taskId, 0.3 + rng() * 1.7); return task; }; for (const [state, quota] of quotas) { for (let i = 0; i < quota; i += 1) { const task = spawn(state); switch (state) { case 'complete': { task.downloadedBytes = task.sizeBytes ?? 0; task.lastTryAt = task.createdAt; task.completedAt = new Date( new Date(task.createdAt).getTime() + int(rng, 5, 3600) * 1000, ).toISOString(); break; } case 'failed': { const code = randomTaskErrorCode(rng, false); task.error = taskErrorFor(rng, code, false); task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng() * 0.6); task.lastTryAt = task.createdAt; break; } case 'cancelled': { task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng() * 0.4); task.lastTryAt = task.createdAt; break; } case 'paused': { task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng()); task.lastTryAt = task.createdAt; break; } case 'retry_wait': { const code = randomTaskErrorCode(rng, true); task.error = taskErrorFor(rng, code, true); task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng() * 0.5); task.lastTryAt = task.createdAt; break; } case 'queued': { // ~80/20 split across the first two queues, matching Main/Sync in the fixtures; // falls back to the single available queue if fewer than two exist. const queue = queues.length > 1 && rng() < 0.8 ? queues[0]! : pick(rng, queues); task.queueId = queue.queueId; const order = queuedOrder.get(queue.queueId)!; order.push(task.taskId); task.queuePosition = order.length; break; } default: break; } tasks.push(task); } } // The reserved active slice: mid-transfer, non-zero progress, so it reads as "already // running" rather than "just started" the moment mockd comes up. for (let i = 0; i < activeSeed; i += 1) { const task = spawn('downloading'); task.downloadedBytes = Math.floor((task.sizeBytes ?? 0) * rng() * 0.7); task.lastTryAt = task.createdAt; tasks.push(task); initiallyActive.push(task.taskId); } return { tasks, initiallyActive, queuedOrder, speedFactor }; } export const DEFAULT_ACTIVE_CAP = 24; const FALLBACK_CATEGORIES: readonly Category[] = [ { categoryId: 'compressed', name: 'Compressed', saveDir: '/home/sami/Downloads/Compressed', extensions: ['zip', 'tar', 'gz'], mimeTypes: [], builtin: true, sortOrder: 0 }, ]; const FALLBACK_QUEUES: readonly Queue[] = [ { queueId: 'main', name: 'Main Queue', state: 'running', maxConcurrent: 3, taskIds: [], schedule: null, onComplete: 'nothing' }, ];