7 Commits
Author SHA1 Message Date
samiandClaude Sonnet 5 79c29b47e8 core: finish the hostile-mode matrix -- 8 remaining end-to-end cases
Was 7/16 of tools/testserver/README.md's mode table covered by
engine_test.cpp. Adds the rest:

- engine_expiring_signed_url_recovers_via_refresh_url: an expired signed
  URL 403s, the engine asks (paused, decision_calls >= 1) rather than
  failing terminally, and DownloadHandle::refresh_url() with a freshly
  signed URL completes it -- exercises both do_refresh_url() fixes and
  the probe-level referrer retry's second-403 path from the previous
  commit.
- engine_403_without_referer_retries_with_origin: no spec.referrer set,
  the automatic single retry (previous commit) recovers with zero
  decisions asked.
- engine_redirect_chain_follows_to_completion: 5 hops of a plain 302.
  No core-side change needed -- documents that CURLOPT_FOLLOWLOCATION/
  MAXREDIRS (already on, RequestOptions::follow_redirects) cover both
  the probe's and every worker's own request, not just one of the two.
- engine_slow_loris_stall_timeout_fires: proves curl's stall detector
  (CURLOPT_LOW_SPEED_LIMIT/_TIME, download_task.cpp's hardcoded 1024 B/s
  for 30s) actually fires rather than hanging. Needed a real fix, not
  just a test: every other test in this file relies on TestServer's
  short 1s loris dribble to keep runtime down, but 1s of trickle
  followed by full-speed streaming never accumulates curl's required 30
  CONSECUTIVE seconds under the floor, so it would never actually abort
  -- a test built on the default dribble would pass by the download
  merely finishing a bit late, not by observing the stall timeout fire.
  testserver_fixture.hpp's TestServer gained an explicit-loris-seconds
  constructor (default ctor unchanged, still 1s) so this one test can
  ask for a dribble (40s) that genuinely outlasts the threshold.
- engine_401_digest_then_provide_auth_completes: same shape as the
  existing 401-basic test: http_client.cpp already asks libcurl for
  CURLAUTH_ANY regardless of net::AuthScheme, so this needed no core
  change -- it passed on the first run and is here to prove that's true
  end-to-end, not just at the http_client unit level.
- engine_chunked_no_length_completes_single_segment: Transfer-Encoding:
  chunked, no Content-Length anywhere (including HEAD). No core change
  needed -- takes the same size-agnostic "unknown size, one plain-GET
  segment" path as the existing no-range test.
- utf8/legacy-content-disposition: already covered end-to-end by
  probe_reads_utf8_content_disposition and
  probe_reads_legacy_content_disposition in probe_test.cpp (probe-level,
  as these modes only affect the initial request) -- verified passing,
  no new test needed.

All 20 engine_test.cpp cases and all 10 probe_test.cpp cases pass. Every
testserver.py spawned while writing and running this was reaped by
TestServer's destructor; verified no stragglers with `ps aux` after each
run.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q3QrF7rCt21bkAjt9BCDFQ
2026-09-12 21:34:24 +04:00
samiandClaude Sonnet 5 8e7d14ba7e core: retry once with the original referrer on a 403, at probe and worker
docs/04-engine-design.md §7's failure policy table has said "403 after
redirect: retry once with the original referrer; many CDNs require it"
since it was written, and Error::forbidden's own enum comment says the
same -- but grepping download_task.cpp and http_client.cpp for 403 turned
up nothing. It was never built.

Implemented at both points a 403 can surface:

- The probe (net::Prober, a separate request path from segment workers):
  on_probe_result() now retries once via restart_probe(false), with
  effective_referrer set to the download URL's own origin (origin_of(),
  via net::split_url()), when the failure is Error::forbidden and this is
  the first retry. A second 403 asks rather than fails outright --
  auto_pause_locked(..., false, true), the same "ask, don't just fail"
  path 416/etag-mismatch already use -- specifically so DownloadHandle::
  refresh_url() stays usable afterward (its own contract requires a
  non-terminal task); this is what makes the expiring-signed-url mode's
  README-documented refresh_url() recovery actually reachable.

- Each segment worker (SegWorker::forbidden, set in seg_head() on a 403
  HEAD): the same one-shot referrer retry via retry_worker(), landing on
  auto_pause_locked() on a second 403 for the same reason.

Both paths route the retry's Referer through a new effective_referrer
field rather than spec.referrer directly, since the origin-retry must not
overwrite what the caller actually asked for -- start_worker_locked() and
restart_probe() were switched to send effective_referrer instead.

do_refresh_url() had two latent bugs surfaced by actually exercising the
expiring-signed-url recovery path end-to-end:

1. It unconditionally proceeded to resume even when the refresh probe
   itself failed -- a bad refresh URL would silently un-pause a task with
   nothing behind it. Now returns (stays paused) on !r.has_value().
2. It only handled "already probed once, just refreshing a few fields" --
   for a task whose first-ever probe never succeeded (every hostile mode
   this commit adds a test for that pauses at the initial probe, not
   mid-download), s->registered was never true, so the existing
   `if (s->registered) set_want()` never fired and nothing happened. Now
   detects !s->have_probe and calls finish_probe_locked() directly, the
   actual first-time registration/segmenter-construction path.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Q3QrF7rCt21bkAjt9BCDFQ
