daemon: build velox-nmhost, systemd socket activation + units, velox(1) man page
Closes build order items 7 (the systemd half) and 9 (D11 in deferrals.md). velox-nmhost (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 (the extension relaunches a fresh host on its own reconnect), exits the moment either side closes. Deliberately dependency-free — no veloxd_* library, no nlohmann_json — since it runs unconfined outside Firefox's sandbox regardless of packaging format. Two real bugs found and fixed while getting the integration test to actually pass rather than hang, both exactly the class of bug a "trivial pump" invites: 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 and writability were never both observable through the same pollfd entry. packaging/nativehost/com.velox.host.json + its own README.md supersede AGENT-DAEMON.md's stale "four locations" line: spike S1 / ADR 0003 found only three manifest locations are real (~/.mozilla/native-messaging-hosts/ for BOTH deb/tarball and snap Firefox, /usr/lib/mozilla/... for deb/tarball only, the flatpak sandbox path) — the fourth, ~/snap/firefox/common/.mozilla/..., is not read by snap Firefox at all. The README spells out the per-user-manifest / postinst enumeration implication for PKG/QA (postinst runs once as root; the two ~/-relative locations are per-user) and flags that docs/07-packaging.md's own install-layout line only shows the one root-owned path. Socket activation: rpc/systemd_activation.cpp is a from-scratch sd_listen_fds() (env vars only — LISTEN_PID/LISTEN_FDS, fd 3 — no libsystemd link) 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 queue/settings subcommands AGENT-DAEMON.md's build order originally sketched aren't implemented in cli/src/main.cpp yet, so the page doesn't claim they are. Checked warning-free with groff -mandoc -ww -z. Full ctest: 57/57 (excluding the pre-existing, unrelated conformance failure noted in earlier commits). Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01GRDjHGgpYmMoPE2UFbe7pP
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,54 @@
|
||||
#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; \
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// 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()
|
||||
Reference in New Issue
Block a user