Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79c29b47e8 | ||
|
|
8e7d14ba7e | ||
|
|
c1c5c82f8b | ||
|
|
b5c6e1f47d | ||
|
|
0468b0176a | ||
|
|
d8b7c128be | ||
|
|
e30d994d74 |
-129
@@ -1,129 +0,0 @@
|
|||||||
.TH VELOX 1 "2026-09-12" "Velox" "User Commands"
|
|
||||||
.SH NAME
|
|
||||||
velox \- command-line client for veloxd, the Velox download manager daemon
|
|
||||||
.SH SYNOPSIS
|
|
||||||
.B velox
|
|
||||||
.I command
|
|
||||||
.RI [ options ]
|
|
||||||
.SH DESCRIPTION
|
|
||||||
.B velox
|
|
||||||
talks to
|
|
||||||
\fBveloxd\fR
|
|
||||||
over its Unix-domain socket and gives a scriptable, terminal-first view of the same
|
|
||||||
downloads the GUI and the Firefox extension see. It does no downloading itself \(em every
|
|
||||||
command is a thin JSON-RPC call; the daemon owns all state.
|
|
||||||
.PP
|
|
||||||
.B veloxd
|
|
||||||
must already be running (see
|
|
||||||
.B ENVIRONMENT
|
|
||||||
below for how
|
|
||||||
.B velox
|
|
||||||
finds it, and
|
|
||||||
.BR systemctl (1)
|
|
||||||
.RI ( "systemctl --user status velox" )
|
|
||||||
for whether it's up). If nothing is listening on the socket,
|
|
||||||
.B velox
|
|
||||||
exits with status 3 rather than hanging.
|
|
||||||
.SH COMMANDS
|
|
||||||
.TP
|
|
||||||
.BI "add " url " [\-\-dir " dir "] [\-\-out " name "] [\-\-segments " n ]
|
|
||||||
Add a new download. Prints the new task's id and initial state.
|
|
||||||
.RS
|
|
||||||
.TP
|
|
||||||
.BI "\-\-dir " dir
|
|
||||||
Destination directory. Must resolve inside one of the daemon's configured
|
|
||||||
.B saveTo.allowedRoots
|
|
||||||
or the call fails; omit to use the configured default download directory.
|
|
||||||
.TP
|
|
||||||
.BI "\-\-out " name
|
|
||||||
Filename to save as. Omit to derive one from the URL (or, once the daemon has probed it,
|
|
||||||
from the server's own
|
|
||||||
.BR Content-Disposition ).
|
|
||||||
.TP
|
|
||||||
.BI "\-\-segments " n
|
|
||||||
Requested connection count for this download, 1\(en32. The daemon may use fewer \(em a
|
|
||||||
per-host cap or a source that turns out not to support resuming both lower it. The
|
|
||||||
.B ls
|
|
||||||
table (and
|
|
||||||
.RI "\-\-json's " segments
|
|
||||||
field) show what was actually granted, not what was asked for.
|
|
||||||
.RE
|
|
||||||
.TP
|
|
||||||
.B ls
|
|
||||||
List every download the daemon knows about: id, state, progress, and filename. With
|
|
||||||
.BR \-\-json ", the raw " download.list " result instead of the table."
|
|
||||||
.TP
|
|
||||||
.BI "pause " id " [" id " ...]"
|
|
||||||
Pause one or more downloads by id. A download already paused, or already finished, is
|
|
||||||
left alone \(em not an error.
|
|
||||||
.TP
|
|
||||||
.BI "resume " id " [" id " ...]"
|
|
||||||
Resume one or more paused downloads.
|
|
||||||
.TP
|
|
||||||
.BI "rm " id " [" id " ...] " "[\-\-delete\-file]"
|
|
||||||
Remove one or more downloads from the list. Without
|
|
||||||
.BR \-\-delete\-file ,
|
|
||||||
any partial data on disk
|
|
||||||
.RI ( .veloxpart / .veloxpart.meta )
|
|
||||||
is discarded but a
|
|
||||||
.B completed
|
|
||||||
file is left in place. With
|
|
||||||
.BR \-\-delete\-file ,
|
|
||||||
the finished file is deleted too \(em there is deliberately no default for this flag; you
|
|
||||||
must say which you mean every time.
|
|
||||||
.SH OPTIONS
|
|
||||||
.TP
|
|
||||||
.B \-\-json
|
|
||||||
Print the raw JSON-RPC result instead of a formatted table. Works with every command;
|
|
||||||
combine with
|
|
||||||
.BR jq (1)
|
|
||||||
for scripting. On error, the JSON form is an
|
|
||||||
.B {"error": {...}}
|
|
||||||
object on stdout rather than a message on stderr.
|
|
||||||
.TP
|
|
||||||
.B \-h ", " \-\-help
|
|
||||||
Print usage and exit 0.
|
|
||||||
.SH EXIT STATUS
|
|
||||||
.TP
|
|
||||||
.B 0
|
|
||||||
Success.
|
|
||||||
.TP
|
|
||||||
.B 1
|
|
||||||
The daemon reached the call but returned a JSON-RPC error (bad task id, path outside the
|
|
||||||
allowed roots, and so on). The message is on stderr, or in the JSON error object with
|
|
||||||
.BR \-\-json .
|
|
||||||
.TP
|
|
||||||
.B 2
|
|
||||||
Usage error \(em missing argument, unknown option, or unknown command.
|
|
||||||
.TP
|
|
||||||
.B 3
|
|
||||||
Could not reach
|
|
||||||
.B veloxd
|
|
||||||
at all: not running, or its socket is missing or unreachable.
|
|
||||||
.SH ENVIRONMENT
|
|
||||||
.TP
|
|
||||||
.B XDG_RUNTIME_DIR
|
|
||||||
.B velox
|
|
||||||
connects to
|
|
||||||
.IR "$XDG_RUNTIME_DIR/velox/velox.sock" .
|
|
||||||
If unset, it falls back to
|
|
||||||
.IR /run/user/ <uid> ,
|
|
||||||
matching
|
|
||||||
\fBveloxd\fR's
|
|
||||||
own resolution \(em the two must agree for the client to find the daemon, so this is
|
|
||||||
normally left to the desktop session's default rather than set by hand.
|
|
||||||
.SH FILES
|
|
||||||
.TP
|
|
||||||
.I $XDG_RUNTIME_DIR/velox/velox.sock
|
|
||||||
The daemon's Unix-domain socket, mode 0600, same-UID only (\fBSO_PEERCRED\fR checked on
|
|
||||||
every connection \(em this is the transport's authorization, not an extra login).
|
|
||||||
.SH SEE ALSO
|
|
||||||
.BR systemctl (1),
|
|
||||||
.BR jq (1)
|
|
||||||
.PP
|
|
||||||
.I docs/01-architecture.md
|
|
||||||
and
|
|
||||||
.I docs/agents/AGENT-DAEMON.md
|
|
||||||
in the Velox source tree for the daemon's own build order and the wire protocol
|
|
||||||
.B velox
|
|
||||||
speaks.
|
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
],
|
],
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"url": "https://example.org/",
|
"url": "https://example.org/",
|
||||||
"categoryId": "compressed",
|
"categoryId": "programs",
|
||||||
"startMode": "queue",
|
"startMode": "queue",
|
||||||
"queueId": "main"
|
"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",
|
"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"
|
"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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,19 @@ std::string lower(std::string s) {
|
|||||||
return 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
|
} // namespace
|
||||||
|
|
||||||
// What to do once every worker has drained (see DownloadTaskState::begin_drain_locked).
|
// What to do once every worker has drained (see DownloadTaskState::begin_drain_locked).
|
||||||
@@ -88,6 +101,7 @@ struct SegWorker {
|
|||||||
bool needs_auth = false;
|
bool needs_auth = false;
|
||||||
bool wrong_status = false;
|
bool wrong_status = false;
|
||||||
bool range_bad = 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
|
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::string resp_etag, resp_last_modified; // captured on a wrong_status 200, for demote
|
||||||
std::optional<ErrorInfo> flush_error;
|
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::optional<std::uint64_t> total_size;
|
||||||
std::string origin_host;
|
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<segment::Segmenter> seg;
|
||||||
std::unique_ptr<io::SparseFile> file;
|
std::unique_ptr<io::SparseFile> file;
|
||||||
std::unordered_map<std::uint32_t, std::unique_ptr<SegWorker>> workers;
|
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;
|
std::vector<std::function<void()>> deferred;
|
||||||
|
|
||||||
DownloadTaskState(TaskHost &h, TaskId i, DownloadSpec s, DownloadCallbacks c)
|
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 -------------------------------------------------------------
|
// --- deferred callbacks -------------------------------------------------------------
|
||||||
void defer(std::function<void()> fn) {
|
void defer(std::function<void()> fn) {
|
||||||
@@ -286,7 +310,7 @@ void DownloadTaskState::restart_probe(bool with_auth) {
|
|||||||
pr.url = spec.url;
|
pr.url = spec.url;
|
||||||
pr.headers = spec.headers;
|
pr.headers = spec.headers;
|
||||||
pr.cookies = spec.cookies;
|
pr.cookies = spec.cookies;
|
||||||
pr.referrer = spec.referrer;
|
pr.referrer = effective_referrer;
|
||||||
pr.user_agent = spec.user_agent;
|
pr.user_agent = spec.user_agent;
|
||||||
pr.proxy = spec.proxy;
|
pr.proxy = spec.proxy;
|
||||||
if (with_auth)
|
if (with_auth)
|
||||||
@@ -299,12 +323,37 @@ void DownloadTaskState::restart_probe(bool with_auth) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void DownloadTaskState::on_probe_result(Result<net::ProbeResult> r) {
|
void DownloadTaskState::on_probe_result(Result<net::ProbeResult> r) {
|
||||||
|
bool retry_probe_with_referrer = false;
|
||||||
{
|
{
|
||||||
std::unique_lock lk(mu);
|
std::unique_lock lk(mu);
|
||||||
if (retired.load() || is_terminal(state))
|
if (retired.load() || is_terminal(state))
|
||||||
return;
|
return;
|
||||||
if (!r.has_value()) {
|
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 {
|
} else {
|
||||||
probe = std::move(r).value();
|
probe = std::move(r).value();
|
||||||
have_probe = true;
|
have_probe = true;
|
||||||
@@ -319,6 +368,8 @@ void DownloadTaskState::on_probe_result(Result<net::ProbeResult> r) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
flush_deferred();
|
flush_deferred();
|
||||||
|
if (retry_probe_with_referrer)
|
||||||
|
restart_probe(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
void DownloadTaskState::finish_probe_locked() {
|
void DownloadTaskState::finish_probe_locked() {
|
||||||
@@ -472,7 +523,7 @@ void DownloadTaskState::start_worker_locked(std::uint32_t seg_idx) {
|
|||||||
req.url = current_url();
|
req.url = current_url();
|
||||||
req.headers = spec.headers;
|
req.headers = spec.headers;
|
||||||
req.cookies = spec.cookies;
|
req.cookies = spec.cookies;
|
||||||
req.referrer = spec.referrer;
|
req.referrer = effective_referrer;
|
||||||
req.user_agent = spec.user_agent;
|
req.user_agent = spec.user_agent;
|
||||||
req.proxy = spec.proxy;
|
req.proxy = spec.proxy;
|
||||||
req.auth = spec.auth;
|
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;
|
w->range_bad = true;
|
||||||
return net::DataAction::abort;
|
return net::DataAction::abort;
|
||||||
}
|
}
|
||||||
|
if (h.status == 403) {
|
||||||
|
w->forbidden = true;
|
||||||
|
return net::DataAction::abort;
|
||||||
|
}
|
||||||
if (h.status >= 400)
|
if (h.status >= 400)
|
||||||
return net::DataAction::abort;
|
return net::DataAction::abort;
|
||||||
seg->set_segment_state(seg_idx, segment::SegState::downloading);
|
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
|
// (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.
|
// 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 &&
|
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;
|
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) {
|
if (len != 0 && seg->segment_completed(seg_idx) >= len) {
|
||||||
r = Result<net::TransferStats>(net::TransferStats{});
|
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);
|
auto_pause_locked(ErrorInfo(Error::range_not_satisfiable, "416"), false, true);
|
||||||
return done();
|
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()) {
|
if (!r.has_value()) {
|
||||||
ErrorInfo e = std::move(r).error();
|
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;
|
net::ProbeRequest pr;
|
||||||
pr.url = spec.url;
|
pr.url = spec.url;
|
||||||
pr.headers = spec.headers;
|
pr.headers = spec.headers;
|
||||||
|
pr.referrer = effective_referrer;
|
||||||
pr.auth = spec.auth;
|
pr.auth = spec.auth;
|
||||||
pr.proxy = spec.proxy;
|
pr.proxy = spec.proxy;
|
||||||
host.probe(std::move(pr), [wp](Result<net::ProbeResult> r) {
|
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);
|
std::unique_lock lk(s->mu);
|
||||||
if (s->retired.load() || is_terminal(s->state))
|
if (s->retired.load() || is_terminal(s->state))
|
||||||
return;
|
return;
|
||||||
if (r.has_value()) {
|
if (!r.has_value()) {
|
||||||
s->probe.effective_url = r.value().effective_url;
|
lk.unlock();
|
||||||
s->probe.etag = r.value().etag;
|
s->flush_deferred();
|
||||||
s->probe.last_modified = r.value().last_modified;
|
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)
|
if (s->registered)
|
||||||
s->host.budget().set_want(s->id, s->want_slots());
|
s->host.budget().set_want(s->id, s->want_slots());
|
||||||
|
|||||||
@@ -28,7 +28,14 @@ namespace vdm::testing {
|
|||||||
|
|
||||||
class TestServer {
|
class TestServer {
|
||||||
public:
|
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;
|
const char *script = VDM_TESTSERVER_PY;
|
||||||
if (!script || !*script || ::access(script, R_OK) != 0)
|
if (!script || !*script || ::access(script, R_OK) != 0)
|
||||||
return;
|
return;
|
||||||
@@ -50,8 +57,9 @@ class TestServer {
|
|||||||
int devnull = ::open("/dev/null", O_WRONLY);
|
int devnull = ::open("/dev/null", O_WRONLY);
|
||||||
if (devnull >= 0)
|
if (devnull >= 0)
|
||||||
::dup2(devnull, STDERR_FILENO);
|
::dup2(devnull, STDERR_FILENO);
|
||||||
|
std::string loris_str = std::to_string(loris_seconds);
|
||||||
::execlp("python3", "python3", script, "--port", "0", "--seed", "9", "--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);
|
::_exit(127);
|
||||||
}
|
}
|
||||||
::close(pipefd[1]);
|
::close(pipefd[1]);
|
||||||
|
|||||||
@@ -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);
|
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 spec_for(TestServer &srv, const std::string &urlpath, const std::string &save) {
|
||||||
DownloadSpec s;
|
DownloadSpec s;
|
||||||
s.url = srv.url(urlpath);
|
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_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
|
// --- 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). ---
|
// 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
|
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
|
// --- 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
|
// download while downloaded bytes visibly advance. DAEMON reads progress by polling
|
||||||
// DownloadHandle::progress() (engine_port_core.hpp), not the on_progress push callback --
|
// DownloadHandle::progress() (engine_port_core.hpp), not the on_progress push callback --
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ target_link_libraries(veloxd_sched PUBLIC velox::proto velox::core veloxd_store
|
|||||||
add_library(veloxd_rpc STATIC
|
add_library(veloxd_rpc STATIC
|
||||||
src/rpc/runtime_dir.cpp
|
src/rpc/runtime_dir.cpp
|
||||||
src/rpc/single_instance.cpp
|
src/rpc/single_instance.cpp
|
||||||
src/rpc/systemd_activation.cpp
|
|
||||||
src/rpc/event_loop.cpp
|
src/rpc/event_loop.cpp
|
||||||
src/rpc/event_hub.cpp
|
src/rpc/event_hub.cpp
|
||||||
src/rpc/uds_server.cpp
|
src/rpc/uds_server.cpp
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ close. Kept here (not buried in commit messages) so the next pass can see them a
|
|||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| ~~D7~~ | **Closed — `capture.offer` is real.** Applies `capture.enabled`/`excludedHosts`/`monitoredExtensions`/`monitoredMimeTypes`/`minSizeBytes` from settings, then the rules table (`store::Rules` + CORE's `vdm::rules::match_rules`/`glob_match` — DAEMON only converts its own stored `proto::Rule` JSON into CORE's plain `vdm::rules::Rule` vocabulary, per that header's own layering note), resolves the category folder (a rule's explicit `categoryId`/`saveDir`, else `store::Categories::guess_by_extension` — the same extension-guess `download.probe`'s `suggestedCategoryId` already used, now shared instead of duplicated), dedupes against active (non-terminal) tasks by exact URL, and on `take` calls `add_one()` — the same path `download.add` itself uses — so a captured download is a real, admitted, persisted task, not a special case. The 750 ms deadline (CLAUDE.md §4 / AGENT-DAEMON.md build step 6) is checked cooperatively between every step via a new `rpc::CaptureDataSource` seam (real impl wraps `store::*`; a test fake can jump its own clock forward to simulate "the store was slow just now" with zero real sleep) — catches the realistic failure mode (several slow steps adding up) though it can't preempt one pathologically stuck single call. Verified against real `veloxd` + `tools/testserver`: a monitored-type offer answers in ~5ms and actually creates + downloads the task; an unmonitored type, an excluded host, a rule-vetoed host, and a second offer for a still-active URL all answer `ignore` with the right `reason`; a bad category save dir surfaces its real `-32011` rather than being swallowed. New `capture_offer_test` covers all of the above plus the deadline itself (two cases, one per "slow" checkpoint), asserting real wall-clock time barely moves even though the fake clock jumped 2 simulated seconds — proof the check reads the injected clock, not a disguised sleep. | `rpc/capture_data_source.hpp`, `rpc/dispatcher.{hpp,cpp}`, `store/rules.{hpp,cpp}`, `store/categories.{hpp,cpp}`, `store/tasks.{hpp,cpp}` | — | done |
|
| ~~D7~~ | **Closed — `capture.offer` is real.** Applies `capture.enabled`/`excludedHosts`/`monitoredExtensions`/`monitoredMimeTypes`/`minSizeBytes` from settings, then the rules table (`store::Rules` + CORE's `vdm::rules::match_rules`/`glob_match` — DAEMON only converts its own stored `proto::Rule` JSON into CORE's plain `vdm::rules::Rule` vocabulary, per that header's own layering note), resolves the category folder (a rule's explicit `categoryId`/`saveDir`, else `store::Categories::guess_by_extension` — the same extension-guess `download.probe`'s `suggestedCategoryId` already used, now shared instead of duplicated), dedupes against active (non-terminal) tasks by exact URL, and on `take` calls `add_one()` — the same path `download.add` itself uses — so a captured download is a real, admitted, persisted task, not a special case. The 750 ms deadline (CLAUDE.md §4 / AGENT-DAEMON.md build step 6) is checked cooperatively between every step via a new `rpc::CaptureDataSource` seam (real impl wraps `store::*`; a test fake can jump its own clock forward to simulate "the store was slow just now" with zero real sleep) — catches the realistic failure mode (several slow steps adding up) though it can't preempt one pathologically stuck single call. Verified against real `veloxd` + `tools/testserver`: a monitored-type offer answers in ~5ms and actually creates + downloads the task; an unmonitored type, an excluded host, a rule-vetoed host, and a second offer for a still-active URL all answer `ignore` with the right `reason`; a bad category save dir surfaces its real `-32011` rather than being swallowed. New `capture_offer_test` covers all of the above plus the deadline itself (two cases, one per "slow" checkpoint), asserting real wall-clock time barely moves even though the fake clock jumped 2 simulated seconds — proof the check reads the injected clock, not a disguised sleep. | `rpc/capture_data_source.hpp`, `rpc/dispatcher.{hpp,cpp}`, `store/rules.{hpp,cpp}`, `store/categories.{hpp,cpp}`, `store/tasks.{hpp,cpp}` | — | done |
|
||||||
| ~~D8~~ | **Closed alongside D7** — `capture.getRules` returns the same settings-backed `enabled`/`monitoredExtensions`/`monitoredMimeTypes`/`minSizeBytes`/`excludedHosts`/`bypassModifier` capture.offer itself reads, so the two can never drift. `rulesVersion` is a constant `1` — there is no persisted revision counter yet (nothing writes `rules.*` outside this process's own lifetime to need one across a restart), and the extension already re-fetches on `event.settings.changed` regardless of what this number does; noted in case a real counter becomes worth adding later. | `rpc/dispatcher.cpp` | `rulesVersion` is a placeholder constant | — |
|
| ~~D8~~ | **Closed alongside D7** — `capture.getRules` returns the same settings-backed `enabled`/`monitoredExtensions`/`monitoredMimeTypes`/`minSizeBytes`/`excludedHosts`/`bypassModifier` capture.offer itself reads, so the two can never drift. `rulesVersion` is a constant `1` — there is no persisted revision counter yet (nothing writes `rules.*` outside this process's own lifetime to need one across a restart), and the extension already re-fetches on `event.settings.changed` regardless of what this number does; noted in case a real counter becomes worth adding later. | `rpc/dispatcher.cpp` | `rulesVersion` is a placeholder constant | — |
|
||||||
| D1 | Pairing prompt is `EnvAutoApprover` (needs `VELOX_PAIR_AUTO=1`) | `rpc/pairing.hpp`, `main.cpp` | A GUI dialog / `org.freedesktop.Notifications` approver is integration work | the `org.freedesktop.Notifications` half of build step 7 — the systemd half closed as D11 below |
|
| D1 | Pairing prompt is `EnvAutoApprover` (needs `VELOX_PAIR_AUTO=1`) | `rpc/pairing.hpp`, `main.cpp` | A GUI dialog / `org.freedesktop.Notifications` approver is integration work | Build step 7 (systemd + notifications) |
|
||||||
| — | **D1, checked this pass, not attempted:** `libdbus-1-dev` (or `libsystemd-dev` for `sd-bus`) has no headers installed in this build environment — only the runtime `.so`s (`dpkg -l`/`apt-cache policy` confirm `libdbus-1-3` present, `libdbus-1-dev` not, "Candidate" available but not installed). A real notification-backed approver needs one of those linked into `veloxd`, which is a new build dependency for `daemon/CMakeLists.txt` (`find_package`/`pkg_check_modules`) and — since packaging manifests need to know about it too — arguably a decision to surface rather than something to reach for silently mid-session. `PairingApprover::approve()` is also still synchronous by shape (its own doc comment already says so: "the real notification-backed approver will run async and is not this shape") — swapping it for the async pattern this session built for `download.probe` (`rpc::TaskActionPort` + the server-layer deferred-reply special-case) is the right shape once there's a real implementation to justify the churn; reshaping the interface with nothing behind it yet would just be churn. Left `EnvAutoApprover` in place rather than build a fragile hand-rolled D-Bus wire client to avoid the missing headers — a broken pairing approver is worse than an honest stub. | `rpc/pairing.hpp` | missing dev headers + an undiscussed new dependency | once `libdbus-1-dev`/`libsystemd-dev` is available and the dependency is approved |
|
| — | **D1, checked this pass, not attempted:** `libdbus-1-dev` (or `libsystemd-dev` for `sd-bus`) has no headers installed in this build environment — only the runtime `.so`s (`dpkg -l`/`apt-cache policy` confirm `libdbus-1-3` present, `libdbus-1-dev` not, "Candidate" available but not installed). A real notification-backed approver needs one of those linked into `veloxd`, which is a new build dependency for `daemon/CMakeLists.txt` (`find_package`/`pkg_check_modules`) and — since packaging manifests need to know about it too — arguably a decision to surface rather than something to reach for silently mid-session. `PairingApprover::approve()` is also still synchronous by shape (its own doc comment already says so: "the real notification-backed approver will run async and is not this shape") — swapping it for the async pattern this session built for `download.probe` (`rpc::TaskActionPort` + the server-layer deferred-reply special-case) is the right shape once there's a real implementation to justify the churn; reshaping the interface with nothing behind it yet would just be churn. Left `EnvAutoApprover` in place rather than build a fragile hand-rolled D-Bus wire client to avoid the missing headers — a broken pairing approver is worse than an honest stub. | `rpc/pairing.hpp` | missing dev headers + an undiscussed new dependency | once `libdbus-1-dev`/`libsystemd-dev` is available and the dependency is approved |
|
||||||
| ~~D2~~ | **Closed** — `download.probe` is real on both transports. It's genuinely async (the engine's probe pool, up to the schema's 30s `x-deadlineMs`) and so cannot fit `VeloxDispatcher::on_download_probe`'s synchronous `HandlerResult<T>` return — `uds_server.cpp`/`ws_server.cpp` special-case `"download.probe"` before the generic `dispatch()`, exactly the way they already special-case `session.hello`/`session.subscribe`, and queue the reply whenever the callback fires. `rpc::TaskActionPort::probe_now` (kept in proto/std terms, no `vdm::net::*`, so `veloxd_rpc` never needs `core/include`'s vdm headers) is what both transports call; `sched::Scheduler::probe_now` is the implementation — builds a `vdm::net::ProbeRequest`, runs it on the engine's probe pool, maps a failure to `-32013 ProbeFailed` (with `data.httpStatus` when there was one), and fills `suggestedCategoryId`/`suggestedSaveDir` with a plain extension match against the categories table (not the real rules engine — that's still D3). Verified live: a real probe answers in ~5ms; a bad host maps to `-32013`; a connection issuing a 10s `slow-loris` probe does not block a second connection's `download.list` (answered in ~1ms) — confirms the async design actually keeps the loop free, not just compiles. | `rpc/task_action_port.hpp`, `rpc/{uds_server,ws_server}.{hpp,cpp}`, `sched/scheduler.{cpp,hpp}` | — | done |
|
| ~~D2~~ | **Closed** — `download.probe` is real on both transports. It's genuinely async (the engine's probe pool, up to the schema's 30s `x-deadlineMs`) and so cannot fit `VeloxDispatcher::on_download_probe`'s synchronous `HandlerResult<T>` return — `uds_server.cpp`/`ws_server.cpp` special-case `"download.probe"` before the generic `dispatch()`, exactly the way they already special-case `session.hello`/`session.subscribe`, and queue the reply whenever the callback fires. `rpc::TaskActionPort::probe_now` (kept in proto/std terms, no `vdm::net::*`, so `veloxd_rpc` never needs `core/include`'s vdm headers) is what both transports call; `sched::Scheduler::probe_now` is the implementation — builds a `vdm::net::ProbeRequest`, runs it on the engine's probe pool, maps a failure to `-32013 ProbeFailed` (with `data.httpStatus` when there was one), and fills `suggestedCategoryId`/`suggestedSaveDir` with a plain extension match against the categories table (not the real rules engine — that's still D3). Verified live: a real probe answers in ~5ms; a bad host maps to `-32013`; a connection issuing a 10s `slow-loris` probe does not block a second connection's `download.list` (answered in ~1ms) — confirms the async design actually keeps the loop free, not just compiles. | `rpc/task_action_port.hpp`, `rpc/{uds_server,ws_server}.{hpp,cpp}`, `sched/scheduler.{cpp,hpp}` | — | done |
|
||||||
| D3 | Stub handlers for the rest: `grabber.*`, `media.*` | `rpc/dispatcher.cpp` | HLS/DASH grabber and media-variant support don't exist anywhere in this build yet — a bigger feature than a store-wiring pass | M4 territory, per AGENT-DAEMON.md |
|
| D3 | Stub handlers for the rest: `grabber.*`, `media.*` | `rpc/dispatcher.cpp` | HLS/DASH grabber and media-variant support don't exist anywhere in this build yet — a bigger feature than a store-wiring pass | M4 territory, per AGENT-DAEMON.md |
|
||||||
@@ -26,5 +26,4 @@ close. Kept here (not buried in commit messages) so the next pass can see them a
|
|||||||
| ~~D4b~~ | **Closed** — `download.pause`/`resume`/`start`/`cancel` and `queue.start`/`stop` all drive the scheduler now, and apply *immediately* (not deferred to the next tick — pausing/resuming/cancelling a live transfer can't wait up to 1s, and per ADR 0013 §3 the governor never touches a user-owned pause on its own). New `rpc::TaskActionPort` interface (owned by `rpc/`, implemented by `sched::Scheduler`) is the seam dispatcher.hpp depends on instead of `sched/scheduler.hpp` directly — avoids a real `veloxd_rpc` <-> `veloxd_sched` circular library dependency (`veloxd_sched` already links `veloxd_rpc` for `EventHub`). `Scheduler::user_pause/resume/start/cancel` + `pause_queue` engine-call-then-eager-transition, matching `tick()`'s existing `to_pause` pattern. Fixed a real bug hit while building this: `transition()` always overwrote `pause_reason` to NULL when the engine's own delayed pause-ack callback arrived with no explicit reason, clobbering whatever the actual initiator (user or governor) had just written — now it preserves the stored reason when none is supplied. Verified against real `veloxd` + `tools/testserver`: pausing a live single-segment throttled transfer freezes `downloadedBytes`, resume continues it from that point, cancel stops it; `queue.stop(pauseRunning:true)` pauses the queue's running task immediately. NOTE: `download.start`'s contract "a task in 'queued' jumps its queue" (priority bump) is not implemented — admission is still plain FIFO by `created_at`. | `sched/scheduler.{cpp,hpp}`, `rpc/task_action_port.hpp`, `rpc/dispatcher.{hpp,cpp}`, `store/queues.{cpp,hpp}` | — | done, except the queue-jump priority bump noted above |
|
| ~~D4b~~ | **Closed** — `download.pause`/`resume`/`start`/`cancel` and `queue.start`/`stop` all drive the scheduler now, and apply *immediately* (not deferred to the next tick — pausing/resuming/cancelling a live transfer can't wait up to 1s, and per ADR 0013 §3 the governor never touches a user-owned pause on its own). New `rpc::TaskActionPort` interface (owned by `rpc/`, implemented by `sched::Scheduler`) is the seam dispatcher.hpp depends on instead of `sched/scheduler.hpp` directly — avoids a real `veloxd_rpc` <-> `veloxd_sched` circular library dependency (`veloxd_sched` already links `veloxd_rpc` for `EventHub`). `Scheduler::user_pause/resume/start/cancel` + `pause_queue` engine-call-then-eager-transition, matching `tick()`'s existing `to_pause` pattern. Fixed a real bug hit while building this: `transition()` always overwrote `pause_reason` to NULL when the engine's own delayed pause-ack callback arrived with no explicit reason, clobbering whatever the actual initiator (user or governor) had just written — now it preserves the stored reason when none is supplied. Verified against real `veloxd` + `tools/testserver`: pausing a live single-segment throttled transfer freezes `downloadedBytes`, resume continues it from that point, cancel stops it; `queue.stop(pauseRunning:true)` pauses the queue's running task immediately. NOTE: `download.start`'s contract "a task in 'queued' jumps its queue" (priority bump) is not implemented — admission is still plain FIFO by `created_at`. | `sched/scheduler.{cpp,hpp}`, `rpc/task_action_port.hpp`, `rpc/dispatcher.{hpp,cpp}`, `store/queues.{cpp,hpp}` | — | done, except the queue-jump priority bump noted above |
|
||||||
| ~~D5~~ | **Mostly closed** — `rpc/event_hub` fans out per-subscription; `session.subscribe` on both transports registers/updates/tears down a real subscription; `Scheduler::transition()` publishes `event.task.state` (with `previousState`) on every state change, scheduler-driven or engine-reported; `dispatcher::on_download_add` publishes `event.task.added`; a 250 ms timer batches `Scheduler::progress_snapshot()` into one `event.task.progress` array per AGENT-DAEMON.md item 5 / the schema's `x-maxRateHz: 4`. Verified live end to end. | — | `event.task.removed` has no source yet (`download.remove` is D3); `event.speed.global`, `event.notify`, `event.auth.required`, `event.settings.changed`, `event.grabber.progress` are unpublished — each lands with its owning handler | as each owning D3 handler lands |
|
| ~~D5~~ | **Mostly closed** — `rpc/event_hub` fans out per-subscription; `session.subscribe` on both transports registers/updates/tears down a real subscription; `Scheduler::transition()` publishes `event.task.state` (with `previousState`) on every state change, scheduler-driven or engine-reported; `dispatcher::on_download_add` publishes `event.task.added`; a 250 ms timer batches `Scheduler::progress_snapshot()` into one `event.task.progress` array per AGENT-DAEMON.md item 5 / the schema's `x-maxRateHz: 4`. Verified live end to end. | — | `event.task.removed` has no source yet (`download.remove` is D3); `event.speed.global`, `event.notify`, `event.auth.required`, `event.settings.changed`, `event.grabber.progress` are unpublished — each lands with its owning handler | as each owning D3 handler lands |
|
||||||
| ~~D6~~ | **Closed** — engine numbers now reach the store: `Scheduler::tick()` probes (`EnginePort::probe`) before every `start()`, persisting `sizeBytes`/`resumable`/validators via `Tasks::set_probe_result` before a byte moves; `Scheduler::persist_progress()` (called from `progress_snapshot()` *and* once more from `on_engine_state` right before `release()`/unmap on every terminal transition) writes `downloadedBytes`/`speedBps`/`segments`/`segmentDetail` from the engine's `Progress`, so a task that finishes between two 250 ms ticks (the common case for anything small or fast) still leaves real numbers instead of the pre-persistence defaults. `TaskSummary.segments` is sourced from `segments.size()` when the task has any (matching what actually lands in `segmentDetail`, per the schema's "exactly `segments` entries"), falling back to the engine's `effective_segments` (budget slots *held*, not necessarily physical range count — see `core/include/vdm/task/download.hpp`'s `Progress` comment) only pre-segmentation. `Tasks::set_final_bytes` tops up `on_finished`'s byte count as a last-resort backstop. Migration `0002` adds `speed_bps` to both `tasks` and `segments`, and fixes `segments.state`'s CHECK to include `'pending'` (0001 omitted it, so a pre-connect snapshot could never be written). Verified against real `veloxd` + `tools/testserver` (not just unit tests): `download.list`/`download.get` correct immediately after completion and after a daemon restart. | `sched/scheduler.{cpp,hpp}`, `store/{tasks,segments}.{cpp,hpp}`, `store/migrations/0002_*.sql` | — | done |
|
| ~~D6~~ | **Closed** — engine numbers now reach the store: `Scheduler::tick()` probes (`EnginePort::probe`) before every `start()`, persisting `sizeBytes`/`resumable`/validators via `Tasks::set_probe_result` before a byte moves; `Scheduler::persist_progress()` (called from `progress_snapshot()` *and* once more from `on_engine_state` right before `release()`/unmap on every terminal transition) writes `downloadedBytes`/`speedBps`/`segments`/`segmentDetail` from the engine's `Progress`, so a task that finishes between two 250 ms ticks (the common case for anything small or fast) still leaves real numbers instead of the pre-persistence defaults. `TaskSummary.segments` is sourced from `segments.size()` when the task has any (matching what actually lands in `segmentDetail`, per the schema's "exactly `segments` entries"), falling back to the engine's `effective_segments` (budget slots *held*, not necessarily physical range count — see `core/include/vdm/task/download.hpp`'s `Progress` comment) only pre-segmentation. `Tasks::set_final_bytes` tops up `on_finished`'s byte count as a last-resort backstop. Migration `0002` adds `speed_bps` to both `tasks` and `segments`, and fixes `segments.state`'s CHECK to include `'pending'` (0001 omitted it, so a pre-connect snapshot could never be written). Verified against real `veloxd` + `tools/testserver` (not just unit tests): `download.list`/`download.get` correct immediately after completion and after a daemon restart. | `sched/scheduler.{cpp,hpp}`, `store/{tasks,segments}.{cpp,hpp}`, `store/migrations/0002_*.sql` | — | done |
|
||||||
| ~~D11~~ | **Closed — build order items 7 (the systemd half) and 9: `velox-nmhost`, socket activation, the systemd user units, and `velox(1)`.** `nmhost/src/main.cpp` (185 lines): a `poll()`-driven byte pump between Firefox's native-messaging framing on stdio (4-byte native-byte-order length prefix) and `veloxd`'s own NDJSON framing on the Unix socket — reframes each direction, no JSON parsing, no retry/backoff, exits the moment either side closes. Deliberately dependency-free (no `veloxd_*` library, no `nlohmann_json`) since it runs unconfined outside Firefox's sandbox whatever the packaging format. Two real bugs found and fixed while getting the integration test to actually pass rather than hang: (1) never set the pumped fds non-blocking, so the "drain what's available" read loop blocked on its own second `read()` instead of returning to `poll()`; (2) stdin and stdout are two different descriptors (0 and 1), not one — an early draft polled `POLLOUT` on fd 0, which is opened read-only, so EOF/writability were never both observable through the same `pollfd` entry. Both are exactly the class of bug a "trivial pump" invites and unit tests over the real binary (not just its helper functions) exist specifically to catch. `packaging/nativehost/com.velox.host.json` + its own `README.md` supersede `AGENT-DAEMON.md`'s stale "four locations" (spike S1 / ADR 0003 found only three are real — the fourth, `~/snap/firefox/common/.mozilla/...`, is not read by snap Firefox at all) and spell out the per-user-manifest / postinst implication for PKG/QA. `EnginePort`-style: `rpc/systemd_activation.cpp` is a from-scratch `sd_listen_fds()` (env vars only, no `libsystemd` link — `LISTEN_PID`/`LISTEN_FDS`, fd 3) that `UdsServer::start()` checks first, skipping its own create/bind/chmod/listen when systemd already bound the socket; `packaging/systemd/velox.socket` + `velox.service` are the unit pair, verified both by `systemd-analyze verify` and by an actual fork/dup2/execve simulation of the activation handshake (a real `session.hello` round-tripped over the handed-off fd with no `bind()` ever called inside the daemon for that run). `velox.service` deliberately skips `ProtectSystem=`/`ProtectHome=`/`ReadWritePaths=` — `saveTo.allowedRoots` is user-configurable to anywhere on the filesystem, and a sandbox here would turn a legitimately-configured save location into an opaque `EROFS`/`EACCES` instead of the daemon's own clear `-32011`. `cli/man/velox.1` documents the CLI as it actually exists today (`add`/`ls`/`pause`/`resume`/`rm`, `--json`, the three-tier `queue`/`settings` subcommands `AGENT-DAEMON.md` build step 8 originally sketched are not implemented in `cli/src/main.cpp` yet, so the page doesn't claim they are) — checked warning-free with `groff -mandoc -ww -z`. | `nmhost/{CMakeLists.txt,src/main.cpp,tests/}`, `daemon/src/rpc/{systemd_activation.{hpp,cpp},uds_server.cpp}`, `packaging/{nativehost,systemd}/`, `cli/man/velox.1` | — | done |
|
|
||||||
| — | ~~Observed, not fixed (CORE, not this lane)~~ — **routed to CORE by the user.** `vdm::task::Progress.speed_bps` reads back as `0` for the whole lifetime of a live, real (non-fake) throttled download, despite `downloadedBytes` visibly advancing between polls — `core/src/task/download_task.cpp`'s per-worker EWMA never seems to produce a nonzero aggregate in this build. DAEMON passes `EnginePort::progress()`'s `speed_bps` straight through (`Scheduler::persist_progress`); nothing in this lane drops it. Still reproduces in the D4b live checks above (0 throughout a paused/resumed/cancelled transfer whose `downloadedBytes` visibly moved) — not re-filed, since it's already CORE's. |
|
| — | ~~Observed, not fixed (CORE, not this lane)~~ — **routed to CORE by the user.** `vdm::task::Progress.speed_bps` reads back as `0` for the whole lifetime of a live, real (non-fake) throttled download, despite `downloadedBytes` visibly advancing between polls — `core/src/task/download_task.cpp`'s per-worker EWMA never seems to produce a nonzero aggregate in this build. DAEMON passes `EnginePort::progress()`'s `speed_bps` straight through (`Scheduler::persist_progress`); nothing in this lane drops it. Still reproduces in the D4b live checks above (0 throughout a paused/resumed/cancelled transfer whose `downloadedBytes` visibly moved) — not re-filed, since it's already CORE's. |
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
#include "rpc/systemd_activation.hpp"
|
|
||||||
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace velox::daemon::rpc {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
constexpr int kListenFdsStart = 3; // SD_LISTEN_FDS_START
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
int systemd_activated_fd() {
|
|
||||||
const char* pid_env = std::getenv("LISTEN_PID");
|
|
||||||
const char* fds_env = std::getenv("LISTEN_FDS");
|
|
||||||
int fd = -1;
|
|
||||||
|
|
||||||
if (pid_env != nullptr && fds_env != nullptr) {
|
|
||||||
try {
|
|
||||||
if (std::stol(pid_env) == static_cast<long>(::getpid()) && std::stol(fds_env) == 1) {
|
|
||||||
fd = kListenFdsStart;
|
|
||||||
}
|
|
||||||
} catch (...) {
|
|
||||||
// Malformed env from something other than systemd; treat as not activated.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contract: consumed once, then cleared, so a value meant for veloxd is never
|
|
||||||
// mistaken for one meant for a process it might itself exec later.
|
|
||||||
::unsetenv("LISTEN_PID");
|
|
||||||
::unsetenv("LISTEN_FDS");
|
|
||||||
::unsetenv("LISTEN_FDNAMES");
|
|
||||||
return fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace velox::daemon::rpc
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
// Minimal sd_listen_fds(3) reimplementation — one function, no libsystemd dependency, for
|
|
||||||
// the one fd velox.socket ever hands us. See velox.socket / velox.service in
|
|
||||||
// packaging/nativehost's systemd unit pair: the socket unit binds
|
|
||||||
// $XDG_RUNTIME_DIR/velox/velox.sock itself (before veloxd ever runs, so the very first
|
|
||||||
// connection attempt after boot is queued by the kernel rather than refused) and execs
|
|
||||||
// veloxd with that listening fd already open at fd 3, LISTEN_FDS=1, LISTEN_PID=<our pid>.
|
|
||||||
|
|
||||||
namespace velox::daemon::rpc {
|
|
||||||
|
|
||||||
// The systemd-activated listening socket fd, or -1 if this process was not socket-
|
|
||||||
// activated (LISTEN_PID doesn't match our pid, or LISTEN_FDS is unset/not exactly 1 — more
|
|
||||||
// than one would mean a unit file mismatch, since veloxd only ever asks for one socket).
|
|
||||||
// Clears LISTEN_PID/LISTEN_FDS from the environment on the way out either way, per
|
|
||||||
// sd_listen_fds's own contract, so a value meant for us is never mistaken for one meant for
|
|
||||||
// a process veloxd might itself exec later.
|
|
||||||
int systemd_activated_fd();
|
|
||||||
|
|
||||||
} // namespace velox::daemon::rpc
|
|
||||||
@@ -12,10 +12,7 @@
|
|||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
#include <fcntl.h>
|
|
||||||
|
|
||||||
#include "rpc/event_loop.hpp"
|
#include "rpc/event_loop.hpp"
|
||||||
#include "rpc/systemd_activation.hpp"
|
|
||||||
#include "version.hpp"
|
#include "version.hpp"
|
||||||
|
|
||||||
namespace velox::daemon::rpc {
|
namespace velox::daemon::rpc {
|
||||||
@@ -77,20 +74,6 @@ UdsServer::~UdsServer() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
std::error_code UdsServer::start() {
|
std::error_code UdsServer::start() {
|
||||||
// velox.socket (systemd user unit, socket activation): the unit binds this path itself
|
|
||||||
// before veloxd ever runs and hands the already-listening fd over at fd 3 — the first
|
|
||||||
// connection after boot is queued by the kernel rather than refused, and there is no
|
|
||||||
// window where a client sees ECONNREFUSED while the daemon is still starting. Skips
|
|
||||||
// create/bind/chmod/listen entirely; the socket file's lifecycle (including removal on
|
|
||||||
// stop) belongs to the unit, not to us, so bound_ stays false.
|
|
||||||
if (const int activated = systemd_activated_fd(); activated >= 0) {
|
|
||||||
::fcntl(activated, F_SETFL, O_NONBLOCK);
|
|
||||||
::fcntl(activated, F_SETFD, FD_CLOEXEC);
|
|
||||||
listen_fd_ = activated;
|
|
||||||
loop_.add_fd(listen_fd_, kRead, [this](int, unsigned) { on_listener_readable(); });
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (path_.size() + 1 > sizeof(sockaddr_un::sun_path)) return errc(ENAMETOOLONG);
|
if (path_.size() + 1 > sizeof(sockaddr_un::sun_path)) return errc(ENAMETOOLONG);
|
||||||
|
|
||||||
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
|
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ veloxd_test(sched_scheduler LIBS veloxd_sched veloxd_rpc)
|
|||||||
veloxd_test(event_hub LIBS veloxd_rpc)
|
veloxd_test(event_hub LIBS veloxd_rpc)
|
||||||
veloxd_test(store_categories_queues LIBS veloxd_store)
|
veloxd_test(store_categories_queues LIBS veloxd_store)
|
||||||
veloxd_test(single_instance LIBS veloxd_rpc)
|
veloxd_test(single_instance LIBS veloxd_rpc)
|
||||||
veloxd_test(systemd_activation LIBS veloxd_rpc)
|
|
||||||
veloxd_test(dispatcher_settings LIBS veloxd_rpc veloxd_store)
|
veloxd_test(dispatcher_settings LIBS veloxd_rpc veloxd_store)
|
||||||
veloxd_test(capture_offer LIBS veloxd_rpc veloxd_store)
|
veloxd_test(capture_offer LIBS veloxd_rpc veloxd_store)
|
||||||
veloxd_test(dispatcher_misc LIBS veloxd_rpc veloxd_store)
|
veloxd_test(dispatcher_misc LIBS veloxd_rpc veloxd_store)
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
// systemd_activated_fd(): the LISTEN_PID/LISTEN_FDS contract, without a real systemd.
|
|
||||||
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include "check.hpp"
|
|
||||||
#include "rpc/systemd_activation.hpp"
|
|
||||||
|
|
||||||
using namespace velox::daemon::rpc;
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
void set_env(const char* k, const std::string& v) { ::setenv(k, v.c_str(), 1); }
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void run() {
|
|
||||||
// Not activated: neither var set.
|
|
||||||
::unsetenv("LISTEN_PID");
|
|
||||||
::unsetenv("LISTEN_FDS");
|
|
||||||
CHECK_EQ(systemd_activated_fd(), -1);
|
|
||||||
|
|
||||||
// LISTEN_PID for a different process: not us, so not activated.
|
|
||||||
set_env("LISTEN_PID", std::to_string(::getpid() + 1));
|
|
||||||
set_env("LISTEN_FDS", "1");
|
|
||||||
CHECK_EQ(systemd_activated_fd(), -1);
|
|
||||||
// Consumed regardless of the outcome — a stale value from some other process's
|
|
||||||
// exec chain must not leak into what veloxd checks next time.
|
|
||||||
CHECK(::getenv("LISTEN_PID") == nullptr);
|
|
||||||
CHECK(::getenv("LISTEN_FDS") == nullptr);
|
|
||||||
|
|
||||||
// Our own pid, LISTEN_FDS=1: activated, fd 3 (SD_LISTEN_FDS_START).
|
|
||||||
set_env("LISTEN_PID", std::to_string(::getpid()));
|
|
||||||
set_env("LISTEN_FDS", "1");
|
|
||||||
CHECK_EQ(systemd_activated_fd(), 3);
|
|
||||||
CHECK(::getenv("LISTEN_PID") == nullptr);
|
|
||||||
|
|
||||||
// Our own pid but LISTEN_FDS=2: a unit file mismatch (veloxd only ever asks for one
|
|
||||||
// socket) — refuse rather than guess which of two fds is the right one.
|
|
||||||
set_env("LISTEN_PID", std::to_string(::getpid()));
|
|
||||||
set_env("LISTEN_FDS", "2");
|
|
||||||
CHECK_EQ(systemd_activated_fd(), -1);
|
|
||||||
|
|
||||||
// Garbage LISTEN_FDS: not activated, not a crash.
|
|
||||||
set_env("LISTEN_PID", std::to_string(::getpid()));
|
|
||||||
set_env("LISTEN_FDS", "not-a-number");
|
|
||||||
CHECK_EQ(systemd_activated_fd(), -1);
|
|
||||||
|
|
||||||
::unsetenv("LISTEN_PID");
|
|
||||||
::unsetenv("LISTEN_FDS");
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_MAIN()
|
|
||||||
@@ -43,11 +43,7 @@ Read `contracts/`, `core/include/`, `docs/`. Never write in `core/`, `gui/`, or
|
|||||||
output. Build this early: it is how you test the daemon before the GUI exists.
|
output. Build this early: it is how you test the daemon before the GUI exists.
|
||||||
9. **`velox-nmhost`** — 4-byte-length-prefixed stdio ⇄ Unix socket pump. **Under 300 lines,
|
9. **`velox-nmhost`** — 4-byte-length-prefixed stdio ⇄ Unix socket pump. **Under 300 lines,
|
||||||
zero business logic**, and it must exit cleanly when Firefox closes the pipe. Install
|
zero business logic**, and it must exit cleanly when Firefox closes the pipe. Install
|
||||||
manifests to the three real locations in `docs/05` §4 / `docs/adr/0003` — not four:
|
manifests to all four locations listed in `docs/05` §4.
|
||||||
spike S1 found `~/snap/firefox/common/.mozilla/native-messaging-hosts/` (the intuitive
|
|
||||||
"inside the snap" path) is not actually read by snap Firefox, and corrected `docs/05` §4
|
|
||||||
down from its original four-location list. `packaging/nativehost/README.md` has the
|
|
||||||
current table.
|
|
||||||
|
|
||||||
## Definition of done (M1)
|
## Definition of done (M1)
|
||||||
- Passes the full conformance suite as a server, over **both** transports.
|
- Passes the full conformance suite as a server, over **both** transports.
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ import {
|
|||||||
} from './context-menus.js';
|
} from './context-menus.js';
|
||||||
import { MediaBridge, notifyTab } from './media-bridge.js';
|
import { MediaBridge, notifyTab } from './media-bridge.js';
|
||||||
import { createTransport, transportStorage, type TransportStatus, type VeloxTransport } from './transport/index.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 transport: VeloxTransport | undefined;
|
||||||
let rules: CaptureRules = DEFAULT_CAPTURE_RULES;
|
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 {
|
function onTransportState(status: TransportStatus): void {
|
||||||
const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : '');
|
const detail = status.fatal ?? (status.needsPairing ? 'needs pairing' : '');
|
||||||
console.debug(`[velox] transport ${status.state}${detail ? ` — ${detail}` : ''}`);
|
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> {
|
async function setOverride(override: 'auto' | 'ws' | 'uds'): Promise<void> {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
# nmhost/ — velox-nmhost, the Firefox native-messaging host. Owned by lane DAEMON.
|
|
||||||
#
|
|
||||||
# Deliberately dependency-free: no veloxd_* library, no nlohmann_json, no SQLite. It is a
|
|
||||||
# byte-level pump between two framings (see src/main.cpp's own header comment) and runs
|
|
||||||
# unconfined outside Firefox's sandbox (ADR 0003) — the less it links, the less there is to
|
|
||||||
# go wrong running from wherever a snap/deb/flatpak install puts it.
|
|
||||||
|
|
||||||
add_executable(velox-nmhost src/main.cpp)
|
|
||||||
target_compile_features(velox-nmhost PRIVATE cxx_std_23)
|
|
||||||
target_compile_options(velox-nmhost PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
|
||||||
|
|
||||||
if(VELOX_BUILD_TESTS AND EXISTS ${CMAKE_CURRENT_SOURCE_DIR}/tests/CMakeLists.txt)
|
|
||||||
add_subdirectory(tests)
|
|
||||||
endif()
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
// velox-nmhost — Firefox native-messaging host. A dumb pump between two framings, nothing
|
|
||||||
// else: stdin/stdout speak Firefox's own protocol (a 4-byte native-byte-order length
|
|
||||||
// prefix, then that many bytes of UTF-8 JSON); $XDG_RUNTIME_DIR/velox/velox.sock speaks
|
|
||||||
// veloxd's own NDJSON (one '\n'-terminated JSON value per line, daemon/src/rpc/ndjson.hpp).
|
|
||||||
// Reframing between the two is the entire job.
|
|
||||||
//
|
|
||||||
// Runs unconfined outside Firefox's snap sandbox with the real $HOME and
|
|
||||||
// $XDG_RUNTIME_DIR (ADR 0003 §Q2) — see packaging/nativehost/ for the manifest this is
|
|
||||||
// installed as, and which native-messaging-hosts directory actually gets read by which
|
|
||||||
// Firefox flavour.
|
|
||||||
//
|
|
||||||
// No business logic: no JSON parsing (frames are pure byte spans; only the length prefix
|
|
||||||
// and the line boundary matter here), no retry/backoff (the extension re-launches a fresh
|
|
||||||
// host on its own reconnect), no protocol version check (veloxd and the extension settle
|
|
||||||
// that between themselves once the pump hands their bytes through). Exits the moment
|
|
||||||
// either side closes.
|
|
||||||
|
|
||||||
#include <fcntl.h>
|
|
||||||
#include <sys/socket.h>
|
|
||||||
#include <sys/un.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
#include <cerrno>
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
#include <poll.h>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
// Firefox's own cap (host -> browser) is 1 MiB; this is just a sanity backstop against a
|
|
||||||
// runaway peer so a malformed stream can't grow a buffer without bound.
|
|
||||||
constexpr std::size_t kMaxFrameBytes = 8 * 1024 * 1024;
|
|
||||||
|
|
||||||
std::string socket_path() {
|
|
||||||
const char* xdg = std::getenv("XDG_RUNTIME_DIR");
|
|
||||||
std::string base = (xdg != nullptr && xdg[0] != '\0') ? xdg
|
|
||||||
: ("/run/user/" + std::to_string(::getuid()));
|
|
||||||
if (!base.empty() && base.back() == '/') base.pop_back();
|
|
||||||
return base + "/velox/velox.sock";
|
|
||||||
}
|
|
||||||
|
|
||||||
int connect_socket(const std::string& path) {
|
|
||||||
const int fd = ::socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0);
|
|
||||||
if (fd < 0) return -1;
|
|
||||||
sockaddr_un addr{};
|
|
||||||
addr.sun_family = AF_UNIX;
|
|
||||||
if (path.size() + 1 > sizeof(addr.sun_path)) {
|
|
||||||
::close(fd);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
std::memcpy(addr.sun_path, path.c_str(), path.size());
|
|
||||||
if (::connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0) {
|
|
||||||
::close(fd);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
return fd;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads everything currently available into `buf`. true on EOF, false otherwise (short
|
|
||||||
// reads / EAGAIN just return with whatever was appended).
|
|
||||||
bool read_available(int fd, std::string& buf) {
|
|
||||||
char chunk[65536];
|
|
||||||
for (;;) {
|
|
||||||
const ssize_t n = ::read(fd, chunk, sizeof(chunk));
|
|
||||||
if (n > 0) {
|
|
||||||
buf.append(chunk, static_cast<std::size_t>(n));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (n == 0) return true; // EOF
|
|
||||||
if (errno == EAGAIN || errno == EWOULDBLOCK) return false;
|
|
||||||
if (errno == EINTR) continue;
|
|
||||||
return true; // treat any other error as if the peer hung up
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Writes as much of `buf` as the fd accepts right now, trimming what was sent. Returns
|
|
||||||
// false on a hard error (peer gone); EAGAIN is not an error, just "try again later".
|
|
||||||
bool flush_some(int fd, std::string& buf) {
|
|
||||||
while (!buf.empty()) {
|
|
||||||
const ssize_t n = ::write(fd, buf.data(), buf.size());
|
|
||||||
if (n > 0) {
|
|
||||||
buf.erase(0, static_cast<std::size_t>(n));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return true;
|
|
||||||
if (n < 0 && errno == EINTR) continue;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// stdin frames (4-byte length + payload) -> NDJSON lines appended to `to_socket`.
|
|
||||||
// Malformed (oversized) length is a hard stop.
|
|
||||||
bool drain_stdin_frames(std::string& in, std::string& to_socket) {
|
|
||||||
for (;;) {
|
|
||||||
if (in.size() < 4) return true;
|
|
||||||
std::uint32_t len;
|
|
||||||
std::memcpy(&len, in.data(), 4);
|
|
||||||
if (len > kMaxFrameBytes) return false;
|
|
||||||
if (in.size() < 4 + len) return true;
|
|
||||||
to_socket.append(in, 4, len);
|
|
||||||
to_socket.push_back('\n');
|
|
||||||
in.erase(0, 4 + len);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NDJSON lines from the socket -> stdout frames (4-byte length + payload) appended to
|
|
||||||
// `to_stdout`.
|
|
||||||
bool drain_socket_lines(std::string& in, std::string& to_stdout) {
|
|
||||||
for (;;) {
|
|
||||||
const auto nl = in.find('\n');
|
|
||||||
if (nl == std::string::npos) {
|
|
||||||
if (in.size() > kMaxFrameBytes) return false;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
std::string_view line(in.data(), nl);
|
|
||||||
if (!line.empty() && line.back() == '\r') line.remove_suffix(1);
|
|
||||||
if (line.size() > kMaxFrameBytes) return false;
|
|
||||||
const auto len = static_cast<std::uint32_t>(line.size());
|
|
||||||
to_stdout.append(reinterpret_cast<const char*>(&len), 4);
|
|
||||||
to_stdout.append(line);
|
|
||||||
in.erase(0, nl + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
int main() {
|
|
||||||
const int sock = connect_socket(socket_path());
|
|
||||||
if (sock < 0) return 1; // daemon not running / socket missing: nothing to pump
|
|
||||||
|
|
||||||
// Every fd this pumps must be non-blocking: read_available()'s own drain loop keeps
|
|
||||||
// calling read() until it actually sees EAGAIN, and a blocking fd never returns that —
|
|
||||||
// it just blocks inside the "drain what's available" loop instead of going back to
|
|
||||||
// poll(), which stalls the whole pump the moment one side has more to send than fits
|
|
||||||
// in a single read().
|
|
||||||
::fcntl(0, F_SETFL, ::fcntl(0, F_GETFL) | O_NONBLOCK);
|
|
||||||
::fcntl(1, F_SETFL, ::fcntl(1, F_GETFL) | O_NONBLOCK);
|
|
||||||
::fcntl(sock, F_SETFL, ::fcntl(sock, F_GETFL) | O_NONBLOCK);
|
|
||||||
|
|
||||||
std::string stdin_buf, stdout_buf, socket_in_buf, socket_out_buf;
|
|
||||||
bool stdin_eof = false;
|
|
||||||
|
|
||||||
// Three distinct descriptors, not two: stdin (0) and stdout (1) are separate pipes
|
|
||||||
// (read-only and write-only respectively — never the same fd, even though they sit
|
|
||||||
// next to each other in a shell's mental model of "the process's stdio"), plus the
|
|
||||||
// bidirectional socket.
|
|
||||||
enum { kStdin, kStdout, kSock };
|
|
||||||
for (;;) {
|
|
||||||
pollfd fds[3] = {
|
|
||||||
{0, 0, 0},
|
|
||||||
{1, 0, 0},
|
|
||||||
{sock, 0, 0},
|
|
||||||
};
|
|
||||||
if (!stdin_eof) fds[kStdin].events |= POLLIN;
|
|
||||||
if (!stdout_buf.empty()) fds[kStdout].events |= POLLOUT;
|
|
||||||
fds[kSock].events |= POLLIN;
|
|
||||||
if (!socket_out_buf.empty()) fds[kSock].events |= POLLOUT;
|
|
||||||
|
|
||||||
// Nothing left to wait for: both directions exhausted.
|
|
||||||
if (fds[kStdin].events == 0 && fds[kStdout].events == 0 && fds[kSock].events == 0) break;
|
|
||||||
|
|
||||||
const int n = ::poll(fds, 3, -1);
|
|
||||||
if (n < 0) {
|
|
||||||
if (errno == EINTR) continue;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fds[kStdout].revents & POLLOUT) {
|
|
||||||
if (!flush_some(1, stdout_buf)) break; // Firefox closed our stdout
|
|
||||||
}
|
|
||||||
if (fds[kSock].revents & POLLOUT) {
|
|
||||||
if (!flush_some(sock, socket_out_buf)) break;
|
|
||||||
}
|
|
||||||
if (fds[kStdin].revents & (POLLIN | POLLHUP)) {
|
|
||||||
if (read_available(0, stdin_buf)) stdin_eof = true;
|
|
||||||
if (!drain_stdin_frames(stdin_buf, socket_out_buf)) break;
|
|
||||||
}
|
|
||||||
if (fds[kSock].revents & (POLLIN | POLLHUP)) {
|
|
||||||
const bool socket_eof = read_available(sock, socket_in_buf);
|
|
||||||
if (!drain_socket_lines(socket_in_buf, stdout_buf)) break;
|
|
||||||
if (socket_eof) {
|
|
||||||
// The daemon is gone. Flush whatever we already turned into stdout
|
|
||||||
// frames, then stop — there is nothing left to relay either direction.
|
|
||||||
(void)flush_some(1, stdout_buf);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if ((fds[kStdin].revents | fds[kStdout].revents | fds[kSock].revents) &
|
|
||||||
(POLLERR | POLLNVAL))
|
|
||||||
break;
|
|
||||||
|
|
||||||
// Firefox closed the pipe: nothing more will ever arrive on stdin, and once our
|
|
||||||
// own outbound backlog drains there is nothing left to send it either. Stop
|
|
||||||
// rather than idle forever relaying replies nobody reads.
|
|
||||||
if (stdin_eof && socket_out_buf.empty()) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
::close(sock);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
# Integration test only — velox-nmhost has no internal functions worth unit-testing in
|
|
||||||
# isolation (it's ~15 lines of byte-shuffling helpers around one poll() loop); what matters
|
|
||||||
# is the real binary's observable behaviour over real pipes and a real socket.
|
|
||||||
|
|
||||||
add_executable(velox_nmhost_pump_test pump_test.cpp)
|
|
||||||
target_compile_features(velox_nmhost_pump_test PRIVATE cxx_std_23)
|
|
||||||
target_compile_options(velox_nmhost_pump_test PRIVATE -Wall -Wextra -Wpedantic -Werror)
|
|
||||||
target_compile_definitions(velox_nmhost_pump_test PRIVATE
|
|
||||||
VELOX_NMHOST_BIN="$<TARGET_FILE:velox-nmhost>")
|
|
||||||
add_dependencies(velox_nmhost_pump_test velox-nmhost)
|
|
||||||
|
|
||||||
add_test(NAME nmhost.pump COMMAND velox_nmhost_pump_test)
|
|
||||||
set_tests_properties(nmhost.pump PROPERTIES TIMEOUT 30)
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
// Minimal test harness: CHECK accumulates failures, TEST_MAIN reports and sets the exit
|
|
||||||
// code. Copied from daemon/tests/check.hpp rather than shared across a build-dependency —
|
|
||||||
// nmhost is deliberately dependency-free, tests included.
|
|
||||||
|
|
||||||
#include <cstdio>
|
|
||||||
#include <string>
|
|
||||||
#include <vector>
|
|
||||||
|
|
||||||
namespace veloxd_test {
|
|
||||||
|
|
||||||
inline std::vector<std::string>& failures() {
|
|
||||||
static std::vector<std::string> f;
|
|
||||||
return f;
|
|
||||||
}
|
|
||||||
inline int& checks() {
|
|
||||||
static int n = 0;
|
|
||||||
return n;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace veloxd_test
|
|
||||||
|
|
||||||
#define CHECK(cond) \
|
|
||||||
do { \
|
|
||||||
++::veloxd_test::checks(); \
|
|
||||||
if (!(cond)) { \
|
|
||||||
::veloxd_test::failures().push_back(std::string(__FILE__) + ":" + \
|
|
||||||
std::to_string(__LINE__) + ": " + #cond); \
|
|
||||||
} \
|
|
||||||
} while (0)
|
|
||||||
|
|
||||||
#define CHECK_EQ(a, b) \
|
|
||||||
do { \
|
|
||||||
++::veloxd_test::checks(); \
|
|
||||||
auto _va = (a); \
|
|
||||||
auto _vb = (b); \
|
|
||||||
if (!(_va == _vb)) { \
|
|
||||||
::veloxd_test::failures().push_back(std::string(__FILE__) + ":" + \
|
|
||||||
std::to_string(__LINE__) + ": " + #a + \
|
|
||||||
" == " + #b); \
|
|
||||||
} \
|
|
||||||
} while (0)
|
|
||||||
|
|
||||||
#define TEST_MAIN() \
|
|
||||||
int main() { \
|
|
||||||
run(); \
|
|
||||||
for (const auto& f : ::veloxd_test::failures()) std::printf("FAIL %s\n", f.c_str()); \
|
|
||||||
std::printf("%d/%d checks passed\n", \
|
|
||||||
::veloxd_test::checks() - \
|
|
||||||
static_cast<int>(::veloxd_test::failures().size()), \
|
|
||||||
::veloxd_test::checks()); \
|
|
||||||
return ::veloxd_test::failures().empty() ? 0 : 1; \
|
|
||||||
}
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
// Integration test for velox-nmhost: spawns the real binary, feeds it a framed stdin
|
|
||||||
// message, answers over a fake Unix socket standing in for veloxd, and checks what comes
|
|
||||||
// back out on stdout — plus that it exits promptly once stdin closes.
|
|
||||||
|
|
||||||
#include <sys/socket.h>
|
|
||||||
#include <sys/stat.h>
|
|
||||||
#include <sys/un.h>
|
|
||||||
#include <sys/wait.h>
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
#include <cstdint>
|
|
||||||
#include <cstdlib>
|
|
||||||
#include <cstring>
|
|
||||||
#include <ctime>
|
|
||||||
#include <string>
|
|
||||||
|
|
||||||
#include "check.hpp"
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
std::string make_temp_dir() {
|
|
||||||
char tmpl[] = "/tmp/velox-nmhost-test-XXXXXX";
|
|
||||||
const char* dir = ::mkdtemp(tmpl);
|
|
||||||
return dir ? dir : "/tmp";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Firefox's own framing: 4-byte native-byte-order length, then that many bytes.
|
|
||||||
std::string frame(const std::string& payload) {
|
|
||||||
std::uint32_t len = static_cast<std::uint32_t>(payload.size());
|
|
||||||
std::string out(reinterpret_cast<char*>(&len), 4);
|
|
||||||
out += payload;
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reads exactly one framed message off `fd`, blocking. Empty on EOF/short read.
|
|
||||||
std::string read_frame(int fd) {
|
|
||||||
std::uint32_t len = 0;
|
|
||||||
std::size_t got = 0;
|
|
||||||
while (got < 4) {
|
|
||||||
const ssize_t n = ::read(fd, reinterpret_cast<char*>(&len) + got, 4 - got);
|
|
||||||
if (n <= 0) return {};
|
|
||||||
got += static_cast<std::size_t>(n);
|
|
||||||
}
|
|
||||||
std::string payload(len, '\0');
|
|
||||||
got = 0;
|
|
||||||
while (got < len) {
|
|
||||||
const ssize_t n = ::read(fd, payload.data() + got, len - got);
|
|
||||||
if (n <= 0) return {};
|
|
||||||
got += static_cast<std::size_t>(n);
|
|
||||||
}
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool write_all(int fd, const std::string& s) {
|
|
||||||
std::size_t off = 0;
|
|
||||||
while (off < s.size()) {
|
|
||||||
const ssize_t n = ::write(fd, s.data() + off, s.size() - off);
|
|
||||||
if (n <= 0) return false;
|
|
||||||
off += static_cast<std::size_t>(n);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
void run() {
|
|
||||||
const std::string dir = make_temp_dir();
|
|
||||||
const std::string velox_dir = dir + "/velox";
|
|
||||||
CHECK(::mkdir(velox_dir.c_str(), 0700) == 0);
|
|
||||||
const std::string sock_path = velox_dir + "/velox.sock";
|
|
||||||
|
|
||||||
// A bare listening socket standing in for veloxd.
|
|
||||||
const int listen_fd = ::socket(AF_UNIX, SOCK_STREAM, 0);
|
|
||||||
CHECK(listen_fd >= 0);
|
|
||||||
sockaddr_un addr{};
|
|
||||||
addr.sun_family = AF_UNIX;
|
|
||||||
std::memcpy(addr.sun_path, sock_path.c_str(), sock_path.size());
|
|
||||||
CHECK(::bind(listen_fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) == 0);
|
|
||||||
CHECK(::listen(listen_fd, 1) == 0);
|
|
||||||
|
|
||||||
int child_stdin[2]; // [0] read (child), [1] write (parent)
|
|
||||||
int child_stdout[2]; // [0] read (parent), [1] write (child)
|
|
||||||
CHECK(::pipe(child_stdin) == 0);
|
|
||||||
CHECK(::pipe(child_stdout) == 0);
|
|
||||||
|
|
||||||
::setenv("XDG_RUNTIME_DIR", dir.c_str(), 1);
|
|
||||||
|
|
||||||
const pid_t pid = ::fork();
|
|
||||||
CHECK(pid >= 0);
|
|
||||||
if (pid == 0) {
|
|
||||||
::dup2(child_stdin[0], 0);
|
|
||||||
::dup2(child_stdout[1], 1);
|
|
||||||
::close(child_stdin[0]);
|
|
||||||
::close(child_stdin[1]);
|
|
||||||
::close(child_stdout[0]);
|
|
||||||
::close(child_stdout[1]);
|
|
||||||
::close(listen_fd);
|
|
||||||
::execl(VELOX_NMHOST_BIN, "velox-nmhost", nullptr);
|
|
||||||
::_exit(127);
|
|
||||||
}
|
|
||||||
::close(child_stdin[0]);
|
|
||||||
::close(child_stdout[1]);
|
|
||||||
|
|
||||||
// Accept nmhost's connection.
|
|
||||||
const int conn = ::accept(listen_fd, nullptr, nullptr);
|
|
||||||
CHECK(conn >= 0);
|
|
||||||
|
|
||||||
// stdin (framed) -> socket (NDJSON line).
|
|
||||||
CHECK(write_all(child_stdin[1], frame(R"({"hello":1})")));
|
|
||||||
char line[256] = {};
|
|
||||||
ssize_t n = ::read(conn, line, sizeof(line) - 1);
|
|
||||||
CHECK(n > 0);
|
|
||||||
CHECK_EQ(std::string(line, static_cast<std::size_t>(n)), std::string("{\"hello\":1}\n"));
|
|
||||||
|
|
||||||
// socket (NDJSON line) -> stdout (framed).
|
|
||||||
CHECK(write_all(conn, "{\"world\":2}\n"));
|
|
||||||
const std::string got = read_frame(child_stdout[0]);
|
|
||||||
CHECK_EQ(got, std::string(R"({"world":2})"));
|
|
||||||
|
|
||||||
// A second round trip on the same connection, to prove buffering across calls works
|
|
||||||
// (not just "the first message happens to line up with one read()").
|
|
||||||
CHECK(write_all(child_stdin[1], frame(R"({"again":3})")));
|
|
||||||
n = ::read(conn, line, sizeof(line) - 1);
|
|
||||||
CHECK(n > 0);
|
|
||||||
CHECK_EQ(std::string(line, static_cast<std::size_t>(n)), std::string("{\"again\":3}\n"));
|
|
||||||
|
|
||||||
// Firefox closes the pipe: nmhost must exit promptly rather than hang.
|
|
||||||
::close(child_stdin[1]);
|
|
||||||
int status = 0;
|
|
||||||
pid_t waited = -1;
|
|
||||||
for (int i = 0; i < 50 && waited != pid; ++i) {
|
|
||||||
waited = ::waitpid(pid, &status, WNOHANG);
|
|
||||||
if (waited == pid) break;
|
|
||||||
struct timespec ts{0, 20'000'000}; // 20ms
|
|
||||||
::nanosleep(&ts, nullptr);
|
|
||||||
}
|
|
||||||
CHECK_EQ(waited, pid);
|
|
||||||
if (waited == pid) CHECK(WIFEXITED(status));
|
|
||||||
|
|
||||||
::close(conn);
|
|
||||||
::close(child_stdout[0]);
|
|
||||||
::close(listen_fd);
|
|
||||||
::unlink(sock_path.c_str());
|
|
||||||
::rmdir(velox_dir.c_str());
|
|
||||||
::rmdir(dir.c_str());
|
|
||||||
}
|
|
||||||
|
|
||||||
TEST_MAIN()
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
# packaging/nativehost — the Firefox native-messaging manifest
|
|
||||||
|
|
||||||
Owner: lane DAEMON (`packaging/nativehost/**` is explicitly DAEMON's per `CLAUDE.md`'s
|
|
||||||
lane table, unlike the rest of `packaging/`, which is PKG/QA's). This directory holds the
|
|
||||||
manifest `velox-nmhost` (built from `nmhost/`) is registered under, and this file is the
|
|
||||||
one place that says exactly which on-disk locations need it and why — the
|
|
||||||
"install manifests to all four locations" line in `docs/agents/AGENT-DAEMON.md`'s build
|
|
||||||
order predates `docs/adr/0003-native-messaging-under-snap.md`, which cut that down to
|
|
||||||
three *real* ones. Read the ADR before touching this list; it is the empirical spike, not
|
|
||||||
a guess.
|
|
||||||
|
|
||||||
## The manifest
|
|
||||||
|
|
||||||
`com.velox.host.json` in this directory is the template:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "com.velox.host",
|
|
||||||
"description": "Velox download manager — native messaging bridge to veloxd",
|
|
||||||
"path": "/usr/libexec/velox/velox-nmhost",
|
|
||||||
"type": "stdio",
|
|
||||||
"allowed_extensions": ["velox@velox.download"]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `name` is what the extension calls `browser.runtime.connectNative("com.velox.host")`
|
|
||||||
with (`extension/src/background/transport/native.ts`'s `HOST_NAME`) — do not rename one
|
|
||||||
without the other.
|
|
||||||
- `allowed_extensions` must match `extension/manifest.json`'s
|
|
||||||
`browser_specific_settings.gecko.id` exactly (`[email protected]`). A mismatch here is
|
|
||||||
a silent failure: Firefox reports "no such native application" as if the manifest didn't
|
|
||||||
exist at all, which reads exactly like a missing-file bug and is easy to chase in the
|
|
||||||
wrong place.
|
|
||||||
- `path` is `/usr/libexec/velox/velox-nmhost` — the `.deb`'s install layout
|
|
||||||
(`docs/07-packaging.md`). **Every other packaging format needs a different `path`** —
|
|
||||||
the binary doesn't live at that absolute path inside a Flatpak sandbox or an AppImage
|
|
||||||
mount, so whatever installs the manifest for those formats must rewrite this field to
|
|
||||||
wherever it actually put the binary, not ship this file verbatim. That substitution is
|
|
||||||
the packaging step's job, not this template's.
|
|
||||||
|
|
||||||
## Where it has to be written, and why
|
|
||||||
|
|
||||||
Per ADR 0003 (spike S1, run against the real snap Firefox on the target machine — not a
|
|
||||||
guess about how confinement "should" work):
|
|
||||||
|
|
||||||
| Location | Covers | Why |
|
|
||||||
|---|---|---|
|
|
||||||
| `~/.mozilla/native-messaging-hosts/com.velox.host.json` | **deb/tarball Firefox, and snap Firefox** | The one location snap Firefox 154 actually reads. Firefox's snap launches native-messaging hosts *outside* the sandbox with the user's real `$HOME` — so this is not "the deb location that happens to also work"; it is *the* snap location, full stop. `~/snap/firefox/common/.mozilla/native-messaging-hosts/` (the intuitive "inside the snap" path) is **not** read by Firefox 154 snap — confirmed empirically, not inferred. |
|
|
||||||
| `/usr/lib/mozilla/native-messaging-hosts/com.velox.host.json` | **deb/tarball Firefox only** | System-wide, so it covers every user on the box for a real (non-snap) Firefox install — but confirmed **not** read by snap Firefox. Do not treat this path as "covers snap too"; that was the wrong assumption `docs/05` §4 corrected in the same commit as the ADR. |
|
|
||||||
| `~/.var/app/org.mozilla.firefox/.mozilla/native-messaging-hosts/com.velox.host.json` | **Flatpak Firefox** | Flatpak's own sandboxed home. Untested on this machine (Firefox here is the snap, not flatpak) — carried over from `docs/05` §4's original four-location list, which this table otherwise supersedes. Verify before relying on it in a release checklist. |
|
|
||||||
|
|
||||||
That is three real locations, not four — the fourth
|
|
||||||
(`~/snap/firefox/common/.mozilla/native-messaging-hosts/`) was the pre-ADR guess the spike
|
|
||||||
disproved. If `docs/agents/AGENT-DAEMON.md`'s build order still says "four locations" when
|
|
||||||
you read this, it is stale; this table is the current source of truth alongside the ADR
|
|
||||||
itself.
|
|
||||||
|
|
||||||
## What this means for `postinst` (PKG/QA's file, not this one)
|
|
||||||
|
|
||||||
This directory ships the manifest template and documents the target paths; it does not
|
|
||||||
install anything itself (`packaging/**` outside this one directory is PKG/QA's — see
|
|
||||||
`CLAUDE.md`'s lane table — and `postinst` specifically is a `.deb`-packaging concern this
|
|
||||||
lane doesn't own the file for). For whoever writes it:
|
|
||||||
|
|
||||||
- **The two `~/`-relative locations are per-user.** `postinst` runs as root, once, at
|
|
||||||
install time — it does not run once per user session. It needs either a real-user
|
|
||||||
enumeration at install time (every UID with a home directory and no login shell of
|
|
||||||
`/usr/sbin/nologin`-style exclusions, roughly what `deluser --remove-home` scripts already
|
|
||||||
have to reason about) or a first-run hook that runs as the logged-in user (a systemd user
|
|
||||||
unit's `ExecStartPre`, or the GUI's own first-run wizard) and writes its own manifest the
|
|
||||||
first time it starts. `docs/adr/0003`'s own follow-up section flagged this as open; it
|
|
||||||
still is.
|
|
||||||
- **`/usr/lib/mozilla/native-messaging-hosts/` is the only one of the three that's a plain
|
|
||||||
root-owned, install-time write** — no per-user enumeration needed for that one.
|
|
||||||
- **Detect snap Firefox and say so.** `docs/07-packaging.md` already commits to this
|
|
||||||
("detects whether Firefox is a snap... prints... a one-line note that the extension will
|
|
||||||
pair over loopback") — the detection matters here specifically because if Firefox turns
|
|
||||||
out to be neither deb/tarball nor a snap this spike covers (a genuinely unknown or future
|
|
||||||
packaging of Firefox), silently trusting `NativeTransport` to work is exactly the failure
|
|
||||||
mode ADR 0003 exists to prevent. `WebSocketTransport` is the guaranteed fallback either
|
|
||||||
way (ADR 0003's own decision) — nothing breaks if the manifest doesn't land correctly, it
|
|
||||||
just means the extension pairs over loopback instead of the (opportunistic, not
|
|
||||||
required) native path.
|
|
||||||
- **`docs/07-packaging.md`'s own install layout line currently lists only the
|
|
||||||
system-wide `/usr/lib/mozilla/...` path.** That line is correct as far as it goes (it *is*
|
|
||||||
one of the three locations, and the only pure root-owned one) but reads as if it were the
|
|
||||||
whole story for native messaging; it predates this file and the ADR. Worth a line
|
|
||||||
pointing here so the two documents don't quietly disagree — PKG/QA's call, not edited
|
|
||||||
here since `docs/07` is PKG/QA's own file.
|
|
||||||
|
|
||||||
## The binary
|
|
||||||
|
|
||||||
`nmhost/` builds `velox-nmhost` — see that directory's own `src/main.cpp` for what it does
|
|
||||||
(a byte-level pump, no protocol logic) and `docs/05-extension-spec.md` §4 for the two
|
|
||||||
transports it sits behind. It is deliberately dependency-free (not linked against any
|
|
||||||
`veloxd_*` library) so wherever a packaging format's sandbox puts it, it has nothing else
|
|
||||||
to go looking for at runtime.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "com.velox.host",
|
|
||||||
"description": "Velox download manager — native messaging bridge to veloxd",
|
|
||||||
"path": "/usr/libexec/velox/velox-nmhost",
|
|
||||||
"type": "stdio",
|
|
||||||
"allowed_extensions": ["[email protected]"]
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
# packaging/systemd — the user unit pair for veloxd
|
|
||||||
|
|
||||||
Provided by lane DAEMON (`daemon/docs/AGENT-DAEMON.md` build step 7: "systemd user units
|
|
||||||
— `velox.service` + `velox.socket` for socket activation"), for PKG/QA to install per
|
|
||||||
`docs/07-packaging.md`'s layout:
|
|
||||||
|
|
||||||
```
|
|
||||||
/usr/lib/systemd/user/velox.service
|
|
||||||
/usr/lib/systemd/user/velox.socket
|
|
||||||
```
|
|
||||||
|
|
||||||
`packaging/` outside `nativehost/` is PKG/QA's per `CLAUDE.md`'s lane table; these two
|
|
||||||
files are here because they are inputs to that packaging step, not a claim on the rest of
|
|
||||||
the directory — same relationship `packaging/nativehost/` already has.
|
|
||||||
|
|
||||||
## Why both files, and what `RuntimeDirectory=` is doing
|
|
||||||
|
|
||||||
`velox.socket` binds `$XDG_RUNTIME_DIR/velox/velox.sock` **before `veloxd` ever runs** and
|
|
||||||
hands the daemon the already-listening fd at startup (`daemon/src/rpc/systemd_activation.cpp`
|
|
||||||
implements the receiving half — `LISTEN_PID`/`LISTEN_FDS`, fd 3 — without a `libsystemd`
|
|
||||||
link). Two things this buys over the daemon binding its own socket on every start:
|
|
||||||
|
|
||||||
- **No window where a client gets `ECONNREFUSED`.** The socket exists and queues
|
|
||||||
connections from the moment `velox.socket` is active, not from whenever `veloxd`
|
|
||||||
finishes starting up — this is the actual point of socket activation, not just "start
|
|
||||||
on demand."
|
|
||||||
- **Cold-boot ordering is free.** Nothing has to wait for `veloxd` to be ready before the
|
|
||||||
GUI, the CLI, or a native-messaging host can attempt a connection; the kernel queues it.
|
|
||||||
|
|
||||||
`RuntimeDirectory=velox` on the socket unit creates `%t/velox` (mode 0700) before the
|
|
||||||
`ListenStream=` bind — without it, binding fails outright the first time (nothing has
|
|
||||||
created the parent directory yet). `veloxd` itself creates that same directory
|
|
||||||
(`ensure_private_dir` in `runtime_dir.cpp`) for the case where it's started directly,
|
|
||||||
outside systemd (`./veloxd` in a terminal, still supported and how most of this daemon's
|
|
||||||
own testing runs) — the two paths converge on the same directory with the same mode
|
|
||||||
either way.
|
|
||||||
|
|
||||||
`UdsServer::start()` (`daemon/src/rpc/uds_server.cpp`) checks for the activated fd first
|
|
||||||
and, if present, skips create/bind/chmod/listen entirely — the socket file's lifecycle then
|
|
||||||
belongs to the unit (including `RemoveOnStop=yes` on stop), not to the daemon. Falls back
|
|
||||||
to binding its own socket exactly as before when not socket-activated (a manual run, or a
|
|
||||||
distro that ships the daemon without the unit files).
|
|
||||||
|
|
||||||
## What was deliberately left out
|
|
||||||
|
|
||||||
`velox.service` does **not** set `ProtectSystem=`, `ProtectHome=`, or `ReadWritePaths=`.
|
|
||||||
`saveTo.allowedRoots` is user-configurable to anywhere on the filesystem — an external
|
|
||||||
drive, a second mount, anywhere `fs/safepath.hpp`'s own canonicalize-and-check accepts —
|
|
||||||
not a fixed set of directories a unit file could enumerate ahead of time. A filesystem-level
|
|
||||||
sandbox here would turn a legitimately-configured save location into an opaque
|
|
||||||
`EROFS`/`EACCES` the daemon can't explain, in place of its own clear `-32011` — worse than
|
|
||||||
no sandbox, specifically for a download manager. `NoNewPrivileges=yes` is kept: it has no
|
|
||||||
such trade-off.
|
|
||||||
|
|
||||||
## Verifying socket activation without a real install
|
|
||||||
|
|
||||||
`systemd-analyze verify --user velox.service velox.socket` checks unit-file syntax (it
|
|
||||||
will complain that `/usr/bin/veloxd` and the `velox(1)` man page don't exist on a dev
|
|
||||||
box that hasn't installed the package — expected, not a unit bug). To exercise the actual
|
|
||||||
activation handshake without installing anything: bind a Unix socket, `dup2` it onto fd 3,
|
|
||||||
fork, set `LISTEN_PID=<child pid>` and `LISTEN_FDS=1` in the child's environment, clear
|
|
||||||
`FD_CLOEXEC` on fd 3, and `execve` `veloxd` — a real `session.hello` round-trips over that
|
|
||||||
fd with no `bind()`/`listen()` call ever happening inside the daemon for that run. This is
|
|
||||||
exactly what `velox.socket`'s `Requires=`/`ExecStart` sequence does in production; systemd
|
|
||||||
supplies the fd, `veloxd` doesn't know the difference.
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Velox download manager daemon
|
|
||||||
Documentation=man:velox(1)
|
|
||||||
# Socket activation (velox.socket) means this unit does not need to be enabled or started
|
|
||||||
# directly for the RPC transport to come up on demand — the first connection attempt after
|
|
||||||
# boot starts veloxd with the listening socket already bound (see velox.socket's own
|
|
||||||
# comment). Requires=/After= still matter for a manual `systemctl --user start velox`.
|
|
||||||
Requires=velox.socket
|
|
||||||
After=velox.socket
|
|
||||||
|
|
||||||
# Never more than one real instance for this user regardless of how it was started — the
|
|
||||||
# abstract-socket single-instance lock (main.cpp, keyed off the resolved runtime dir) is
|
|
||||||
# the actual enforcement; this just keeps systemd itself from racing two starts.
|
|
||||||
StartLimitIntervalSec=60
|
|
||||||
StartLimitBurst=5
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Type=simple
|
|
||||||
ExecStart=/usr/bin/veloxd
|
|
||||||
# main.cpp's SIGTERM handler stops the event loop and falls through to a clean shutdown
|
|
||||||
# (flushes buffers, closes the store, releases the single-instance lock) — the default
|
|
||||||
# KillSignal=SIGTERM and TimeoutStopSec are already the right shape for that; no
|
|
||||||
# ExecStop/KillMode override needed.
|
|
||||||
Restart=on-failure
|
|
||||||
RestartSec=2
|
|
||||||
|
|
||||||
# Hardening deliberately stops here, not at ProtectSystem=/ProtectHome=/ReadWritePaths=:
|
|
||||||
# saveTo.allowedRoots is user-configurable to anywhere (an external drive, a second
|
|
||||||
# mount — fs/safepath.hpp is the daemon's own validation boundary, not a fixed set of
|
|
||||||
# directories a unit file could enumerate up front). A filesystem-level sandbox here would
|
|
||||||
# silently turn a legitimately-configured save location into an opaque EROFS/EACCES the
|
|
||||||
# daemon can't explain, instead of its own clear -32011 — worse than no sandbox, for a
|
|
||||||
# download manager specifically. NoNewPrivileges is free of that trade-off.
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
Also=velox.socket
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
[Unit]
|
|
||||||
Description=Velox download manager — RPC socket
|
|
||||||
|
|
||||||
[Socket]
|
|
||||||
# %t is $XDG_RUNTIME_DIR for a user unit — the exact path veloxd itself resolves
|
|
||||||
# (daemon/src/rpc/runtime_dir.cpp's resolve_runtime_dir), and the exact path velox(1)
|
|
||||||
# resolves too (default_socket_path() in cli/src/client.cpp). All three must agree; this
|
|
||||||
# is the one line that has to.
|
|
||||||
ListenStream=%t/velox/velox.sock
|
|
||||||
|
|
||||||
# RuntimeDirectory creates %t/velox (mode 0700, this user's own) before binding, so the
|
|
||||||
# ListenStream= path above always has somewhere to land — veloxd itself does the same
|
|
||||||
# thing (ensure_private_dir) when it creates the directory unassisted (the non-activated
|
|
||||||
# path, e.g. a manual `veloxd` run outside systemd).
|
|
||||||
RuntimeDirectory=velox
|
|
||||||
RuntimeDirectoryMode=0700
|
|
||||||
|
|
||||||
# Same-UID-only, matching the socket's own authorization once a connection is accepted
|
|
||||||
# (SO_PEERCRED, checked again in uds_server.cpp regardless of this mode bit — belt and
|
|
||||||
# braces, not a substitute for it).
|
|
||||||
SocketMode=0600
|
|
||||||
|
|
||||||
# The socket file belongs to systemd's socket-activation state, not to whatever's on disk
|
|
||||||
# from a previous boot; remove it on stop so a stale entry never shadows the next start.
|
|
||||||
RemoveOnStop=yes
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=sockets.target
|
|
||||||
@@ -40,16 +40,26 @@ while [ $# -gt 0 ]; do
|
|||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
# Kill the server and anything it spawned. `kill $!` alone would only reap the subshell
|
# Kill the server and anything it spawned. `pkill -P "$pid"` only reaps direct children —
|
||||||
# wrapper and leave the node process holding the port, which then breaks the next run.
|
# 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() {
|
stop() {
|
||||||
local pid="$1"
|
local pid="$1"
|
||||||
[ -n "$pid" ] || return 0
|
[ -n "$pid" ] || return 0
|
||||||
pkill -P "$pid" 2>/dev/null || true
|
kill -TERM -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
|
||||||
kill "$pid" 2>/dev/null || true
|
|
||||||
wait "$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() {
|
cleanup() {
|
||||||
stop "$MOCKD_PID"
|
stop "$MOCKD_PID"
|
||||||
stop "$SLOW_PID"
|
stop "$SLOW_PID"
|
||||||
@@ -84,8 +94,8 @@ step "generated TypeScript against a live server"
|
|||||||
if [ -z "$EXTERNAL_UDS" ] && [ -z "$EXTERNAL_WS" ]; then
|
if [ -z "$EXTERNAL_UDS" ] && [ -z "$EXTERNAL_WS" ]; then
|
||||||
( cd "$REPO/tools/mockd" && npm install --silent --no-audit --no-fund )
|
( cd "$REPO/tools/mockd" && npm install --silent --no-audit --no-fund )
|
||||||
UDS="$WORK/velox.sock"
|
UDS="$WORK/velox.sock"
|
||||||
WS_PORT=52080
|
WS_PORT="$(free_port)"
|
||||||
( 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 "$UDS" --ws-port "$WS_PORT" --allowed-root "$WORK" ) >"$WORK/mockd.log" 2>&1 &
|
--uds "$UDS" --ws-port "$WS_PORT" --allowed-root "$WORK" ) >"$WORK/mockd.log" 2>&1 &
|
||||||
MOCKD_PID=$!
|
MOCKD_PID=$!
|
||||||
# Wait for the socket rather than sleeping a guessed amount.
|
# 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"
|
VXDG="$WORK/veloxd-xdg"
|
||||||
mkdir -p "$VXDG/runtime" "$VXDG/data" "$VXDG/config" "$VXDG/downloads"
|
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" \
|
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=$!
|
VELOXD_PID=$!
|
||||||
VUDS="$VXDG/runtime/velox/velox.sock"
|
VUDS="$VXDG/runtime/velox/velox.sock"
|
||||||
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
|
||||||
@@ -183,7 +197,7 @@ fi
|
|||||||
step "capture.offer fails open when the daemon is too slow"
|
step "capture.offer fails open when the daemon is too slow"
|
||||||
if [ -z "$EXTERNAL_UDS" ]; then
|
if [ -z "$EXTERNAL_UDS" ]; then
|
||||||
SLOW_UDS="$WORK/slow.sock"
|
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 &
|
--uds "$SLOW_UDS" --no-ws --slow 2000 ) >"$WORK/slow.log" 2>&1 &
|
||||||
SLOW_PID=$!
|
SLOW_PID=$!
|
||||||
for _ in $(seq 1 50); do [ -S "$SLOW_UDS" ] && break; sleep 0.2; done
|
for _ in $(seq 1 50); do [ -S "$SLOW_UDS" ] && break; sleep 0.2; done
|
||||||
|
|||||||
@@ -76,6 +76,12 @@ interface Fixture {
|
|||||||
/** A condition the server cannot produce from the request alone. Skipped unless the
|
/** A condition the server cannot produce from the request alone. Skipped unless the
|
||||||
* harness has arranged it — see tests/integration. */
|
* harness has arranged it — see tests/integration. */
|
||||||
requires?: string;
|
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;
|
transport?: TransportName;
|
||||||
deadlineMs?: number;
|
deadlineMs?: number;
|
||||||
request?: { jsonrpc: '2.0'; id: number | string; method: string; params?: unknown };
|
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
|
* Methods that destroy state, or consume state another fixture creates. Replayed last so
|
||||||
* not depend on file order, which is the sort of thing that goes green locally and red in
|
* the suite does not depend on file order, which is the sort of thing that goes green
|
||||||
* CI on a different filesystem.
|
* 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 {
|
function replayOrder(a: Fixture, b: Fixture): number {
|
||||||
const rank = (f: Fixture): number => (DESTRUCTIVE.has(f.request?.method ?? '') ? 1 : 0);
|
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 };
|
return { bindings, setup };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function replay(conn: Conn, fixtures: readonly Fixture[],
|
async function replay(initialConn: Conn, fixtures: readonly Fixture[],
|
||||||
bindings: Record<string, string>,
|
bindings: Record<string, string>,
|
||||||
includeRequires = false): Promise<Outcome[]> {
|
includeRequires = false,
|
||||||
|
reconnect?: () => Promise<Conn>):
|
||||||
|
Promise<{ outcomes: Outcome[]; conn: Conn }> {
|
||||||
const out: Outcome[] = [];
|
const out: Outcome[] = [];
|
||||||
|
let conn = initialConn;
|
||||||
const t = conn.transport;
|
const t = conn.transport;
|
||||||
|
|
||||||
for (const f of [...fixtures].sort(replayOrder)) {
|
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);
|
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);
|
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') {
|
if (f.kind === 'timeout') {
|
||||||
out.push({
|
out.push({
|
||||||
@@ -313,7 +331,7 @@ async function replay(conn: Conn, fixtures: readonly Fixture[],
|
|||||||
out.push({ fixture: f.file, transport: t, ok: mismatch === null,
|
out.push({ fixture: f.file, transport: t, ok: mismatch === null,
|
||||||
detail: mismatch ?? 'result validates and matches the golden shape' });
|
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. */
|
/** 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
|
* 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
|
* 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.
|
* 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[] {
|
function applyXfail(results: readonly Outcome[], xfail: readonly XfailEntry[]): Outcome[] {
|
||||||
const matches = (e: XfailEntry, r: Outcome): boolean =>
|
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) => {
|
return results.map((r) => {
|
||||||
const entry = xfail.find((e) => matches(e, r));
|
const entry = xfail.find((e) => matches(e, r));
|
||||||
@@ -409,14 +435,18 @@ async function main(): Promise<void> {
|
|||||||
const udsPath = arg('--uds');
|
const udsPath = arg('--uds');
|
||||||
const wsPort = arg('--ws-port');
|
const wsPort = arg('--ws-port');
|
||||||
|
|
||||||
if (udsPath) {
|
// Each opens (and re-opens, via `reconnect`) with the same handshake: session.hello on
|
||||||
const conn = await connectUds(udsPath);
|
// the Unix socket, session.pair + session.hello on the WebSocket. Needed because at
|
||||||
await conn.call('session.hello', { clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0' });
|
// least one fixture (session.hello.version-mismatch) documents that the *server* closes
|
||||||
const { bindings, setup } = await setupBindings(conn);
|
// the connection after replying — replay() calls this to get a working connection back
|
||||||
results.push(...setup, ...(await replay(conn, fixtures, bindings, includeRequires)));
|
// rather than leaving every later fixture on the shared connection to time out.
|
||||||
conn.close();
|
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 conn = await connectWs(Number(wsPort));
|
||||||
const paired = await conn.request(
|
const paired = await conn.request(
|
||||||
'session.pair',
|
'session.pair',
|
||||||
@@ -427,10 +457,23 @@ async function main(): Promise<void> {
|
|||||||
if (token === undefined) throw new Error('pairing failed: no token issued');
|
if (token === undefined) throw new Error('pairing failed: no token issued');
|
||||||
await conn.request('session.hello',
|
await conn.request('session.hello',
|
||||||
{ clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0', token }, 5000);
|
{ clientType: 'test', clientName: 'conformance', protocolVersion: '1.0.0', token }, 5000);
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (udsPath) {
|
||||||
|
const conn = await freshUds();
|
||||||
const { bindings, setup } = await setupBindings(conn);
|
const { bindings, setup } = await setupBindings(conn);
|
||||||
results.push(...setup, ...(await replay(conn, fixtures, bindings, includeRequires)));
|
const { outcomes, conn: last } = await replay(conn, fixtures, bindings, includeRequires, freshUds);
|
||||||
results.push(...(await privilegeChecks(conn)));
|
results.push(...setup, ...outcomes);
|
||||||
conn.close();
|
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) {
|
if (!udsPath && !wsPort) {
|
||||||
process.stdout.write('no --uds or --ws-port given: ran static checks only\n');
|
process.stdout.write('no --uds or --ws-port given: ran static checks only\n');
|
||||||
|
|||||||
@@ -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.refreshUrl.json", "reason": "D3: stub handler, -32603" },
|
||||||
{ "fixture": "contracts/fixtures/download.update.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.list.json", "reason": "D3: stub handler, -32603" },
|
||||||
{ "fixture": "contracts/fixtures/rules.upsert.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.get.json", "reason": "D3: stub handler, -32603" },
|
||||||
{ "fixture": "contracts/fixtures/schedule.set.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.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.harvest.json", "reason": "D3: stub handler, -32603" },
|
||||||
{ "fixture": "contracts/fixtures/grabber.start.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.addVariant.json", "reason": "D3: stub handler, -32603" },
|
||||||
{ "fixture": "contracts/fixtures/media.listVariants.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.getRules.json", "reason": "D3: stub handler, -32603 -- DAEMON is filing capture.offer next" },
|
||||||
{ "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/capture.offer.take.json", "reason": "D3: stub handler, -32603 -- DAEMON is filing capture.offer next" },
|
||||||
{ "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/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" }
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user