2026-09-12 21:34:24 +04:00
sami c1c5c82f8b merge: lane/ext — real-veloxd verification, session.subscribe fix 2026-09-12 17:20:19 +04:00
samiandClaude Sonnet 5 b5c6e1f47d ext: verify against a real veloxd, not just FakeDaemon
main.cpp's single-instance lock is now keyed to the runtime directory
instead of the euid, so an isolated XDG_RUNTIME_DIR/XDG_DATA_HOME/
XDG_CONFIG_HOME/HOME gets its own veloxd alongside anyone else's.
tests/live/real-veloxd.test.ts spawns one (VELOX_PAIR_AUTO=1 standing
in for the GUI's Allow click) plus tools/testserver/testserver.py, and
exercises the real WebSocketTransport end to end: session.hello,
pairing, the token surviving a reconnect, a wrong token rejected and
then rate-limiting the next pairing attempt, download.add reaching a
real running task, the real capture.offer path (rules table + category
folder + dedupe) taking a monitored download and ignoring its own
duplicate, and fail-open proven by SIGKILLing the daemon mid-offer —
the hook still resolves to {} inside its 750ms budget. A last case
proves fail-open at the transport layer too: a call against a closed
socket rejects instead of hanging.

Guarded behind  so it skips itself (with a clear message)
when no daemon binary is around — npm test and CI are unaffected;
run it with VELOXD_BIN=/path/to/veloxd npx vitest run tests/live.

Real-daemon testing found one actual bug, fixed here: background/
index.ts never called session.subscribe, so event.task.progress and
friends never reached this connection at all — FakeDaemon's tests
never caught it because FakeDaemon broadcasts regardless of
subscription state. Now subscribed to the full event set on every
connect (first connect and every reconnect), which is what the popup's
live-progress path actually depends on against a real daemon.

Per instructions: ctest -L conformance was not run (pending PROTO's
fixture fix).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB
2026-09-12 16:43:55 +04:00
sami 0468b0176a merge: lane/daemon 2026-09-12 16:27:29 +04:00
sami d8b7c128be merge: lane/proto 2026-09-12 16:27:29 +04:00
samiandClaude Sonnet 5 e30d994d74 proto: fix conformance run.sh flakiness, prune the veloxd xfail list
Two run.sh fixes plus the xfail prune, all requested together:

1. VELOX_PAIR_AUTO=1 for the isolated veloxd. Pairing is the D1 dev stub
   (EnvAutoApprover) and denies without it, so session.pair never issued
   a token and the WS half of the veloxd step could never even connect.

2. WS_PORT was hardcoded to 52080 with no free-port search, so one leaked
   mockd made every future run fail EADDRINUSE. free_port() binds :0 and
   asks the kernel instead. The EXIT trap's stop() used `pkill -P "$pid"`,
   which only reaps direct children — tsx's actual listener is often a
   grandchild, which that missed and left holding the port. Every server
   (mockd, slow mockd, veloxd) now launches under `setsid`, making it the
   leader of its own process group, so stop() does `kill -TERM -"$pid"`
   (a process-group kill) and reaches everything it spawned in one shot.

3. Pruned the xfail list now that D2, D4b and most of D3 have landed.

Pruning surfaced two more bugs than expected, both in the test harness
itself, not veloxd — worth recording since they were indistinguishable
from real daemon hangs until isolated:

- errors/session.hello.version-mismatch.json documents that the *server*
  closes the connection after replying (correct, intended behavior). The
  harness replays every fixture on one shared connection per transport,
  so once this fixture ran, every later UDS fixture sent into the dead
  socket and just sat there until its own timeout — including ones still
  on the xfail list, which applyXfail waved through as "expected -32603"
  regardless of the real reason. Fixed with a `closesConnection` fixture
  flag: replay() reconnects (fresh session.hello) right after such a
  fixture instead of leaving the rest of the run to time out one by one.
  This is what was actually behind queue.*/session.*/download.remove
  appearing to hang — none of them do; verified individually and via a
  raw probe script before finding the real cause.
- category.remove.json (deletes the "firmware" category) sorted before
  category.upsert.json (creates it) alphabetically, so it was failing
  -32602 "no such category" against a fresh DB — never a daemon bug.
  Added it to DESTRUCTIVE so it now replays after every other fixture.

Also fixed while verifying "confirm each really passes": download.addBatch.json's
`defaults.categoryId` was "compressed", a category nothing ever creates —
real veloxd correctly enforces the FK on tasks.category_id, so all three
batch items failed instead of the two expected. Changed to "programs" (a
migration-seeded builtin).

Of the 15 fixtures named for pruning, 10 turned out to cleanly pass and
are gone from the list entirely: download.pause/resume/start/cancel,
download.remove, download.addBatch, queue.upsert/stop, download.probe's
success path (D2, including errors/download.probe.probe-failed.json),
and category.upsert. Two do NOT cleanly pass and are kept, with reasons
rewritten to match what's actually happening now instead of the stale D3
text: download.probe.json (see below) and errors/download.provideAuth.not-found.json,
a real bug — on_download_provideAuth never checks the task exists, so an
unknown taskId gets a normal `{ok:false}` result instead of -32010.

Five more fixtures newly needed xfail entries to reach green, none of
them stubs:
- category.list.json — documented gap (deferrals.md's D3a note): the
  categories table has no mimeTypes/sortOrder columns.
- download.probe.json, download.get.json, download.list.json,
  session.hello.json — not bugs. Each golden depicts a richer lifecycle
  state (a probed/in-progress download, a daemon with media/grabber/
  Secret Service implemented) than this harness's bound tasks, which are
  always fresh and never started, can produce. Optional/omit-if-absent
  fields (effectiveUrl, requiresAuth, capabilities) are correctly absent;
  the mismatch is against the golden's illustrative values, not the
  contract.
- queue.start.json, category.remove.json — same class: startedTaskIds /
  reassignedTaskIds are correctly empty because this run's queue/category
  have no real membership.

`ctest -L conformance` is green: 100% (2/2), 81.7s (down from ~240s now
that pairing and the port/reconnect fixes remove the retries and the
5-10s timeouts the connection-death bug was producing).

One thing NOT fixed here, flagged for a follow-up decision rather than
touched mid-task: download.add.json's fixture is `startMode: "now"`
against a real, large (~6GB) Ubuntu ISO on the real internet, with
saveDir hardcoded to /home/sami/Downloads/Programs. Every run against a
real veloxd writes a real multi-GB file into that path — confirmed by
running this repeatedly during verification. Isolating the daemon's XDG
dirs doesn't isolate this. Worth its own change (startMode: "later"
would still exercise the add path without the transfer) but out of scope
for a fixture I wasn't asked to touch beyond what blocked this task.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SFeUKLbdHizrJjLBeK7ffz
2026-09-12 14:04:18 +04:00
10 changed files with 839 additions and 61 deletions
+1 -1
View File
@@ -20,7 +20,7 @@
],
"defaults": {
"url": "https://example.org/",
"categoryId": "compressed",
"categoryId": "programs",
"startMode": "queue",
"queueId": "main"
}
@@ -30,5 +30,6 @@
"the version check is transport-independent; this is replayed on the Unix socket so it is not masked by -32002",
"data.expected is the daemon's own current protocol version string (kProtocolVersion), not a bare major and not pinnable in a golden file -- the conformance compare on error payloads is on `code` only, structural elsewhere, so echoing the live version is fine"
],
"transport": "uds"
"transport": "uds",
"closesConnection": true
}
+127 -9
View File
@@ -64,6 +64,19 @@ std::string lower(std::string s) {
return s;
}
// scheme://host[:port] of `url`, with no path/query/fragment -- what docs/04 §7's "403
// after redirect: retry once with the original referrer" retries with as the Referer
// header. Empty on an unparseable URL (the caller just won't get a referrer retry).
std::string origin_of(std::string_view url) {
auto s = net::split_url(url);
if (!s.valid)
return {};
std::string out = s.scheme + "://" + s.host;
if (s.port)
out += ":" + std::to_string(*s.port);
return out;
}
} // namespace
// What to do once every worker has drained (see DownloadTaskState::begin_drain_locked).
@@ -88,6 +101,7 @@ struct SegWorker {
bool needs_auth = false;
bool wrong_status = false;
bool range_bad = false;
bool forbidden = false; // 403 -- docs/04 §7's "retry once with the original referrer"
bool auth_handshake = false; // saw a 401/407 and let libcurl resend with credentials
std::string resp_etag, resp_last_modified; // captured on a wrong_status 200, for demote
std::optional<ErrorInfo> flush_error;
@@ -135,6 +149,16 @@ struct DownloadTaskState : std::enable_shared_from_this<DownloadTaskState> {
std::optional<std::uint64_t> total_size;
std::string origin_host;
// docs/04 §7's "403 after redirect: retry once with the original referrer" -- many
// CDNs 403 a bare/foreign Referer. Starts as spec.referrer (the browser's, verbatim);
// start_worker_locked() sends this, not spec.referrer directly, so a 403 retry can
// override it (to the download URL's own origin) without touching what the caller
// actually asked for. referrer_retried bounds it to exactly once per task -- a second
// 403 with a same-origin Referer already set is a real, honest failure
// (Error::forbidden), not something a referrer swap can fix.
std::string effective_referrer;
bool referrer_retried = false;
std::unique_ptr<segment::Segmenter> seg;
std::unique_ptr<io::SparseFile> file;
std::unordered_map<std::uint32_t, std::unique_ptr<SegWorker>> workers;
@@ -164,7 +188,7 @@ struct DownloadTaskState : std::enable_shared_from_this<DownloadTaskState> {
std::vector<std::function<void()>> deferred;
DownloadTaskState(TaskHost &h, TaskId i, DownloadSpec s, DownloadCallbacks c)
: host(h), id(i), spec(std::move(s)), cbs(std::move(c)) {}
: host(h), id(i), spec(std::move(s)), cbs(std::move(c)), effective_referrer(spec.referrer) {}
// --- deferred callbacks -------------------------------------------------------------
void defer(std::function<void()> fn) {
@@ -286,7 +310,7 @@ void DownloadTaskState::restart_probe(bool with_auth) {
pr.url = spec.url;
pr.headers = spec.headers;
pr.cookies = spec.cookies;
pr.referrer = spec.referrer;
pr.referrer = effective_referrer;
pr.user_agent = spec.user_agent;
pr.proxy = spec.proxy;
if (with_auth)
@@ -299,12 +323,37 @@ void DownloadTaskState::restart_probe(bool with_auth) {
}
void DownloadTaskState::on_probe_result(Result<net::ProbeResult> r) {
bool retry_probe_with_referrer = false;
{
std::unique_lock lk(mu);
if (retired.load() || is_terminal(state))
return;
if (!r.has_value()) {
fail_locked(std::move(r).error());
ErrorInfo e = std::move(r).error();
// docs/04 §7's referrer retry applies here too: a probe (HEAD, or the
// ranged-GET fallback when HEAD is refused -- probe.cpp) can be the request
// that actually gets 403'd, before any segment worker exists to retry it
// (net::Prober builds its own request from ProbeRequest::referrer, not
// through start_worker_locked() -- see restart_probe()'s use of
// effective_referrer below). Same one-shot bound via referrer_retried as the
// worker-level retry (seg_finished's w->forbidden branch) shares.
if (e.code == Error::forbidden && !referrer_retried) {
referrer_retried = true;
effective_referrer = origin_of(spec.url);
retry_probe_with_referrer = true;
} else if (e.code == Error::forbidden) {
// Already retried with the origin referrer and still 403 -- not something
// another blind retry fixes (an expired signed URL, a private resource).
// Ask rather than fail outright, the same "ask, don't just fail" shape as
// wrong_status/range_bad/the worker-level 403 branch: refresh_url() is a
// no-op once the task is terminal, and tools/testserver's expiring-signed-
// url mode (also a bare 403, indistinguishable from any other without
// parsing the body -- CLAUDE.md §3, core never does) is meant to be
// recovered exactly that way.
auto_pause_locked(std::move(e), false, true);
} else {
fail_locked(std::move(e));
}
} else {
probe = std::move(r).value();
have_probe = true;
@@ -319,6 +368,8 @@ void DownloadTaskState::on_probe_result(Result<net::ProbeResult> r) {
}
}
flush_deferred();
if (retry_probe_with_referrer)
restart_probe(false);
}
void DownloadTaskState::finish_probe_locked() {
@@ -472,7 +523,7 @@ void DownloadTaskState::start_worker_locked(std::uint32_t seg_idx) {
req.url = current_url();
req.headers = spec.headers;
req.cookies = spec.cookies;
req.referrer = spec.referrer;
req.referrer = effective_referrer;
req.user_agent = spec.user_agent;
req.proxy = spec.proxy;
req.auth = spec.auth;
@@ -552,6 +603,10 @@ net::DataAction DownloadTaskState::seg_head(std::uint32_t seg_idx, const net::Re
w->range_bad = true;
return net::DataAction::abort;
}
if (h.status == 403) {
w->forbidden = true;
return net::DataAction::abort;
}
if (h.status >= 400)
return net::DataAction::abort;
seg->set_segment_state(seg_idx, segment::SegState::downloading);
@@ -637,7 +692,7 @@ void DownloadTaskState::seg_finished(std::uint32_t seg_idx, Result<net::Transfer
// (content-length-mismatch's honest-length lie, flaky-reset's tail, a proxy RST after
// the last byte). If the segment is fully covered, that's a success.
if (seg && !cancel_requested && !pause_requested && !w->needs_auth && !w->wrong_status &&
!w->flush_error && !w->range_bad) {
!w->flush_error && !w->range_bad && !w->forbidden) {
const std::uint64_t len = seg->segment_end(seg_idx) - seg->segment_start(seg_idx) + 1;
if (len != 0 && seg->segment_completed(seg_idx) >= len) {
r = Result<net::TransferStats>(net::TransferStats{});
@@ -756,6 +811,35 @@ void DownloadTaskState::seg_finished(std::uint32_t seg_idx, Result<net::Transfer
auto_pause_locked(ErrorInfo(Error::range_not_satisfiable, "416"), false, true);
return done();
}
if (w->forbidden) {
// docs/04 §7: "403 after redirect: retry once with the original referrer -- many
// CDNs require it." Bare/foreign Referer is the common cause; origin_of() rebuilds
// it from the (possibly redirected) URL the response actually came from. Exactly
// once per task, not a backoff series -- a second 403 with a same-origin Referer
// already set isn't something another blind retry can fix (a private/expired
// resource, an expiring signed URL past its window, ...). That's not necessarily
// terminal, though: ask (same "ask, don't just fail outright" shape as
// wrong_status/range_bad above) rather than fail_locked() outright, specifically
// so DownloadHandle::refresh_url() -- do_refresh_url() is a no-op once the task is
// terminal -- stays usable for the case tools/testserver's README pairs it with:
// a caller that gets a fresh signed URL and hands it back.
release_slot();
if (!referrer_retried) {
referrer_retried = true;
effective_referrer = origin_of(current_url());
seg->set_segment_state(seg_idx, segment::SegState::stalled);
auto wp = weak_from_this();
host.schedule(std::chrono::steady_clock::now(), [wp, seg_idx] {
if (auto s = wp.lock())
s->retry_worker(seg_idx);
});
if (workers.empty())
transition(EngineState::retry_wait, std::nullopt);
} else {
auto_pause_locked(ErrorInfo(Error::forbidden, "403", w->http_status), false, true);
}
return done();
}
if (!r.has_value()) {
ErrorInfo e = std::move(r).error();
@@ -1215,6 +1299,7 @@ void DownloadTaskState::do_refresh_url(std::string url, std::vector<net::HeaderF
net::ProbeRequest pr;
pr.url = spec.url;
pr.headers = spec.headers;
pr.referrer = effective_referrer;
pr.auth = spec.auth;
pr.proxy = spec.proxy;
host.probe(std::move(pr), [wp](Result<net::ProbeResult> r) {
@@ -1224,10 +1309,43 @@ void DownloadTaskState::do_refresh_url(std::string url, std::vector<net::HeaderF
std::unique_lock lk(s->mu);
if (s->retired.load() || is_terminal(s->state))
return;
if (r.has_value()) {
s->probe.effective_url = r.value().effective_url;
s->probe.etag = r.value().etag;
s->probe.last_modified = r.value().last_modified;
if (!r.has_value()) {
lk.unlock();
s->flush_deferred();
return; // still paused; the caller can retry refresh_url() or decide()
}
if (!s->have_probe) {
// The task's *first* probe never succeeded (e.g. this session's own
// expiring-signed-url path: 403, one referrer retry, still 403 -> ask rather
// than fail outright -- see on_probe_result() -- specifically so this branch
// exists to recover it). finish_probe_locked() is what actually registers the
// task with the budget and builds its Segmenter; nothing downstream of a
// partial field copy would ever start a worker without it.
s->probe = std::move(r).value();
s->have_probe = true;
s->awaiting_auth = false;
s->awaiting_decision = false;
s->finish_probe_locked();
lk.unlock();
s->flush_deferred();
return;
}
s->probe.effective_url = r.value().effective_url;
s->probe.etag = r.value().etag;
s->probe.last_modified = r.value().last_modified;
// refresh_url()'s own contract is "on a live OR PAUSED task, without losing
// progress" -- distinct from do_decide(restart), which discards progress. A task
// can be paused here for any of three reasons (a plain user pause, awaiting_auth,
// or awaiting_decision -- e.g. this session's own 403-after-referrer-retry path,
// or the pre-existing wrong_status/range_bad ones); apply_slot_target()'s guard
// blocks on awaiting_auth/awaiting_decision specifically, so leaving either set
// would have set_want() below recompute a target that nothing ever acts on --
// the caller's new URL re-probed successfully and then the task just sat there.
// Clear both and leave `paused` the same way do_decide(restart) does.
if (s->state == EngineState::paused) {
s->awaiting_auth = false;
s->awaiting_decision = false;
s->transition(EngineState::connecting, std::nullopt);
}
if (s->registered)
s->host.budget().set_want(s->id, s->want_slots());
+10 -2
View File
@@ -28,7 +28,14 @@ namespace vdm::testing {
class TestServer {
public:
TestServer() {
TestServer() : TestServer(1.0) {}
// loris_seconds overrides the dribble duration slow-loris mode uses (default matches the
// no-arg ctor's long-standing 1s). A test that needs curl's stall detector
// (CURLOPT_LOW_SPEED_TIME, hardcoded to 30s in download_task.cpp) to actually fire needs a
// dribble that outlasts that threshold, not the short one every other test relies on to
// keep runtime down.
explicit TestServer(double loris_seconds) {
const char *script = VDM_TESTSERVER_PY;
if (!script || !*script || ::access(script, R_OK) != 0)
return;
@@ -50,8 +57,9 @@ class TestServer {
int devnull = ::open("/dev/null", O_WRONLY);
if (devnull >= 0)
::dup2(devnull, STDERR_FILENO);
std::string loris_str = std::to_string(loris_seconds);
::execlp("python3", "python3", script, "--port", "0", "--seed", "9", "--loris-seconds",
"1", "--throttle-bps", "131072", static_cast<char *>(nullptr));
loris_str.c_str(), "--throttle-bps", "131072", static_cast<char *>(nullptr));
::_exit(127);
}
::close(pipefd[1]);
+185
View File
@@ -124,6 +124,31 @@ std::string server_sha(TestServer &srv, const std::string &mode, const std::stri
return out.substr(open + 1, close - open - 1);
}
// Small, deliberately identical extraction to server_sha's: GET /<mode>/sign/<size>?ttl=N
// and pull the "url" field's value out of the {"url":..., "exp":...} JSON body.
std::string sign_url(TestServer &srv, const std::string &mode, const std::string &size,
int ttl_seconds) {
std::string url =
srv.url("/" + mode + "/sign/" + size + "?ttl=" + std::to_string(ttl_seconds));
std::string cmd = "curl -s '" + url + "'";
std::string out;
if (FILE *f = ::popen(cmd.c_str(), "r")) {
char buf[1024];
while (std::fgets(buf, sizeof buf, f))
out += buf;
::pclose(f);
}
auto q = out.find("\"url\"");
if (q == std::string::npos)
return {};
auto colon = out.find(':', q);
auto open = out.find('"', colon);
auto close = out.find('"', open + 1);
if (open == std::string::npos || close == std::string::npos)
return {};
return out.substr(open + 1, close - open - 1);
}
DownloadSpec spec_for(TestServer &srv, const std::string &urlpath, const std::string &save) {
DownloadSpec s;
s.url = srv.url(urlpath);
@@ -328,6 +353,30 @@ VT_TEST(engine_401_then_provide_auth_completes) {
VT_CHECK_EQ(file_size(td.file("au.bin")), 1u * 1024 * 1024);
}
VT_TEST(engine_401_digest_then_provide_auth_completes) {
// Same shape as engine_401_then_provide_auth_completes, but the challenge is HTTP
// Digest (qop=auth) rather than Basic. provide_auth() doesn't know or care which --
// http_client.cpp always asks libcurl for CURLAUTH_ANY (net::AuthScheme::any) and lets
// curl negotiate against whatever WWW-Authenticate the server actually sent -- so this
// exists purely to prove that's true end-to-end, not just at the unit level.
TestServer srv;
VT_REQUIRE(srv.available());
TmpDir td;
Recorder rec;
Engine eng;
DownloadHandle h;
auto cbs = rec.cbs(&h, "test", "test");
h = eng.start(spec_for(srv, "/401-digest/file/1M", td.file("dg.bin")), std::move(cbs));
rec.arm(h);
auto r = rec.wait();
VT_REQUIRE(r.has_value());
VT_CHECK(rec.auth_calls.load() >= 1);
VT_CHECK_EQ(file_size(td.file("dg.bin")), 1u * 1024 * 1024);
auto got = hash_file(td.file("dg.bin"), Checksum::Algo::sha256);
VT_CHECK_EQ(got.value(), server_sha(srv, "401-digest", "1M"));
}
// --- hostile-mode matrix: the four where a bug is silent corruption, not a visible
// failure (docs/04 §5 "ask, never silently corrupt" / §7's failure-policy table). ---
@@ -458,6 +507,142 @@ VT_TEST(engine_content_length_mismatch_fails_honestly) {
VT_CHECK_EQ(::access(td.file("clm.bin").c_str(), F_OK), -1); // never renamed into place
}
// --- remaining hostile-mode matrix (tools/testserver/README.md's mode table). ---
VT_TEST(engine_expiring_signed_url_recovers_via_refresh_url) {
// A signed URL past its ttl 403s (tools/testserver's own JSON body distinguishes
// "expired" from "bad signature", but core never parses response bodies -- CLAUDE.md
// §3 -- so both just read as a 403). The one automatic referrer retry (see
// engine_403_without_referer_retries_with_origin, below) can't fix an expired
// signature, so the second 403 asks -- via the same auto_pause_locked(..., false,
// true) "ask, don't just fail" path as wrong_status/range_bad -- rather than
// terminally failing outright, specifically so DownloadHandle::refresh_url() (its own
// contract: works "on a live or paused task", never on a terminal one) stays usable:
// the README pairs this mode with exactly that recovery.
TestServer srv;
VT_REQUIRE(srv.available());
TmpDir td;
Recorder rec;
Engine eng;
std::string expired = sign_url(srv, "expiring-signed-url", "64K", /*ttl=*/1);
VT_REQUIRE(!expired.empty());
std::this_thread::sleep_for(1500ms); // let the ttl actually pass before the first request
DownloadSpec s;
s.url = expired;
s.save_path = td.file("exp.bin");
auto h = eng.start(std::move(s), rec.cbs());
for (int i = 0; i < 300 && rec.decision_calls.load() == 0; ++i)
std::this_thread::sleep_for(20ms);
VT_REQUIRE(rec.decision_calls.load() >= 1);
VT_CHECK_EQ(h.state(), EngineState::paused);
std::string fresh = sign_url(srv, "expiring-signed-url", "64K", /*ttl=*/60);
VT_REQUIRE(!fresh.empty());
h.refresh_url(fresh);
auto r = rec.wait(60s);
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(file_size(td.file("exp.bin")), 64u * 1024);
auto got = hash_file(td.file("exp.bin"), Checksum::Algo::sha256);
VT_CHECK_EQ(got.value(), server_sha(srv, "expiring-signed-url", "64K"));
}
VT_TEST(engine_403_without_referer_retries_with_origin) {
// docs/04 §7: "403 after redirect: retry once with the original referrer -- many CDNs
// require it." No spec.referrer is set here (the common case for anything not
// initiated from a browser page, e.g. `velox add <url>`), so the first attempt 403s;
// the engine's own retry supplies the download URL's own origin as Referer, which
// this mode accepts, and the download completes with no decision ever asked.
TestServer srv;
VT_REQUIRE(srv.available());
TmpDir td;
Recorder rec;
Engine eng;
auto h = eng.start(spec_for(srv, "/403-without-referer/file/128K", td.file("ref.bin")),
rec.cbs());
auto r = rec.wait(30s);
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(rec.decision_calls.load(), 0); // recovered automatically, not asked
VT_CHECK_EQ(file_size(td.file("ref.bin")), 128u * 1024);
auto got = hash_file(td.file("ref.bin"), Checksum::Algo::sha256);
VT_CHECK_EQ(got.value(), server_sha(srv, "403-without-referer", "128K"));
}
VT_TEST(engine_redirect_chain_follows_to_completion) {
// 5 hops (tools/testserver's own --redirect-depth default) of a plain 302, query
// string preserved across each. No CORE-side logic needed for this one -- libcurl's
// own CURLOPT_FOLLOWLOCATION (RequestOptions::follow_redirects, already on) and
// CURLOPT_MAXREDIRS (default 20, well over 5) do the whole thing -- this is here as
// the end-to-end check that they're actually wired through both the probe and every
// segment worker's own request, not just one of the two.
TestServer srv;
VT_REQUIRE(srv.available());
TmpDir td;
Recorder rec;
Engine eng;
auto h = eng.start(spec_for(srv, "/redirect-chain/file/1M", td.file("rc.bin")), rec.cbs());
auto r = rec.wait(30s);
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(file_size(td.file("rc.bin")), 1u * 1024 * 1024);
auto got = hash_file(td.file("rc.bin"), Checksum::Algo::sha256);
VT_CHECK_EQ(got.value(), server_sha(srv, "redirect-chain", "1M"));
}
VT_TEST(engine_slow_loris_stall_timeout_fires) {
// Status line, headers, and body dribbled out one byte at a time for --loris-seconds,
// then (if the dribble hasn't already been cut off) normal streaming -- a connection
// that's technically alive (bytes ARE arriving, just far too slowly) but must not be
// allowed to hang the task forever. http_client.cpp sets CURLOPT_LOW_SPEED_LIMIT/_TIME
// (RequestOptions::low_speed_bytes_per_sec/low_speed_secs, hardcoded in
// download_task.cpp to 1024 B/s for 30s) for exactly this.
//
// Every other test in this file uses TestServer's default 1s loris dribble to keep
// runtime down, but 1s is far shorter than curl's 30s low_speed_time: a 1s trickle
// followed by full-speed streaming never accumulates 30 CONSECUTIVE seconds under the
// floor, so curl would never actually abort it -- the download would just complete
// slightly late, which would make this test pass for the wrong reason (or not exercise
// the stall timeout at all). Explicitly ask for a dribble that outlasts the 30s
// threshold so the stall timeout is the thing actually observed firing, not assumed.
TestServer srv(40.0);
VT_REQUIRE(srv.available());
TmpDir td;
Recorder rec;
Engine eng;
auto s = spec_for(srv, "/slow-loris/file/64K", td.file("sl.bin"));
s.segments = 1;
s.max_retries = 1;
auto h = eng.start(std::move(s), rec.cbs());
auto r = rec.wait(60s); // stall timeout fires ~30s in; must resolve, not hang to 60s
VT_REQUIRE(!r.has_value());
VT_CHECK(is_retryable(r.error().code) || r.error().code == Error::max_retries_exhausted);
}
VT_TEST(engine_chunked_no_length_completes_single_segment) {
// No Content-Length anywhere (HEAD gets none either, since it's the same handler path)
// -- the probe can't know total_size or prove resumability, so this should take the
// exact same "unknown size, one plain-GET segment" path as engine_non_resumable_single_
// segment, just arriving there via a chunked body instead of a server that plainly
// refuses Range. No core-side work needed if that demotion is already size-agnostic;
// this is here to prove it, since every other test's server tells the probe the size
// up front.
TestServer srv;
VT_REQUIRE(srv.available());
TmpDir td;
Recorder rec;
Engine eng;
auto h = eng.start(spec_for(srv, "/chunked-no-length/file/2M", td.file("ch.bin")),
rec.cbs());
auto r = rec.wait();
VT_REQUIRE(r.has_value());
VT_CHECK_EQ(rec.decision_calls.load(), 0);
VT_CHECK_EQ(file_size(td.file("ch.bin")), 2u * 1024 * 1024);
auto got = hash_file(td.file("ch.bin"), Checksum::Algo::sha256);
VT_CHECK_EQ(got.value(), server_sha(srv, "chunked-no-length", "2M"));
}
// --- DAEMON-reported bug: Progress.speed_bps reads 0 for the whole life of a live
// download while downloaded bytes visibly advance. DAEMON reads progress by polling
// DownloadHandle::progress() (engine_port_core.hpp), not the on_progress push callback --
+28 -2
View File
@@ -19,7 +19,12 @@ import {
} from './context-menus.js';
import { MediaBridge, notifyTab } from './media-bridge.js';
import { createTransport, transportStorage, type TransportStatus, type VeloxTransport } from './transport/index.js';
import type { CaptureOfferParams, CaptureRules, DownloadSpec } from '../shared/protocol/index.js';
import {
SESSION_SUBSCRIBE_PARAMS_EVENTS_ITEM_VALUES,
type CaptureOfferParams,
type CaptureRules,
type DownloadSpec,
} from '../shared/protocol/index.js';
let transport: VeloxTransport | undefined;
let rules: CaptureRules = DEFAULT_CAPTURE_RULES;
@@ -75,10 +80,31 @@ async function refreshRules(): Promise<void> {
}
}
/**
* "Nothing is delivered until this is called" (session.subscribe's own description)
* without it, event.task.progress et al. never reach this connection at all, no matter
* how many listeners bridge.ts registers locally. Requests the whole set every time
* because any popup/options document could open at any moment and none of them narrow
* per-tab; a fresh connection (first connect, or after a drop) starts with nothing
* subscribed until this runs again.
*/
async function subscribeToEvents(): Promise<void> {
try {
await mustTransport().call('session.subscribe', {
events: [...SESSION_SUBSCRIBE_PARAMS_EVENTS_ITEM_VALUES],
});
} catch {
// Best-effort; a reconnect (or the next event.settings.changed-driven refresh) retries.
}
}
function onTransportState(status: TransportStatus): void {
const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : '');
console.debug(`[velox] transport ${status.state}${detail ? `${detail}` : ''}`);
if (status.state === 'connected') void refreshRules();
if (status.state === 'connected') {
void refreshRules();
void subscribeToEvents();
}
}
async function setOverride(override: 'auto' | 'ws' | 'uds'): Promise<void> {
+389
View File
@@ -0,0 +1,389 @@
// Runs the transport, the capture hook, and the popup event path against a REAL veloxd
// — not FakeDaemon. Everything else in this suite is faithful to the documented wire
// protocol, but "faithful" isn't "real"; this is what actually proves it.
//
// Requires VELOXD_BIN (path to a built veloxd) in the environment. Skips itself with a
// clear message otherwise, so `npm test` and CI (no daemon binary lying around) are
// unaffected. Run it like:
//
// VELOXD_BIN=/path/to/build/dev/bin/veloxd npx vitest run tests/live
//
// Each veloxd instance gets its own scratch XDG_RUNTIME_DIR/XDG_DATA_HOME/
// XDG_CONFIG_HOME/HOME (main.cpp's single-instance lock is keyed to the runtime dir, so
// this can run alongside another developer's or CI's own veloxd on the same machine).
// VELOX_PAIR_AUTO=1 stands in for the GUI's Allow-prompt approver during dev/test
// (rpc/pairing.hpp's EnvAutoApprover) — pairing itself is exercised for real, only the
// human click is stubbed.
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { mkdtempSync, mkdirSync, rmSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WebSocket as WsClient } from 'ws';
import { RpcError, TransportClosedError } from '../../src/background/transport/types.js';
import { WebSocketTransport, type WebSocketCtor, type WebSocketTransportDeps } from '../../src/background/transport/websocket.js';
import { CaptureHook } from '../../src/background/capture/index.js';
import type { OnHeadersReceivedDetails } from '../../src/background/capture/index.js';
import type { CaptureRules, DownloadSpec, TaskProgressEvent, TaskStateEvent } from '../../src/shared/protocol/index.js';
const VELOXD_BIN = process.env.VELOXD_BIN;
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
const TESTSERVER_PY = join(REPO_ROOT, 'tools/testserver/testserver.py');
// A fixed moz-extension origin, used both as session.pair's extensionId and as the WS
// upgrade's Origin header — real Firefox sets the latter itself; ws's client needs it
// spelled out (docs/05 §4: the daemon refuses the upgrade without a moz-extension:// Origin).
const EXTENSION_ID = '11111111-2222-3333-4444-555555555555';
const ORIGIN = `moz-extension://${EXTENSION_ID}`;
class OriginWebSocket extends WsClient {
constructor(url: string) {
super(url, { origin: ORIGIN });
}
}
const CTOR = OriginWebSocket as unknown as WebSocketCtor;
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
async function waitFor(cond: () => Promise<boolean> | boolean, timeoutMs: number, what: string): Promise<void> {
const deadline = Date.now() + timeoutMs;
for (;;) {
if (await cond()) return;
if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}`);
await sleep(50);
}
}
interface VeloxdInstance {
proc: ChildProcessWithoutNullStreams;
scratch: string;
wsPort: number;
/** True once the process has actually exited, by signal or otherwise. Node only sets
* `proc.exitCode` for a normal exit a signal-killed process reports its death via
* `signalCode` and an `exit` event instead, never a non-null `exitCode`. */
hasExited(): boolean;
kill(signal?: NodeJS.Signals): void;
}
async function startVeloxd(bin: string): Promise<VeloxdInstance> {
const scratch = mkdtempSync(join(tmpdir(), 'velox-live-'));
const runtime = join(scratch, 'rt');
const data = join(scratch, 'data');
const config = join(scratch, 'cfg');
const home = join(scratch, 'home');
mkdirSync(runtime, { mode: 0o700 });
mkdirSync(data, { recursive: true });
mkdirSync(config, { recursive: true });
mkdirSync(join(home, 'Downloads'), { recursive: true });
const proc = spawn(bin, [], {
env: {
...process.env,
VELOX_PAIR_AUTO: '1',
XDG_RUNTIME_DIR: runtime,
XDG_DATA_HOME: data,
XDG_CONFIG_HOME: config,
HOME: home,
},
});
let exited = false;
proc.on('exit', () => {
exited = true;
});
let stderr = '';
proc.stderr.on('data', (d) => {
stderr += String(d);
});
const portFile = join(runtime, 'velox', 'ws.port');
try {
await waitFor(async () => {
if (exited) throw new Error(`veloxd exited early (code ${proc.exitCode}, signal ${proc.signalCode}): ${stderr}`);
try {
await readFile(portFile);
return true;
} catch {
return false;
}
}, 10_000, 'veloxd to write ws.port');
} catch (e) {
proc.kill('SIGKILL');
rmSync(scratch, { recursive: true, force: true });
throw e;
}
const wsPort = Number((await readFile(portFile, 'utf8')).trim());
return {
proc,
scratch,
wsPort,
hasExited: () => exited,
kill(signal: NodeJS.Signals = 'SIGTERM') {
proc.kill(signal);
},
};
}
interface TestServerInstance {
proc: ChildProcessWithoutNullStreams;
baseUrl: string;
}
async function startTestServer(): Promise<TestServerInstance> {
const proc = spawn('python3', [TESTSERVER_PY, '--port', '0'], {});
let stdout = '';
let port: number | null = null;
proc.stdout.on('data', (d) => {
stdout += String(d);
const m = /^(\d+)\s*$/m.exec(stdout);
if (m) port = Number(m[1]);
});
await waitFor(() => port !== null, 5_000, 'testserver to print its port');
const baseUrl = `http://127.0.0.1:${port}`;
await waitFor(async () => {
try {
const res = await fetch(`${baseUrl}/__health`);
return res.ok;
} catch {
return false;
}
}, 5_000, 'testserver /__health');
return { proc, baseUrl };
}
function memDeps(init: { token?: string | null } = {}): { deps: WebSocketTransportDeps; store: { token: string | null } } {
const store = { token: init.token ?? null };
return {
store,
deps: {
getToken: async () => store.token,
setToken: async (t) => {
store.token = t;
},
getCachedPort: async () => null,
setCachedPort: async () => undefined,
extensionId: EXTENSION_ID,
},
};
}
function makeTransport(port: number, deps: WebSocketTransportDeps, extra: Partial<WebSocketTransportDeps> = {}): WebSocketTransport {
return new WebSocketTransport({
...deps,
...extra,
webSocketCtor: CTOR,
portRange: { start: port, end: port },
openTimeoutMs: 2000,
});
}
const maybeDescribe = VELOXD_BIN ? describe : describe.skip;
if (!VELOXD_BIN) {
console.warn('tests/live/real-veloxd.test.ts: VELOXD_BIN not set — skipping (see file header).');
}
maybeDescribe('WebSocketTransport against a real veloxd', () => {
let daemon: VeloxdInstance;
let testserver: TestServerInstance;
// The mid-test fail-open case kills `daemon` and a later test starts a replacement —
// every scratch dir that ever existed gets cleaned up here, not just the last one.
const allScratchDirs: string[] = [];
async function freshVeloxd(): Promise<VeloxdInstance> {
const d = await startVeloxd(VELOXD_BIN!);
allScratchDirs.push(d.scratch);
return d;
}
beforeAll(async () => {
daemon = await freshVeloxd();
testserver = await startTestServer();
}, 20_000);
afterAll(() => {
daemon?.kill('SIGKILL');
testserver?.proc.kill('SIGKILL');
for (const dir of allScratchDirs) rmSync(dir, { recursive: true, force: true });
});
it('session.hello without a token surfaces NotPaired / needsPairing', async () => {
const { deps } = memDeps();
const t = makeTransport(daemon.wsPort, deps, { autoPair: false });
await expect(t.connect()).rejects.toBeInstanceOf(RpcError);
expect(t.status.needsPairing).toBe(true);
t.disconnect();
});
let pairedToken: string;
it('pairs (VELOX_PAIR_AUTO=1 stands in for the human Allow click) and hellos with the issued token', async () => {
const { deps, store } = memDeps();
const t = makeTransport(daemon.wsPort, deps); // autoPair: true (default)
await t.connect();
expect(t.state).toBe('connected');
expect(t.status.daemonVersion).toBeTruthy();
expect(store.token).toBeTruthy();
pairedToken = store.token!;
t.disconnect();
});
it('the pairing token survives a reconnect: a fresh transport reuses it with no fresh pairing', async () => {
const { deps } = memDeps({ token: pairedToken });
// autoPair: false — if this succeeds at all, it can only be because the stored
// token from the previous test was accepted outright, not because this transport
// silently re-paired.
const t = makeTransport(daemon.wsPort, deps, { autoPair: false });
await t.connect();
expect(t.state).toBe('connected');
t.disconnect();
});
it('a wrong token is rejected, and repeating it rate-limits the next pairing attempt', async () => {
// Five failed session.hello attempts from this origin (ws_server.cpp records a
// rate-limiter failure on every not-paired hello, not only on a failed session.pair)
// exhausts the window; the sixth thing this origin tries — a pairing attempt — gets
// RateLimited rather than a fresh token.
for (let i = 0; i < 5; i += 1) {
const { deps } = memDeps({ token: 'not-the-real-token' });
const t = makeTransport(daemon.wsPort, deps, { autoPair: false });
await expect(t.connect()).rejects.toBeInstanceOf(RpcError);
t.disconnect();
}
const { deps } = memDeps(); // no token -> autoPair kicks in -> session.pair
const t = makeTransport(daemon.wsPort, deps);
await expect(t.connect()).rejects.toBeInstanceOf(RpcError);
expect(t.status.needsPairing).toBe(true);
expect(t.status.retryAfterSec).toBeGreaterThan(0);
t.disconnect();
});
it('download.add creates a real task the engine picks up', async () => {
const { deps } = memDeps({ token: pairedToken });
const t = makeTransport(daemon.wsPort, deps, { autoPair: false });
await t.connect();
try {
const spec: DownloadSpec = { url: `${testserver.baseUrl}/plain/file/64K`, filename: 'plain-download.bin' };
const added = await t.call('download.add', spec);
expect(added.taskId).toBeTruthy();
await waitFor(async () => {
const detail = await t.call('download.get', { taskId: added.taskId });
const state = detail.summary.state;
return state === 'complete' || state === 'downloading' || state === 'verifying';
}, 10_000, 'the task to leave the queued state');
} finally {
t.disconnect();
}
}, 15_000);
it('capture.offer end to end: the real capture path takes a monitored download, ignores its own duplicate, and fails open when the daemon dies mid-offer', async () => {
const { deps } = memDeps({ token: pairedToken });
const t = makeTransport(daemon.wsPort, deps, { autoPair: false });
await t.connect();
const rules: CaptureRules = await t.call('capture.getRules', {});
expect(rules.monitoredExtensions).toContain('zip'); // seeded default (0001_initial.sql)
const hook = new CaptureHook({
offer: (params, opts) => t.call('capture.offer', params, opts),
stash: { take: () => undefined, peek: () => undefined },
getCookies: async () => [],
getRules: () => rules,
origin: ORIGIN,
});
// A throttled URL so the task the first offer creates is still active (not yet
// complete) when the dedupe offer for the same URL follows immediately after.
const url = `${testserver.baseUrl}/throttled/file/512K`;
const details: OnHeadersReceivedDetails = {
requestId: 'live-1',
url,
method: 'GET',
type: 'other',
statusCode: 200,
tabId: 1,
responseHeaders: [{ name: 'content-disposition', value: 'attachment; filename="live-capture.zip"' }],
};
const first = await hook.handle(details);
expect(first).toEqual({ cancel: true }); // the daemon took it — Firefox never starts its own download
const list = await t.call('download.list', { filter: { query: 'live-capture' } });
expect(list.items.length).toBeGreaterThan(0);
const task = list.items[0]!;
expect(task.categoryId).toBe('programs'); // "zip" routes to the built-in Programs category
expect(task.saveDir).toContain('Downloads/Programs');
// Same URL again, task still active: the daemon's own dedupe (has_active_duplicate)
// says Ignore, so the hook proceeds instead of cancelling a second time.
const dup = await hook.handle({ ...details, requestId: 'live-2' });
expect(dup).toEqual({});
// Now kill the daemon mid-offer and prove fail-open holds against the REAL binary,
// not just FakeDaemon: the hook must still resolve to {} (Firefox downloads
// normally) well inside its own 750 ms budget.
daemon.kill('SIGKILL');
await waitFor(() => daemon.hasExited(), 5_000, 'veloxd to actually die');
const started = Date.now();
const afterDeath = await hook.handle({ ...details, requestId: 'live-3', url: `${url}?after-death=1` });
const elapsedMs = Date.now() - started;
expect(afterDeath).toEqual({}); // fail open — never {cancel: true} with a dead daemon
expect(elapsedMs).toBeLessThan(900); // budget is 750ms; the hook's own timer bounds this
t.disconnect();
}, 20_000);
it('event.task.progress reaches a subscribed client (the popup\'s own path)', async () => {
if (daemon.hasExited()) {
// The previous test kills the daemon on purpose to prove fail-open; start a fresh
// one so this test still exercises the real event path end to end.
daemon = await freshVeloxd();
}
const { deps } = memDeps();
const t = makeTransport(daemon.wsPort, deps); // fresh daemon instance -> fresh pairing
await t.connect();
try {
await t.call('session.subscribe', {
events: ['event.task.added', 'event.task.state', 'event.task.progress'],
});
const progressEvents: TaskProgressEvent[] = [];
const stateEvents: TaskStateEvent[] = [];
t.on('event.task.progress', (p) => progressEvents.push(p as TaskProgressEvent));
t.on('event.task.state', (p) => stateEvents.push(p as TaskStateEvent));
const spec: DownloadSpec = { url: `${testserver.baseUrl}/throttled/file/1M`, filename: 'progress-check.bin' };
const added = await t.call('download.add', spec);
// /throttled defaults to 1 MiB/s, so a 1 MiB file takes ~1s — long enough that at
// least one 4 Hz progress tick (event.task.progress's documented cap) lands before
// it completes, exactly the path popup/store.ts consumes in the real extension.
await waitFor(
() => progressEvents.some((e) => e.tasks.some((row) => row.taskId === added.taskId)),
8_000,
'a live event.task.progress tick for our task',
);
expect(stateEvents.some((e) => e.taskId === added.taskId)).toBe(true);
} finally {
t.disconnect();
}
}, 15_000);
it('fail-open also holds through the transport itself: a call against a dead socket rejects, never hangs past its deadline', async () => {
const { deps } = memDeps();
const t = makeTransport(daemon.wsPort, deps);
await t.connect();
t.disconnect(); // closes the socket without telling the daemon anything is wrong
await expect(t.call('capture.offer', { url: 'https://example.com/x.zip', method: 'GET', tabUrl: '' }, { timeoutMs: 200 })).rejects.toBeInstanceOf(
TransportClosedError,
);
});
});
+22 -8
View File
@@ -40,16 +40,26 @@ while [ $# -gt 0 ]; do
esac
done
# Kill the server and anything it spawned. `kill $!` alone would only reap the subshell
# wrapper and leave the node process holding the port, which then breaks the next run.
# Kill the server and anything it spawned. `pkill -P "$pid"` only reaps direct children —
# tsx's actual listener is often a grandchild, which that missed, leaving it holding the
# port and breaking the next run (a leaked mockd once did exactly this). Every server
# below is launched via `setsid`, which makes it the leader of its own new session/process
# group (pgid == its own pid), so `kill -TERM -"$pid"` (negative: a process-group kill)
# reaches it and everything it spawned in one shot, however deep.
stop() {
local pid="$1"
[ -n "$pid" ] || return 0
pkill -P "$pid" 2>/dev/null || true
kill "$pid" 2>/dev/null || true
kill -TERM -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
}
# A free loopback TCP port, kernel-assigned (bind :0) rather than a fixed number — a
# hardcoded port means one leaked process from a previous run makes every future run fail
# EADDRINUSE instead of just picking a different port.
free_port() {
python3 -c "import socket; s=socket.socket(); s.bind(('127.0.0.1',0)); print(s.getsockname()[1]); s.close()"
}
cleanup() {
stop "$MOCKD_PID"
stop "$SLOW_PID"
@@ -84,8 +94,8 @@ step "generated TypeScript against a live server"
if [ -z "$EXTERNAL_UDS" ] && [ -z "$EXTERNAL_WS" ]; then
( cd "$REPO/tools/mockd" && npm install --silent --no-audit --no-fund )
UDS="$WORK/velox.sock"
WS_PORT=52080
( cd "$REPO/tools/mockd" && exec ./node_modules/.bin/tsx src/index.ts \
WS_PORT="$(free_port)"
( cd "$REPO/tools/mockd" && exec setsid ./node_modules/.bin/tsx src/index.ts \
--uds "$UDS" --ws-port "$WS_PORT" --allowed-root "$WORK" ) >"$WORK/mockd.log" 2>&1 &
MOCKD_PID=$!
# Wait for the socket rather than sleeping a guessed amount.
@@ -139,8 +149,12 @@ if [ -z "$EXTERNAL_UDS" ] && [ -z "$EXTERNAL_WS" ]; then
VXDG="$WORK/veloxd-xdg"
mkdir -p "$VXDG/runtime" "$VXDG/data" "$VXDG/config" "$VXDG/downloads"
# VELOX_PAIR_AUTO=1: the pairing approver is the D1 dev stub (EnvAutoApprover,
# daemon/src/rpc/pairing.cpp) and denies every pairing without it — without this,
# session.pair never issues a token and the WS half of this step can't even connect.
XDG_RUNTIME_DIR="$VXDG/runtime" XDG_DATA_HOME="$VXDG/data" XDG_CONFIG_HOME="$VXDG/config" \
"$VELOXD_BIN" >"$WORK/veloxd.log" 2>&1 &
VELOX_PAIR_AUTO=1 \
setsid "$VELOXD_BIN" >"$WORK/veloxd.log" 2>&1 &
VELOXD_PID=$!
VUDS="$VXDG/runtime/velox/velox.sock"
for _ in $(seq 1 50); do [ -S "$VUDS" ] && break; sleep 0.2; done
@@ -183,7 +197,7 @@ fi
step "capture.offer fails open when the daemon is too slow"
if [ -z "$EXTERNAL_UDS" ]; then
SLOW_UDS="$WORK/slow.sock"
( cd "$REPO/tools/mockd" && exec ./node_modules/.bin/tsx src/index.ts \
( cd "$REPO/tools/mockd" && exec setsid ./node_modules/.bin/tsx src/index.ts \
--uds "$SLOW_UDS" --no-ws --slow 2000 ) >"$WORK/slow.log" 2>&1 &
SLOW_PID=$!
for _ in $(seq 1 50); do [ -S "$SLOW_UDS" ] && break; sleep 0.2; done
+61 -18
View File
@@ -76,6 +76,12 @@ interface Fixture {
/** A condition the server cannot produce from the request alone. Skipped unless the
* harness has arranged it see tests/integration. */
requires?: string;
/** This request is documented to make the *server* close the connection after replying
* (e.g. a mismatched protocol major on the Unix socket). replay() reconnects afterward
* so every later fixture in the shared-connection replay isn't sent into a dead socket
* and left to time out one by one which is silent when the fixture in question is
* also on the xfail allowlist, since applyXfail accepts any failure reason. */
closesConnection?: boolean;
transport?: TransportName;
deadlineMs?: number;
request?: { jsonrpc: '2.0'; id: number | string; method: string; params?: unknown };
@@ -196,11 +202,13 @@ function staticChecks(fixtures: readonly Fixture[]): Outcome[] {
}
/**
* Methods that destroy the state later fixtures rely on. Replayed last so the suite does
* not depend on file order, which is the sort of thing that goes green locally and red in
* CI on a different filesystem.
* Methods that destroy state, or consume state another fixture creates. Replayed last so
* the suite does not depend on file order, which is the sort of thing that goes green
* locally and red in CI on a different filesystem alphabetical happens to put
* category.remove.json before category.upsert.json, and category.remove's fixture only
* has a "firmware" category to delete because category.upsert's fixture just created one.
*/
const DESTRUCTIVE = new Set<string>(['download.remove']);
const DESTRUCTIVE = new Set<string>(['download.remove', 'category.remove']);
function replayOrder(a: Fixture, b: Fixture): number {
const rank = (f: Fixture): number => (DESTRUCTIVE.has(f.request?.method ?? '') ? 1 : 0);
@@ -247,10 +255,13 @@ async function setupBindings(conn: Conn): Promise<{ bindings: Record<string, str
return { bindings, setup };
}
async function replay(conn: Conn, fixtures: readonly Fixture[],
async function replay(initialConn: Conn, fixtures: readonly Fixture[],
bindings: Record<string, string>,
includeRequires = false): Promise<Outcome[]> {
includeRequires = false,
reconnect?: () => Promise<Conn>):
Promise<{ outcomes: Outcome[]; conn: Conn }> {
const out: Outcome[] = [];
let conn = initialConn;
const t = conn.transport;
for (const f of [...fixtures].sort(replayOrder)) {
@@ -266,7 +277,14 @@ async function replay(conn: Conn, fixtures: readonly Fixture[],
}
const deadline = f.deadlineMs ?? Math.max(METHODS[method].deadlineMs, 2000);
if (process.env.DEBUG_CONFORMANCE) process.stderr.write(`>>> [${t}] ${f.file} ${method}\n`);
const frame = await conn.request(method, bind(f.request.params ?? {}, bindings), deadline);
if (process.env.DEBUG_CONFORMANCE) process.stderr.write(`<<< [${t}] ${f.file} ${frame ? 'ok' : 'TIMEOUT'}\n`);
if (f.closesConnection && reconnect) {
conn.close();
conn = await reconnect();
}
if (f.kind === 'timeout') {
out.push({
@@ -313,7 +331,7 @@ async function replay(conn: Conn, fixtures: readonly Fixture[],
out.push({ fixture: f.file, transport: t, ok: mismatch === null,
detail: mismatch ?? 'result validates and matches the golden shape' });
}
return out;
return { outcomes: out, conn };
}
/** The transport rules are part of the contract, so they get replayed too. */
@@ -364,10 +382,18 @@ function loadXfail(path: string): XfailEntry[] {
* Reconciles outcomes against the allowlist. A listed fixture that failed is downgraded
* to a pass (its detail says why). A listed fixture that *passed* is flipped to a
* failure: the entry is stale and must be deleted from the list, not left to rot.
*
* Never touches a 'static' outcome: those validate the golden fixture against the
* generated validators offline and never talk to a server, so a stub handler can't make
* one fail in the first place matching them here would just relabel an
* always-true check as "xfail" and then, since it always stays true, immediately flag it
* as an unexpected pass. (A fixture also gets *two* static outcomes params and result
* so without this exclusion a single xfail entry would print that "duplicate" twice.)
*/
function applyXfail(results: readonly Outcome[], xfail: readonly XfailEntry[]): Outcome[] {
const matches = (e: XfailEntry, r: Outcome): boolean =>
e.fixture === r.fixture && (e.transport === undefined || e.transport === r.transport);
r.transport !== 'static' && e.fixture === r.fixture &&
(e.transport === undefined || e.transport === r.transport);
return results.map((r) => {
const entry = xfail.find((e) => matches(e, r));
@@ -409,14 +435,18 @@ async function main(): Promise<void> {
const udsPath = arg('--uds');
const wsPort = arg('--ws-port');
if (udsPath) {
const conn = await connectUds(udsPath);
await conn.call('session.hello', { clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0' });
const { bindings, setup } = await setupBindings(conn);
results.push(...setup, ...(await replay(conn, fixtures, bindings, includeRequires)));
conn.close();
// Each opens (and re-opens, via `reconnect`) with the same handshake: session.hello on
// the Unix socket, session.pair + session.hello on the WebSocket. Needed because at
// least one fixture (session.hello.version-mismatch) documents that the *server* closes
// the connection after replying — replay() calls this to get a working connection back
// rather than leaving every later fixture on the shared connection to time out.
async function freshUds(): Promise<Conn> {
const conn = await connectUds(udsPath!);
await conn.call('session.hello',
{ clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0' });
return conn;
}
if (wsPort) {
async function freshWs(): Promise<Conn> {
const conn = await connectWs(Number(wsPort));
const paired = await conn.request(
'session.pair',
@@ -427,10 +457,23 @@ async function main(): Promise<void> {
if (token === undefined) throw new Error('pairing failed: no token issued');
await conn.request('session.hello',
{ clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0', token }, 5000);
return conn;
}
if (udsPath) {
const conn = await freshUds();
const { bindings, setup } = await setupBindings(conn);
results.push(...setup, ...(await replay(conn, fixtures, bindings, includeRequires)));
results.push(...(await privilegeChecks(conn)));
conn.close();
const { outcomes, conn: last } = await replay(conn, fixtures, bindings, includeRequires, freshUds);
results.push(...setup, ...outcomes);
last.close();
}
if (wsPort) {
const conn = await freshWs();
const { bindings, setup } = await setupBindings(conn);
const { outcomes, conn: last } = await replay(conn, fixtures, bindings, includeRequires, freshWs);
results.push(...setup, ...outcomes);
results.push(...(await privilegeChecks(last)));
last.close();
}
if (!udsPath && !wsPort) {
process.stdout.write('no --uds or --ws-port given: ran static checks only\n');
+14 -20
View File
@@ -1,17 +1,6 @@
[
{ "fixture": "contracts/fixtures/download.probe.json", "reason": "D2: download.probe -> -32603, needs the engine probe path" },
{ "fixture": "contracts/fixtures/errors/download.probe.probe-failed.json", "reason": "D2: download.probe -> -32603, needs the engine probe path" },
{ "fixture": "contracts/fixtures/download.pause.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/download.resume.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/download.start.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/download.cancel.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/download.remove.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/download.addBatch.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/download.refreshUrl.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/download.update.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/download.provideAuth.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/errors/download.provideAuth.not-found.json", "reason": "D3: stub handler, -32603 instead of -32010" },
{ "fixture": "contracts/fixtures/rules.list.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/rules.upsert.json", "reason": "D3: stub handler, -32603" },
@@ -25,13 +14,7 @@
{ "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.upsert.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/queue.reorder.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/queue.start.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/queue.stop.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/category.upsert.json", "reason": "D3: stub handler, -32603" },
{ "fixture": "contracts/fixtures/category.remove.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" },
@@ -40,7 +23,18 @@
{ "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": "stub handler, -32603 -- NOT in daemon/docs/deferrals.md; filed to DAEMON to add a D-row" },
{ "fixture": "contracts/fixtures/capture.offer.take.json", "reason": "stub handler, -32603 -- NOT in daemon/docs/deferrals.md; filed to DAEMON to add a D-row" },
{ "fixture": "contracts/fixtures/errors/capture.offer.ignore.json", "reason": "stub handler, -32603 -- NOT in daemon/docs/deferrals.md; filed to DAEMON to add a D-row" }
{ "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/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/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.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/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/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/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" }
]