GUI's M1 definition of done is "10 000 synthetic rows scroll at 60 fps with flat memory over 10 minutes (mockd --tasks 10000)". This flag was missing from the four unhappy-path flags that did land; the brief's own flag list omitted it, which is corrected here too. --tasks seeds a plausible population rather than N copies of one row: varied state, size (log-uniform 50 KB - 20 GB), category, queue position and description, drawn from the same category.list / queue.list fixtures the rest of mockd already serves so a synthetic task can never name a category or queue those methods don't also return. State distribution is roughly 55% complete / 8% failed / 4% cancelled / 6% paused / 2% retry_wait / 25% queued, using the new TaskErrorCode taxonomy for failures. "Progress advances across the whole set, not a handful of live rows" ruled out the obvious cheap answer. A bounded, rotating pool of concurrently-active downloads (--active-cap, default 24) is fed continuously from each queue's FIFO — with the rest of that queue's queuePosition renumbered on every promotion, as a real scheduler would — and a small fraction of active tasks hit a transient failure and cycle through retry_wait before rejoining, so the pool keeps rotating through new rows for the whole run instead of draining once. Verified over a 10000-task, 60-second run: 61.5 MB RSS flat, and the active pool's membership meaningfully different after 60s. tick() only ever walks the active pool plus due retry-wait entries, never the full task list, so its cost stays flat regardless of --tasks. A manual download.add is still admitted immediately regardless of --active-cap — a human driving the GUI by hand must never wait behind synthetic load. Fixed a latent double-push while building this: any task 'connecting' at the top of a tick was pushed to the progress batch once for the transition and again at the loop's unconditional final push, inflating event.task.progress payloads with a duplicate entry for that taskId. It predates this change (the original tick() had the same shape) but only became visible once several tasks are legitimately 'connecting' in the same tick, which --active-cap's continuous promotion now does routinely. --seed makes a run reproducible, which matters when a GUI bug only shows up at a particular row. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012fgjnqFCS5h5L7gZTZo3rV
286 lines
12 KiB
TypeScript
286 lines
12 KiB
TypeScript
#!/usr/bin/env -S npx tsx
|
|
/**
|
|
* mockd — a fake veloxd that is good enough to build a GUI and an extension against.
|
|
*
|
|
* It serves contracts/fixtures over both transports, keeps just enough state that adding
|
|
* and pausing a download does something visible, and fakes progress events at 4 Hz. Its
|
|
* unhappy-path flags exist so the GUI and EXT lanes can test the cases that are hard to
|
|
* arrange on purpose — a slow daemon, a flaky one, a dropped socket, a refused pairing —
|
|
* long before the real daemon exists.
|
|
*
|
|
* npm start -- --help
|
|
*/
|
|
|
|
import { resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { rmSync } from 'node:fs';
|
|
|
|
import { Dispatcher, type Session } from './dispatch.js';
|
|
import { indexByMethod, loadFixtures, resolvePlaceholders } from './fixtures.js';
|
|
import { MockState } from './state.js';
|
|
import { DEFAULT_ACTIVE_CAP, generateSyntheticTasks } from './synth.js';
|
|
import { startUds, type Connection } from './transport/uds.js';
|
|
import { startWs } from './transport/ws.js';
|
|
import type { Category, Queue, TaskSummary } from '../../../extension/src/shared/protocol/types.js';
|
|
import { PROTOCOL_VERSION } from '../../../extension/src/shared/protocol/types.js';
|
|
|
|
const HERE = resolve(fileURLToPath(import.meta.url), '..');
|
|
const REPO = resolve(HERE, '..', '..', '..');
|
|
const FIXTURE_DIR = resolve(REPO, 'contracts', 'fixtures');
|
|
|
|
const USAGE = `mockd — mock veloxd, serving contracts/fixtures over both transports
|
|
|
|
--uds <path> Unix socket path
|
|
(default: $XDG_RUNTIME_DIR/velox/velox.sock)
|
|
--ws-port <n> loopback WebSocket port (default: 52000)
|
|
--no-uds do not listen on the Unix socket
|
|
--no-ws do not listen on the WebSocket
|
|
--progress-hz <n> progress event rate (default: 4, the contract's maximum)
|
|
--speed <bytes> synthetic per-task speed in bytes/sec (default: 8388608)
|
|
|
|
Load testing, for the GUI lane's "N rows at 60 fps" definition of done:
|
|
--tasks <n> seed n plausible synthetic tasks — varied states, sizes,
|
|
speeds, categories and queue positions — instead of the two
|
|
from contracts/fixtures/download.list.json. A bounded, rotating
|
|
pool of active downloads (see --active-cap) keeps progress
|
|
moving across a broad slice of the table for as long as mockd
|
|
runs, not just the first few rows.
|
|
e.g. mockd --tasks 10000
|
|
--active-cap <n> ceiling on concurrently-"downloading" synthetic tasks
|
|
(default: 24). Has no effect on a manual download.add, which is
|
|
always admitted immediately.
|
|
--seed <n> PRNG seed for --tasks, so a given seed reproduces the exact
|
|
same table (default: 1337)
|
|
|
|
Unhappy paths, for the GUI and EXT lanes:
|
|
--slow <ms> delay every reply by <ms>. Past 750 ms, capture.offer must
|
|
fail open and let Firefox take the download.
|
|
--flaky <0..1> answer this fraction of calls with -32603
|
|
--drop-connection <s> terminate every connection every <s> seconds
|
|
--refuse-pairing session.pair always fails, as if the user clicked Deny
|
|
--lockout session.pair answers -32014, as if the brute-force lockout tripped
|
|
--allowed-root <dir> add a root that download.add's saveDir may resolve inside
|
|
--allow-any-origin skip the moz-extension:// Origin check (debugging only)
|
|
--no-validate do not validate params against the generated validators
|
|
|
|
-h, --help this message
|
|
`;
|
|
|
|
interface Args {
|
|
uds: string | null;
|
|
wsPort: number | null;
|
|
progressHz: number;
|
|
speed: number;
|
|
tasks: number;
|
|
activeCap: number;
|
|
seed: number;
|
|
slow: number;
|
|
flaky: number;
|
|
dropEverySec: number;
|
|
refusePairing: boolean;
|
|
allowAnyOrigin: boolean;
|
|
validate: boolean;
|
|
lockout: boolean;
|
|
allowedRoots: string[];
|
|
}
|
|
|
|
function parseArgs(argv: readonly string[]): Args {
|
|
const runtime = process.env['XDG_RUNTIME_DIR'] ?? `/run/user/${process.getuid?.() ?? 1000}`;
|
|
const args: Args = {
|
|
uds: resolve(runtime, 'velox', 'velox.sock'),
|
|
wsPort: 52000,
|
|
progressHz: 4,
|
|
speed: 8 * 1024 * 1024,
|
|
tasks: 0,
|
|
activeCap: DEFAULT_ACTIVE_CAP,
|
|
seed: 1337,
|
|
slow: 0,
|
|
flaky: 0,
|
|
dropEverySec: 0,
|
|
refusePairing: false,
|
|
allowAnyOrigin: false,
|
|
validate: true,
|
|
lockout: false,
|
|
allowedRoots: [resolve(process.env['HOME'] ?? '/home/sami', 'Downloads'), '/tmp'],
|
|
};
|
|
|
|
for (let i = 0; i < argv.length; i += 1) {
|
|
const arg = argv[i];
|
|
const next = (): string => {
|
|
const v = argv[i + 1];
|
|
if (v === undefined) throw new Error(`${arg} needs a value`);
|
|
i += 1;
|
|
return v;
|
|
};
|
|
switch (arg) {
|
|
case '-h': case '--help': process.stdout.write(USAGE); process.exit(0); break;
|
|
case '--uds': args.uds = next(); break;
|
|
case '--no-uds': args.uds = null; break;
|
|
case '--ws-port': args.wsPort = Number(next()); break;
|
|
case '--no-ws': args.wsPort = null; break;
|
|
case '--progress-hz': args.progressHz = Number(next()); break;
|
|
case '--speed': args.speed = Number(next()); break;
|
|
case '--tasks': args.tasks = Number(next()); break;
|
|
case '--active-cap': args.activeCap = Number(next()); break;
|
|
case '--seed': args.seed = Number(next()); break;
|
|
case '--slow': args.slow = Number(next()); break;
|
|
case '--flaky': args.flaky = Number(next()); break;
|
|
case '--drop-connection': args.dropEverySec = Number(next()); break;
|
|
case '--refuse-pairing': args.refusePairing = true; break;
|
|
case '--lockout': args.lockout = true; break;
|
|
case '--allowed-root': args.allowedRoots.push(resolve(next())); break;
|
|
case '--allow-any-origin': args.allowAnyOrigin = true; break;
|
|
case '--no-validate': args.validate = false; break;
|
|
default:
|
|
process.stderr.write(`mockd: unknown option ${arg}\n\n${USAGE}`);
|
|
process.exit(2);
|
|
}
|
|
}
|
|
return args;
|
|
}
|
|
|
|
function main(): void {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const log = (msg: string): void => {
|
|
process.stdout.write(`[${new Date().toISOString()}] ${msg}\n`);
|
|
};
|
|
|
|
const fixtures = loadFixtures(FIXTURE_DIR);
|
|
const byMethod = indexByMethod(fixtures);
|
|
log(`loaded ${fixtures.length} fixtures covering ${byMethod.size} methods from contracts/fixtures`);
|
|
|
|
// Seed the task list from the download.list fixture, so a client that connects before
|
|
// adding anything still has rows to draw. --tasks replaces this with a much larger
|
|
// synthetic population instead — see below.
|
|
const listFixture = byMethod.get('download.list');
|
|
const seed = args.tasks > 0 ? [] : (resolvePlaceholders(
|
|
(listFixture?.response as { result?: { items?: unknown[] } } | undefined)?.result?.items ?? [],
|
|
) as TaskSummary[]);
|
|
|
|
const state = new MockState(seed, {
|
|
progressHz: args.progressHz, speedBps: args.speed, activeCap: args.activeCap,
|
|
});
|
|
|
|
if (args.tasks > 0) {
|
|
// Categories and queues come from their own golden fixtures rather than being
|
|
// reinvented here, so a --tasks run can never drift from what category.list and
|
|
// queue.list actually serve — the extension's default-category picker and the GUI's
|
|
// category tree see the same ids these synthetic tasks are filed under.
|
|
const categories = (resolvePlaceholders(
|
|
(byMethod.get('category.list')?.response as { result?: { items?: unknown[] } } | undefined)
|
|
?.result?.items ?? [],
|
|
) as Category[]);
|
|
const queues = (resolvePlaceholders(
|
|
(byMethod.get('queue.list')?.response as { result?: { items?: unknown[] } } | undefined)
|
|
?.result?.items ?? [],
|
|
) as Queue[]);
|
|
|
|
const started = Date.now();
|
|
const synthetic = generateSyntheticTasks({
|
|
count: args.tasks, seed: args.seed, categories, queues, baseSpeedBps: args.speed,
|
|
});
|
|
state.seedSynthetic(synthetic);
|
|
log(`--tasks ${args.tasks}: generated in ${Date.now() - started} ms `
|
|
+ `(${synthetic.initiallyActive.length} active now, active-cap ${args.activeCap}, seed ${args.seed})`);
|
|
}
|
|
const dispatcher = new Dispatcher(state, byMethod, {
|
|
protocolVersion: PROTOCOL_VERSION,
|
|
daemonVersion: '0.0.0-mockd',
|
|
refusePairing: args.refusePairing,
|
|
flaky: args.flaky,
|
|
validate: args.validate,
|
|
allowedRoots: args.allowedRoots,
|
|
lockout: args.lockout,
|
|
});
|
|
|
|
const connections = new Set<Connection>();
|
|
const broadcast = (method: string, params: unknown): void => {
|
|
const frame = { jsonrpc: '2.0', method, params };
|
|
for (const conn of connections) {
|
|
if (conn.session.subscribed.has(method)) conn.send(frame);
|
|
}
|
|
};
|
|
|
|
if (args.uds !== null) {
|
|
startUds(args.uds, dispatcher, connections, log, args.slow);
|
|
const path = args.uds;
|
|
const cleanup = (): void => {
|
|
rmSync(path, { force: true });
|
|
process.exit(0);
|
|
};
|
|
process.on('SIGINT', cleanup);
|
|
process.on('SIGTERM', cleanup);
|
|
}
|
|
if (args.wsPort !== null) {
|
|
startWs({ port: args.wsPort, delayMs: args.slow, dropEverySec: args.dropEverySec,
|
|
allowAnyOrigin: args.allowAnyOrigin }, dispatcher, connections, log);
|
|
}
|
|
|
|
// Progress at the contract's 4 Hz ceiling, as one batched array — never one
|
|
// notification per task. The GUI lane needs this shape to build its coalescing against.
|
|
setInterval(() => {
|
|
const { moved, completed, failed } = state.tick();
|
|
// A progress payload only makes sense for a still-active task; a task that just
|
|
// finished (either direction) gets its own event.task.state below instead, so the two
|
|
// events never race on the same tick with contradictory numbers.
|
|
const stillProgressing = moved.filter((t) => t.state === 'downloading');
|
|
if (stillProgressing.length > 0) {
|
|
broadcast('event.task.progress', {
|
|
tasks: stillProgressing.map((t) => ({
|
|
taskId: t.taskId,
|
|
downloadedBytes: t.downloadedBytes,
|
|
speedBps: t.speedBps,
|
|
etaSeconds: t.etaSeconds,
|
|
segments: Array.from({ length: Math.min(t.segments, 8) }, (_, i) => ({
|
|
index: i,
|
|
downloadedBytes: Math.floor(t.downloadedBytes / t.segments),
|
|
speedBps: Math.floor(t.speedBps / t.segments),
|
|
})),
|
|
})),
|
|
at: new Date().toISOString(),
|
|
});
|
|
}
|
|
for (const task of completed) {
|
|
broadcast('event.task.state', {
|
|
taskId: task.taskId, state: 'complete', previousState: 'downloading',
|
|
summary: task, error: null,
|
|
});
|
|
broadcast('event.notify', {
|
|
level: 'success', title: 'Download complete',
|
|
body: `${task.filename} finished.`, taskId: task.taskId, sound: 'complete',
|
|
});
|
|
}
|
|
for (const task of failed) {
|
|
broadcast('event.task.state', {
|
|
taskId: task.taskId, state: task.state, previousState: 'downloading',
|
|
summary: task, error: task.error,
|
|
});
|
|
if (task.state === 'failed') {
|
|
broadcast('event.notify', {
|
|
level: 'error', title: 'Download failed',
|
|
body: `${task.filename}: ${task.error?.message ?? 'unknown error'}`,
|
|
taskId: task.taskId, sound: 'error',
|
|
});
|
|
}
|
|
}
|
|
}, Math.max(1, Math.round(1000 / args.progressHz)));
|
|
|
|
setInterval(() => {
|
|
const speed = state.globalSpeed();
|
|
broadcast('event.speed.global', {
|
|
...speed,
|
|
limitBps: state.limiter.enabled ? state.limiter.globalBps : null,
|
|
});
|
|
}, 1000);
|
|
|
|
log(`protocol v${PROTOCOL_VERSION}; ${state.tasks.size} seeded task(s)`);
|
|
if (args.slow > 750) {
|
|
log(`WARNING --slow ${args.slow} exceeds capture.offer's 750 ms deadline: a correct extension will fail open`);
|
|
}
|
|
}
|
|
|
|
main();
|
|
|
|
// Referenced so the Session type stays exported for transport implementations.
|
|
export type { Session };
|