Files
samiandClaude Sonnet 5 4e177ec809 proto: stop conformance from downloading real files, ADR the null-clearing gap
1. download.add.json had startMode "now" against a real, large (~6 GB)
   Ubuntu ISO with saveDir hardcoded to /home/sami/Downloads/Programs.
   Against a real veloxd (tests/conformance/run.sh) that's a real
   download into the real user's real home, every single run — it had
   already happened twice. startMode -> "later" (exercises the add path,
   hands nothing to the engine) and saveDir is dropped entirely (resolves
   to saveTo.defaultDir instead, checked against allowedRoots the same
   way). Documented the rule this fixture was breaking in
   contracts/fixtures/README.md so it doesn't happen a third time.

   Auditing the rest for the same shape (now real: capture.offer, D7)
   found a second, subtler instance: capture.offer.take.json's "take"
   admits a real, immediately-started task the same way download.add
   does, and the "Programs" category's saveDir is a migration-seeded
   builtin (~/Downloads/Programs) that no isolated test setup can
   redirect -- so even after pointing the URL at example.org (RFC 2606),
   a real ~6 GB sparse .veloxpart still landed in the real home on the
   declared Content-Length alone. Shrunk to a plausible-but-small 5 MiB.
   Also scoped to "transport": "uds" -- a real "take" persists an active
   task, so replaying the same fixture again on the second live transport
   against the same shared daemon was hitting capture.offer's own
   dedupe-by-URL and failing on a missing taskId, not a bug.
   download.add's other real-URL siblings (errors/*.invalid-path,
   *.invalid-params, *.disk-full) all fail before admission or are
   requires-gated; left alone.

2. ADR 0018: DAEMON can set a nullable field through download.update /
   settings.set but never clear it back to null, because the generated
   C++ parser collapses "absent" and "explicit null" to the same
   std::nullopt for every optional field (contracts/codegen/gen_cpp.py,
   on purpose, and correct for create-style params -- just wrong for
   patch-style ones, which is the only place the schema documents
   "explicit null clears"). Decision: an opt-in x-clearable schema
   annotation makes just those fields std::optional<std::optional<T>> in
   C++ (TS already round-trips this natively); not a blanket rule
   (would retype response fields like TaskSummary.effectiveUrl that have
   no clear-vs-absent distinction to make), not an explicit clear-list
   field (would redesign a wire contract DAEMON already built against
   just to route around a generator gap). Recorded, not implemented here
   -- that's its own PROTO PR (schema annotations + gen_cpp.py + gen_ts.py
   + regeneration + a minor VERSION bump per ADR 0015), not bundled into
   a fixture-safety pass. Left a pointer to the ADR at the generator
   comment it concerns.

3. Re-verified every xfail entry against current deferrals.md rather
   than trust the reasons already on file: D7/D8 (capture.offer/
   getRules), D3d/e/f/g/h/i (rules, queue.reorder, schedule, limiter,
   download.update/refreshUrl) and D9 (settings) have all closed since
   the list was last pruned, so most of it was stale. Removed everything
   that now cleanly passes; kept and re-reasoned everything that doesn't:
   - errors/download.provideAuth.not-found.json stays, as asked: real
     bug, on_download_provideAuth never checks the task exists.
   - category.list.json (mimeTypes -- documented D3a gap), schedule.set.json
     (nextRunAt -- documented D3f gap): unchanged in substance, reason
     text was already accurate.
   - download.probe/get/list/update.json, session.hello.json,
     queue.start/reorder.json, category.remove.json: not bugs -- each
     golden depicts a richer lifecycle/config state (a probed download,
     real queue or category membership, media/grabber capabilities) than
     this harness's fresh, never-started bound tasks and empty isolated
     DB can produce.
   - limiter.get.json: real fixture bug, not a daemon one -- applyToRunning
     is a write-only instruction on limiter.set, on_limiter_get never
     returns it; the golden shouldn't have had it either. Fixed the
     fixture and tools/mockd's own limiter.get, which had the same field
     hardcoded into its in-memory state independent of the fixture file.
   - grabber.*/media.*: still genuinely stub (M4 territory).
   Only remaining unexpected-pass surfaced while re-verifying
   (errors/capture.offer.ignore.json, always "take" instead of "ignore")
   traced to capture.minSizeBytes defaulting to 0 on a fresh daemon,
   making its below-minimum-size scenario unreachable -- not a bug, so
   raised the setting in run.sh's isolated seeding instead of xfailing it.

