1 Commits
Author SHA1 Message Date
samiandClaude Sonnet 5 c6f864ea30 gui: fix RpcClient double-free on stop() and the 1000-row list cap
Both found live while building gui/tests/dod's DoD harness, not from reading
the code — see that commit for how.

RpcClient::stop() left conn_ dangling after joining the worker thread: the
thread's own finish() flushes the DeferredDelete stop()'s
connect(&thread_, &QThread::finished, conn_, &QObject::deleteLater) already
posted, so conn_ is gone by the time stop() returns, but nothing cleared the
pointer. Any caller that calls stop() and later lets the client destruct
(the harness's own client.stop() at shutdown; also plain, correct API usage)
hit a double-free in the destructor's leftover `delete conn_`. Caught by
ASan on the very first run that actually exercised the stop-then-destroy
path.

requestInitialList() also called download.list with a hardcoded
`{"limit": 1000}`, silently capping the table at 1000 rows no matter how
many the daemon actually has — download.list.schema.json's own description
says "the GUI pages", not "the GUI takes it all in one call". The
scroll-60fps DoD gate refused to run against mockd --tasks 10000 rather
than "pass" against a 1000-row table, which is what surfaced it.
requestInitialList() now pages (5000 per call, the schema's own max) until
`total` is satisfied, then resets the model once with everything.

Separately: RpcConnection's session.subscribe list never included
event.settings.changed or event.grabber.progress, even though RpcClient has
carried signals for both since the Options/Grabber work — session.subscribe
"replaces the previous selection" and "nothing is delivered until this is
called", so both events were being silently dropped by any real daemon that
enforces the subscription (mockd does; verified live with a second
subscribed client actually receiving event.settings.changed after this
fix, round-tripped through a real veloxd's settings.set). GrabberWizard's
5 s poll fallback is exactly why this went unnoticed until now — it covered
for the missing push the whole time.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NSCdCWFXBTSBBK3MzWtJiC
2026-09-12 21:27:28 +04:00
12 changed files with 98 additions and 198 deletions
+1 -6
View File
@@ -506,12 +506,7 @@ def emit_field_parse(f: Field, indent: str) -> list[str]:
f'{i} const auto it = j.find("{f.name}");'] f'{i} const auto it = j.find("{f.name}");']
if f.optional: if f.optional:
# Absent and null mean the same thing: the field is not set. A client that omits # Absent and null mean the same thing: the field is not set. A client that omits
# a nullable field and one that sends null are treated identically on purpose -- # a nullable field and one that sends null are treated identically on purpose.
# correct for create-style params, where there is no existing value to distinguish
# "never set" from "explicitly cleared". Patch-style fields need the distinction
# (download.update's patch: "an explicit null clears a nullable field") and get an
# opt-in exception via x-clearable per ADR 0018 (not implemented yet: this is the
# decision record, not the generator change).
o.append(f"{i} if (it != j.end() && !it->is_null()) {{") o.append(f"{i} if (it != j.end() && !it->is_null()) {{")
o += emit_value_parse(f.type, "(*it)", "val", "fp", i + " ") o += emit_value_parse(f.type, "(*it)", "val", "fp", i + " ")
o.append(f"{i} out.{m} = std::move(val);") o.append(f"{i} out.{m} = std::move(val);")
+1 -25
View File
@@ -21,7 +21,7 @@ fixtures/
```jsonc ```jsonc
{ {
"name": "download.add — add an ISO for later, into the Programs category", "name": "download.add — start an ISO now, into the Programs category",
"description": "Why this case is worth pinning.", "description": "Why this case is worth pinning.",
"transport": "uds", // optional: replay only on this transport "transport": "uds", // optional: replay only on this transport
"requires": "...", // optional: a condition a plain server cannot produce "requires": "...", // optional: a condition a plain server cannot produce
@@ -81,27 +81,3 @@ cases in `tests/integration/`.
correct response is *no response*: past 750 ms the extension must abandon the offer and let correct response is *no response*: past 750 ms the extension must abandon the offer and let
Firefox download normally. A download manager that eats downloads when its daemon is down Firefox download normally. A download manager that eats downloads when its daemon is down
is worse than no download manager. is worse than no download manager.
## No fixture may pair a real external URL with `startMode: "now"`
This suite replays every fixture against a real, live `veloxd` (`tests/conformance/run.sh`),
not just `mockd`. `mockd` never actually fetches anything, so it hid this for a while: a
fixture with `startMode: "now"` (or `"queue"` into a running queue — anything that gets
admitted to the scheduler right away) and a real, resolvable URL makes a **real** daemon
actually start downloading it, for real, onto whatever machine runs the suite. This
happened — twice, with `download.add.json` pointed at a ~6 GB Ubuntu ISO, straight into the
developer's real `~/Downloads`.
The fix in each case is one of:
- `startMode: "later"` — exercises the add path (validation, category assignment, the
event) without ever handing the task to the engine;
- a URL under `example.org`/`example.com` (IANA-reserved for exactly this, RFC 2606) —
resolvable enough to validate as a URL, never a real download source;
- `requires`, if the fixture's entire point needs a real transfer to fail in a specific way
(see `errors/download.add.disk-full.json`) — skipped by default, so it only ever runs
where the condition has actually been arranged.
A real `saveDir` gets the same treatment for the same reason: an absolute path like
`/home/sami/Downloads/...` only means anything on the machine that fixture was written on.
Omit `saveDir` and let `saveTo.defaultDir` apply, or use a relative-feeling path under a
root the runner controls.
+5 -6
View File
@@ -1,23 +1,22 @@
{ {
"name": "capture.offer — attachment on a monitored type is taken", "name": "capture.offer — attachment on a monitored type is taken",
"description": "Golden fixture. tests/conformance replays this against the real daemon AND the TS client. If either side drifts, this goes red before the lanes ever integrate. url is example.org (RFC 2606), not a real download source: 'take' against a real veloxd (tests/conformance/run.sh) admits a real task and hands it to the engine for real, and no fixture may do that against a real external URL. contentLength is a plausible-but-small 5 MiB rather than a real ISO's size: the 'Programs' category's saveDir is a migration-seeded builtin (~/Downloads/Programs, daemon/src/store/migrations/0001_initial.sql), not something an isolated test run's settings can redirect, so 'take' always sparse-preallocates into that real path on whatever machine runs this suite -- keeping the declared size small keeps that footprint trivial instead of a real ISO's worth of disk. transport is uds only: a real 'take' persists an active task, so replaying this same fixture again on a second live transport against the same daemon would correctly dedupe against it (capture.offer dedupes by exact URL) and get 'ignore' instead -- an artifact of replaying one fixture against one shared daemon over two transports, not a behaviour to golden.", "description": "Golden fixture. tests/conformance replays this against the real daemon AND the TS client. If either side drifts, this goes red before the lanes ever integrate.",
"transport": "uds",
"request": { "request": {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 42, "id": 42,
"method": "capture.offer", "method": "capture.offer",
"params": { "params": {
"url": "https://example.org/dl/ubuntu-26.04-desktop-amd64.iso", "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"method": "GET", "method": "GET",
"tabUrl": "https://example.org/26.04/", "tabUrl": "https://releases.ubuntu.com/26.04/",
"headers": { "headers": {
"User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:154.0) Gecko/20100101 Firefox/154.0", "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:154.0) Gecko/20100101 Firefox/154.0",
"Referer": "https://example.org/26.04/", "Referer": "https://releases.ubuntu.com/26.04/",
"Accept": "*/*" "Accept": "*/*"
}, },
"cookies": [], "cookies": [],
"contentType": "application/octet-stream", "contentType": "application/octet-stream",
"contentLength": 5242880, "contentLength": 6228541440,
"contentDisposition": "attachment; filename=\"ubuntu-26.04-desktop-amd64.iso\"", "contentDisposition": "attachment; filename=\"ubuntu-26.04-desktop-amd64.iso\"",
"filename": "ubuntu-26.04-desktop-amd64.iso", "filename": "ubuntu-26.04-desktop-amd64.iso",
"origin": "moz-extension://11111111-2222-3333-4444-555555555555" "origin": "moz-extension://11111111-2222-3333-4444-555555555555"
+7 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "download.add — add an ISO for later, into the Programs category", "name": "download.add \u2014 start an ISO now, into the Programs category",
"description": "The ordinary add path. saveDir is canonicalized and checked against the allowed roots before anything is written. startMode is 'later' deliberately: this suite replays against a real veloxd (tests/conformance/run.sh), and a real daemon given startMode 'now' would actually start fetching url for real. No fixture may pair a real external URL with startMode 'now' -- see contracts/fixtures/README.md.", "description": "The ordinary add path. saveDir is canonicalized and checked against the allowed roots before anything is written.",
"request": { "request": {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 11, "id": 11,
@@ -8,9 +8,10 @@
"params": { "params": {
"url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso", "url": "https://releases.ubuntu.com/26.04/ubuntu-26.04-desktop-amd64.iso",
"filename": "ubuntu-26.04-desktop-amd64.iso", "filename": "ubuntu-26.04-desktop-amd64.iso",
"saveDir": "/home/sami/Downloads/Programs",
"categoryId": "programs", "categoryId": "programs",
"segments": 8, "segments": 8,
"startMode": "later" "startMode": "now"
} }
}, },
"response": { "response": {
@@ -18,13 +19,13 @@
"id": 11, "id": 11,
"result": { "result": {
"taskId": "$uuid", "taskId": "$uuid",
"state": "paused", "state": "connecting",
"duplicate": null "duplicate": null
} }
}, },
"assertions": [ "assertions": [
"saveDir is omitted here on purpose: it resolves to saveTo.defaultDir, which is itself checked against saveTo.allowedRoots the same way an explicit saveDir would be -- see errors/download.add.invalid-path.json for the -32011 case", "the .veloxpart file is created sparse and preallocated at the final size",
"startMode 'later' lands the task in 'paused' and never hands it to the engine, so nothing is fetched and no .veloxpart is created yet -- that only happens once the task is actually started (download.start.json, or startMode 'now'/'queue' against a source this suite controls)", "saveDir resolves inside saveTo.allowedRoots, or the call fails -32011 having written nothing",
"event.task.added is emitted to every subscriber before this reply is sent" "event.task.added is emitted to every subscriber before this reply is sent"
] ]
} }
+4 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "limiter.get \u2014 the limiter is off", "name": "limiter.get \u2014 the limiter is off",
"description": "globalBps still carries the last configured value so the GUI can restore it when the user re-enables the limit. applyToRunning is a write-only instruction on limiter.set (\"retune already-running transfers now\", not a persisted setting), so it never comes back from get.", "description": "globalBps still carries the last configured value so the GUI can restore it when the user re-enables the limit.",
"request": { "request": {
"jsonrpc": "2.0", "jsonrpc": "2.0",
"id": 52, "id": 52,
@@ -12,11 +12,11 @@
"id": 52, "id": 52,
"result": { "result": {
"enabled": false, "enabled": false,
"globalBps": 2097152 "globalBps": 2097152,
"applyToRunning": false
} }
}, },
"assertions": [ "assertions": [
"enabled false means no throttling regardless of globalBps", "enabled false means no throttling regardless of globalBps"
"applyToRunning is absent, not false: it's meaningless outside a limiter.set call"
] ]
} }
@@ -1,107 +0,0 @@
# ADR 0018 — Nullable optional fields: absent vs. explicit null
**Status:** accepted · **Date:** 2026-09-13 · **Lane:** PROTO
**Prompted by:** a DAEMON report against `download.update`: the generated C++ parser gives
`VeloxDispatcher` no way to tell "the caller left this field alone" from "the caller wants
it cleared," so `download.update` and (the moment a nullable `SettingKey` exists)
`settings.set` can set a nullable field but never clear it back to `null`.
## Context
`download.update`'s `patch` object documents the convention plainly: "Only the present
fields change. An explicit null clears a nullable field." That is a deliberate, already-
committed wire contract — not something up for redesign here. The gap is one layer down:
`contracts/codegen/gen_cpp.py`'s `emit_field_parse` collapses "key absent" and "key present
with value `null`" to the same `std::nullopt`, on purpose, and the comment says so:
> Absent and null mean the same thing: the field is not set. A client that omits a
> nullable field and one that sends null are treated identically on purpose.
That collapse is *correct* for the common case — most nullable-optional fields are on
create-style params (`DownloadSpec.saveDir`, `.categoryId`, …) where there is no existing
value to distinguish "never set" from "explicitly cleared" in the first place; either way
the daemon just uses a default. It is wrong specifically for **patch-style** params, where
a field can already hold a value and the caller needs to say which of two different things
they mean: "leave it" or "clear it."
The schema IR (`schema_ir.py`) already tracks `required` and `nullable` as two independent
booleans per `Field`, so the information needed to make this distinction exists all the way
through parsing — `emit_field_parse` just doesn't act on it. Only `download.update`'s
`patch` object is affected today (`filename`, `saveDir`, `categoryId`, `queueId`,
`description`, `segments`, `bufferBytes`, `checksum` — all eight of its fields are
nullable-and-optional with exactly this "leave vs. clear" meaning). No `SettingKey` is
nullable yet, so `settings.set` has no live instance of the bug, but the same shape
(`values` patches an existing bag) means the first nullable settings key will hit the exact
same gap.
## Decision
**A JSON-null-aware optional, opt in per field via a new `x-clearable: true` annotation —
not a blanket rule and not a companion "clear list" field.**
- New per-field schema annotation, `x-clearable: true`, valid only on a field whose type
already includes `null` (schema error otherwise — clearable implies nullable). Marks
"this field distinguishes absent from explicit null"; every other nullable-optional field
keeps today's collapse.
- The generated C++ type for a `x-clearable` field becomes `std::optional<std::optional<T>>`:
outer `nullopt` = absent (leave unchanged), outer engaged with an inner `nullopt` =
explicit `null` (clear it), outer engaged with an inner value = set it. One field, three
states, no parallel bitset to keep in sync and no second field to forget to check.
- `emit_field_parse` for such a field stops folding `is_null()` into "absent": absent skips
the assignment (outer stays `nullopt`); present-and-null assigns an engaged-but-empty
inner optional; present-and-valued parses normally into the inner optional. Every other
field's codegen (the `required`/`nullable`-but-not-`clearable` majority) is unchanged.
- TypeScript needs no generator change: `field?: T | null` already round-trips this exactly
the way JSON does — an omitted key serializes as absent, `null` serializes as `null`, and
`"field" in obj` / `obj.field === null` already distinguish the three states natively.
This gap is a C++-generator-only problem.
- Applies now to `download.update`'s eight `patch` fields. `Settings` gets no annotation
today (nothing nullable to mark); the day a nullable `SettingKey` is added, it gets
`x-clearable: true` in the same PR, not left to rediscover this ADR.
## Versioning
Per ADR 0015: this retypes a generated C++ field (`optional<T>` -> `optional<optional<T>>`)
with the wire byte-for-byte unchanged — a client sending the same JSON parses correctly
either way. **Minor bump, with a migration note** for anyone reading `patch.filename` et al.
directly (unwrap twice: check the outer, then the inner). Not major; `session.hello`'s
major-only check must not refuse a wire-compatible peer over a binding-only change.
## Consequences
- `on_download_update` (DAEMON, not this lane) can finally implement "explicit null
clears": read the outer optional for presence, the inner for clear-vs-value, exactly the
three states the schema already promised.
- The collapse comment in `emit_field_parse` stays as the default behavior and gets a
pointer to this ADR for the opt-in exception, instead of being read as an oversight.
- Implementation (schema annotation support in `schema_ir.py`, the `gen_cpp.py` emission
change above, regenerating `core/generated/`, the `x-clearable: true` annotations on
`download.update`'s eight fields, the VERSION bump and migration note) is **not** done in
this change — recorded here so DAEMON isn't blocked on relitigating the design, tracked as
its own PROTO PR per the normal contracts process (schema + regenerated code + fixtures +
VERSION bump together, CLAUDE.md §2).
## Alternatives rejected
**An explicit clear list** (e.g. `patch.clearFields: ["categoryId", …]`, plain non-nullable
`optional<T>` fields otherwise). Rejected: the wire contract "an explicit null clears a
nullable field" is already written into `download.update`'s schema description and is what
DAEMON built against — this would be a real, disruptive wire redesign to route around a
generator gap, not a fix for it. It also doesn't compose: every patch-shaped object gains a
second array to keep in sync with the first, by hand, forever.
**A parallel "which fields were present" bitset** (struct of `optional<T>` fields plus a
sibling presence-flags struct or bitset). Rejected: two things to check per field instead
of one, and nothing stops a caller from reading the optional and forgetting the presence
bit — exactly the class of bug this ADR exists to close.
**Apply the tri-state to every `nullable && !required` field automatically**, using the IR
flags already present, no annotation needed. Rejected: `emit_field_parse` only backs
`parse<T>()`, used for *params* types the daemon receives — but the conformance C++ runner
also instantiates `parse<T>()` for **result** types (round-tripping golden fixtures), and
plenty of those are nullable-optional with no patch semantics at all (`TaskSummary.effectiveUrl`,
"null until the first probe succeeds" — a plain nullable value, not a leave-or-clear
choice). Blanket application would retype those too, forcing every read site across the
daemon that already does `if (summary.effectiveUrl)` into an unwanted double-unwrap for a
distinction that field doesn't have. Opt-in keeps the blast radius at exactly the fields
that need it.
+40 -6
View File
@@ -56,6 +56,13 @@ void RpcClient::stop() {
QMetaObject::invokeMethod(conn_, "stop", Qt::QueuedConnection); QMetaObject::invokeMethod(conn_, "stop", Qt::QueuedConnection);
thread_.quit(); thread_.quit();
thread_.wait(); thread_.wait();
// thread_.wait() does not return until thread_'s own finish() has already flushed the
// DeferredDelete this class's own connect(&thread_, &QThread::finished, conn_,
// &QObject::deleteLater) posted — conn_ is gone by now. Null it out so a later call
// (stop() is a public slot; a caller stopping and then destroying the client is normal
// use, and the destructor's own `delete conn_` for the never-started case must not
// run a second time against memory this path already freed).
conn_ = nullptr;
} }
void RpcClient::call(const QString &methodName, const QJsonObject &params, void RpcClient::call(const QString &methodName, const QJsonObject &params,
@@ -76,17 +83,44 @@ void RpcClient::onConnectionState(int state) {
} }
void RpcClient::requestInitialList() { void RpcClient::requestInitialList() {
call(QString::fromLatin1(method::kDownloadList), QJsonObject{{"limit", 1000}}, fetchListPage(0, {});
[this](const RpcReply &reply) { }
// download.list.schema.json: "Filtering, sorting and paging all happen in the daemon so
// the GUI never materializes 100k rows to show 40" — limit maxes out at 5000, so one call
// cannot ever return everything for a table the DoD's own gate says can hold 10 000 rows.
// A single fixed-limit call here silently truncated the table below that (caught by
// gui/tests/dod's scroll-60fps gate refusing to run against a 1000-row table when mockd
// seeded 10000). Page until `total` is satisfied, then reset the model exactly once.
void RpcClient::fetchListPage(int offset, QJsonArray accumulated) {
constexpr int kPageSize = 5000; // download.list's own maximum
constexpr int kMaxPages = 100; // 500 000 rows — a safety cap, not an expected ceiling
call(QString::fromLatin1(method::kDownloadList),
QJsonObject{{"offset", offset}, {"limit", kPageSize}},
[this, offset, accumulated](const RpcReply &reply) mutable {
if (!reply.ok()) { if (!reply.ok()) {
qCWarning(lcRpc, "download.list failed: %d %s", reply.error.code, qCWarning(lcRpc, "download.list failed: %d %s", reply.error.code,
qUtf8Printable(reply.error.message)); qUtf8Printable(reply.error.message));
if (!accumulated.isEmpty()) {
emit taskListReset(accumulated); // show what we got rather than nothing
}
return; return;
} }
const QJsonArray items = reply.result.toObject().value("items").toArray(); const QJsonObject result = reply.result.toObject();
qCInfo(lcRpc, "initial download.list: %lld row(s)", const QJsonArray page = result.value("items").toArray();
static_cast<long long>(items.size())); const qint64 total = static_cast<qint64>(result.value("total").toDouble());
emit taskListReset(items); for (const QJsonValue &item : page) {
accumulated.append(item);
}
const bool morePages =
!page.isEmpty() && accumulated.size() < total && (offset / kPageSize) < kMaxPages;
if (morePages) {
fetchListPage(offset + static_cast<int>(page.size()), accumulated);
return;
}
qCInfo(lcRpc, "initial download.list: %lld of %lld row(s)",
static_cast<long long>(accumulated.size()), static_cast<long long>(total));
emit taskListReset(accumulated);
}); });
} }
+1
View File
@@ -67,6 +67,7 @@ class RpcClient : public QObject {
private: private:
void requestInitialList(); void requestInitialList();
void fetchListPage(int offset, QJsonArray accumulated);
QThread thread_; QThread thread_;
RpcConnection *conn_ = nullptr; // owned by thread_ affinity, deleted on thread finish RpcConnection *conn_ = nullptr; // owned by thread_ affinity, deleted on thread finish
+6 -4
View File
@@ -154,10 +154,12 @@ void RpcConnection::dispatchFrame(const QJsonObject &frame) {
socket_->abort(); // version mismatch or refused — bounce and retry socket_->abort(); // version mismatch or refused — bounce and retry
return; return;
} }
sendRaw(kSubscribeId, QString::fromLatin1(method::kSessionSubscribe), sendRaw(
QJsonObject{{"events", QJsonArray{event::kTaskAdded, event::kTaskRemoved, kSubscribeId, QString::fromLatin1(method::kSessionSubscribe),
event::kTaskState, event::kTaskProgress, QJsonObject{
event::kSpeedGlobal, event::kNotify}}}); {"events", QJsonArray{event::kTaskAdded, event::kTaskRemoved, event::kTaskState,
event::kTaskProgress, event::kSpeedGlobal, event::kNotify,
event::kSettingsChanged, event::kGrabberProgress}}});
return; return;
} }
if (id == kSubscribeId) { if (id == kSubscribeId) {
+5 -17
View File
@@ -160,18 +160,11 @@ if [ -z "$EXTERNAL_UDS" ] && [ -z "$EXTERNAL_WS" ]; then
for _ in $(seq 1 50); do [ -S "$VUDS" ] && break; sleep 0.2; done for _ in $(seq 1 50); do [ -S "$VUDS" ] && break; sleep 0.2; done
[ -S "$VUDS" ] || { echo "veloxd did not start:"; cat "$WORK/veloxd.log"; exit 1; } [ -S "$VUDS" ] || { echo "veloxd did not start:"; cat "$WORK/veloxd.log"; exit 1; }
# saveTo.allowedRoots defaults to ["~/Downloads"]; download.add's own isolated # saveTo.allowedRoots defaults to ["~/Downloads"]; download.add.json (fixture) asks
# downloads dir needs to be an allowed root too, or every download.add fixture fails # for a saveDir under $HOME/Downloads, so both that and download.add's own isolated
# -32011 before the point of this runner is even reached. $HOME/Downloads stays in # downloads dir need to be allowed roots, or every download.add fixture fails -32011
# the list alongside it: a few fixtures still set an explicit saveDir there # before the point of this runner is even reached. settings.set is itself a D3 stub,
# (category.upsert.json, download.update.json) rather than take the default. # so this is written straight into the isolated velox.db rather than over the wire.
# capture.minSizeBytes defaults to 0 (nothing is ever "too small"), which makes
# errors/capture.offer.ignore.json's below-minimum-size case impossible to reach
# against a fresh daemon; raised here so that fixture's scenario is actually
# reachable. Written straight into the isolated velox.db, before veloxd has any RPC
# session to write it through: settings.set is real now (D9), but this has to be in
# place before the very first fixture runs, and setup happens before any connection
# exists.
python3 - "$VXDG/data/velox/velox.db" "$VXDG/downloads" "$HOME/Downloads" <<'PY' python3 - "$VXDG/data/velox/velox.db" "$VXDG/downloads" "$HOME/Downloads" <<'PY'
import json, sqlite3, sys import json, sqlite3, sys
db_path, isolated_downloads, home_downloads = sys.argv[1:4] db_path, isolated_downloads, home_downloads = sys.argv[1:4]
@@ -181,11 +174,6 @@ db.execute(
"ON CONFLICT(key) DO UPDATE SET value = excluded.value", "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
("saveTo.allowedRoots", json.dumps([isolated_downloads, home_downloads])), ("saveTo.allowedRoots", json.dumps([isolated_downloads, home_downloads])),
) )
db.execute(
"INSERT INTO settings(key, value) VALUES(?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
("capture.minSizeBytes", json.dumps(1000000)),
)
db.execute( db.execute(
"INSERT INTO settings(key, value) VALUES(?, ?) " "INSERT INTO settings(key, value) VALUES(?, ?) "
"ON CONFLICT(key) DO UPDATE SET value = excluded.value", "ON CONFLICT(key) DO UPDATE SET value = excluded.value",
+27 -13
View File
@@ -1,26 +1,40 @@
[ [
{ "fixture": "contracts/fixtures/grabber.harvest.json", "reason": "D3: stub handler, -32603 (M4 territory per deferrals.md)" }, { "fixture": "contracts/fixtures/download.refreshUrl.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/grabber.start.json", "reason": "D3: stub handler, -32603 (M4 territory per deferrals.md)" }, { "fixture": "contracts/fixtures/download.update.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/grabber.status.json", "reason": "D3: stub handler, -32603 (M4 territory per deferrals.md)" },
{ "fixture": "contracts/fixtures/media.addVariant.json", "reason": "D3: stub handler, -32603 (M4 territory per deferrals.md)" }, { "fixture": "contracts/fixtures/rules.list.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/media.listVariants.json", "reason": "D3: stub handler, -32603 (M4 territory per deferrals.md)" }, { "fixture": "contracts/fixtures/rules.upsert.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/settings.get.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/settings.set.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/limiter.get.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/limiter.set.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/schedule.get.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/schedule.set.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/queue.reorder.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/grabber.harvest.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/grabber.start.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/grabber.status.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/media.addVariant.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/media.listVariants.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/capture.getRules.json", "reason": "D3: stub handler, -32603 -- DAEMON is filing capture.offer next" },
{ "fixture": "contracts/fixtures/capture.offer.take.json", "reason": "D3: stub handler, -32603 -- DAEMON is filing capture.offer next" },
{ "fixture": "contracts/fixtures/errors/capture.offer.ignore.json", "reason": "D3: stub handler, -32603 -- DAEMON is filing capture.offer next" },
{ "fixture": "contracts/fixtures/errors/download.provideAuth.not-found.json", "reason": "real bug: on_download_provideAuth (dispatcher.cpp) never checks the task exists -- TaskActionPort::provide_auth returns false for an unknown id, which the handler folds into a normal {ok:false} result instead of -32010" }, { "fixture": "contracts/fixtures/errors/download.provideAuth.not-found.json", "reason": "real bug: on_download_provideAuth (dispatcher.cpp) never checks the task exists -- TaskActionPort::provide_auth returns false for an unknown id, which the handler folds into a normal {ok:false} result instead of -32010" },
{ "fixture": "contracts/fixtures/category.list.json", "reason": "documented gap (deferrals.md D3a note): categories table has no mimeTypes/sortOrder columns, so category.upsert accepts them but category.list never echoes mimeTypes back" }, { "fixture": "contracts/fixtures/category.list.json", "reason": "documented gap (deferrals.md D3a note): categories table has no mimeTypes/sortOrder columns, so category.upsert accepts them but category.list never echoes mimeTypes back" },
{ "fixture": "contracts/fixtures/capture.getRules.json", "reason": "not a bug: capture.monitoredMimeTypes defaults to [] (store/settings.cpp's kDefaults) on a fresh daemon; the golden's non-empty example illustrates a configured one" },
{ "fixture": "contracts/fixtures/download.probe.json", "reason": "not a bug: requiresAuth is optional-and-omitted-when-false (schema doesn't require it); the golden shows it because that fixture's probe hit a 401, this run's doesn't" }, { "fixture": "contracts/fixtures/download.probe.json", "reason": "not a bug: requiresAuth is optional-and-omitted-when-false (schema doesn't require it); the golden shows it because that fixture's probe hit a 401, this run's doesn't" },
{ "fixture": "contracts/fixtures/download.get.json", "reason": "not a bug: effectiveUrl is 'null until the first probe succeeds' (schema) and omitted rather than sent as null; our bound $taskId is a fresh, never-started task, so it's never been probed -- the golden depicts an in-progress download instead" }, { "fixture": "contracts/fixtures/download.get.json", "reason": "not a bug: effectiveUrl is 'null until the first probe succeeds' (schema) and omitted rather than sent as null; our bound $taskId is a fresh, never-started task, so it's never been probed -- the golden depicts an in-progress download instead" },
{ "fixture": "contracts/fixtures/download.list.json", "reason": "same as download.get.json: effectiveUrl omitted for our never-started bound tasks, golden depicts an in-progress download" }, { "fixture": "contracts/fixtures/download.list.json", "reason": "same as download.get.json: effectiveUrl omitted for our never-started bound tasks, golden depicts an in-progress download" },
{ "fixture": "contracts/fixtures/download.update.json", "reason": "not a bug: etaSeconds is only known for a task the engine has probed/is running; our bound $taskId is a fresh, never-started task, so it's absent -- same class as download.get.json's effectiveUrl" }, { "fixture": "contracts/fixtures/session.hello.json", "reason": "not a bug: capabilities is genuinely empty because media/grabber/Secret Service aren't implemented yet; the golden's ['media','grabber','secretservice'] illustrates a future daemon, not this one" },
{ "fixture": "contracts/fixtures/session.hello.json", "reason": "not a bug: capabilities is genuinely empty because media/grabber aren't implemented yet (capture/Secret Service's parts of it now are); the golden's example list illustrates a future daemon, not this one" },
{ "fixture": "contracts/fixtures/queue.start.json", "reason": "not a bug: startedTaskIds is empty because nothing is a member of queue 'main' in this isolated run; the golden depicts a queue with real membership" }, { "fixture": "contracts/fixtures/queue.start.json", "reason": "not a bug: startedTaskIds is empty because nothing is a member of queue 'main' in this isolated run; the golden depicts a queue with real membership" },
{ "fixture": "contracts/fixtures/queue.reorder.json", "reason": "not a bug: the fixture's taskIds are two literal ids that only ever existed in a seeded mock; queue 'main' has no members at all in this isolated run, so any non-empty list is correctly rejected as not a permutation of (empty) membership" },
{ "fixture": "contracts/fixtures/rules.list.json", "reason": "not a bug: no rule is ever seeded in a fresh daemon; the golden depicts a configured rule set" },
{ "fixture": "contracts/fixtures/schedule.set.json", "reason": "documented gap (deferrals.md D3f note): nextRunAt is deliberately left unset -- computing it needs DST-aware next-transition logic sched/schedule_window.hpp doesn't have yet" },
{ "fixture": "contracts/fixtures/category.remove.json", "reason": "not a bug: reassignedTaskIds is empty because nothing was ever filed under the 'firmware' category this run creates; the golden depicts a category with real membership" } { "fixture": "contracts/fixtures/category.remove.json", "reason": "not a bug: reassignedTaskIds is empty because nothing was ever filed under the 'firmware' category this run creates; the golden depicts a category with real membership" }
] ]
+1 -4
View File
@@ -288,10 +288,7 @@ export class Dispatcher {
} }
case 'limiter.get': case 'limiter.get':
// applyToRunning is a write-only instruction on limiter.set ("retune already- return state.limiter;
// running transfers now"), not a persisted setting, so get never echoes it back
// (contracts/fixtures/limiter.get.json).
return { enabled: state.limiter.enabled, globalBps: state.limiter.globalBps };
case 'limiter.set': { case 'limiter.set': {
state.limiter = { state.limiter = {