ctest -L conformance: green, 100% (2/2), ~87s.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SFeUKLbdHizrJjLBeK7ffz
2026-09-12 16:56:35 +04:00
..

tools/mockd — a fake veloxd

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.

The GUI and extension lanes develop against this from day one and never wait for the real daemon. Its unhappy-path flags exist so those lanes can test the cases that are hard to arrange on purpose — a slow daemon, a flaky one, a dropped socket, a refused pairing.

cd tools/mockd
npm install
npm start -- --help
npm start                      # both transports, default paths

Defaults: $XDG_RUNTIME_DIR/velox/velox.sock and ws://127.0.0.1:52000.

Flags

Flag Effect
--uds <path> / --no-uds Unix socket path, or don't listen
--ws-port <n> / --no-ws loopback WebSocket port, or don't listen
--progress-hz <n> progress event rate (default 4, the contract's ceiling)
--speed <bytes> synthetic per-task speed
--tasks <n> seed n plausible synthetic tasks instead of the fixture's two — see below
--active-cap <n> ceiling on concurrently-"downloading" synthetic tasks (default 24)
--seed <n> PRNG seed for --tasks, so a run is exactly reproducible (default 1337)
--slow <ms> delay every reply. Past 750 ms capture.offer must fail open.
--flaky <0..1> answer this fraction of calls with -32603
--drop-connection <s> terminate every connection every N seconds
--refuse-pairing session.pair 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 stop validating params (to see what a client actually sends)

--tasks — load testing the GUI's table

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 takes more than 10 000 identical rows:

npm start -- --tasks 10000

Seeds a plausible population — varied state, size, category, queue position and description, drawn from the same category.list / queue.list fixtures the rest of mockd serves, so nothing here can name a category or queue those methods don't also return. Roughly 55% land complete, the rest split across failed, cancelled, paused, retry_wait and queued, plus a bounded pool (--active-cap, default 24) seeded straight into downloading.

That pool is rotating, not fixed: 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 small fraction of "finishing" active tasks fail instead and cycle through retry_wait before rejoining. Over a ten-minute run this means hundreds of distinct rows have shown live progress by the time it ends, not the same handful forever, while at any instant the active count stays realistic. A manual download.add is always admitted immediately regardless of --active-cap — a human driving the GUI by hand is never made to wait behind synthetic load.

tick() only ever walks the active pool plus whatever retry-wait entries just came due, never the full task list, so the per-tick cost stays flat regardless of --tasks.

--seed makes a run reproducible: the same seed always produces the same table, which matters when a GUI bug only shows up at a particular row.

What is real and what is faked

Real, because a client's correctness depends on it:

  • transport and privilege rules, taken from the generated METHODS table — so a privileged method is refused with -32003 over the WebSocket exactly as veloxd must;
  • param validation, through the generated validators, including range and length checks;
  • saveDir canonicalization against the allowed roots, so -32011 is reachable;
  • the capture.offer decision — monitored types, minimum size, excluded hosts — so both the take and the ignore paths get exercised;
  • pairing: session.hello accepts only a token this process actually issued;
  • task state, so add / pause / resume / cancel / remove do what a client expects to see.

Faked: bytes advance on a clock, not from a socket. There is no network, no disk, and no engine. Anything not listed above is answered from its golden fixture.

Why it imports the generated protocol code

mockd uses extension/src/shared/protocol/ — the generated TypeScript — rather than types of its own. A mock with hand-written types is a third source of truth, and it drifts. This way a schema change that breaks a client breaks mockd in the same commit.

It imports the individual generated modules (types.js, methods.js, …) rather than index.js, because the extension/ tree has no package.json of its own for Node to resolve a star re-export through. That is a quirk of running from outside that package, not a problem with the generated code; the extension's own bundler is unaffected.