Files
logos-logoscore-cli/tests/test_integration_logoscore.cpp
Dario LipicarandClaude Opus 5 0b2afed18a chore(deps): track master for protocol, cpp-sdk and plugin-qt (#92)
* feat(access-policy): --access-policy enforce, and prove it on a real daemon

`--access-policy` already reached the runtime; what was missing was a way to
ask for deny-by-default without hand-writing JSON, and any evidence that it
works. The README actively said the opposite ("enforcement is not yet
implemented ... a no-op for now") — it has been enforced for a while.

resolveAccessPolicyArg moves out of main.cpp into daemon/access_policy_arg.
so it can be unit-tested, and gains one spelling: the literal `enforce`
expands to {"version":1,"mode":"enforce","restrictions":{}}. That is not a
second switch — `mode` is still the runtime's only switch — it is the bare
document that arms it. Checked before the file branch, so arming enforcement
can't depend on the daemon's working directory.

The integration tests are the point: same binaries, same modules, same call,
policy the only variable. test_ipc_module declares test_basic_module and
test_extlib_module; test_basic_module declares nothing.
  no flag  -> requestModule(test_basic_module, test_extlib_module) mints
  enforce  -> the same call is refused, and both names appear in the log
  enforce  -> requestModule(test_ipc_module, test_basic_module) still mints
The third is the one that matters; a change that refused everything would
pass the second on its own. The refusal is matched structurally rather than
by exact text because the two capability_module implementations in this tree
quote the names differently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(qt-host): link the Qt host runtime from logos-plugin-qt, not logos-qt-sdk

logoscore's daemon and its in-process core service are built on LogosAPI,
LogosAPIProvider and LogosProviderObject. Those moved out of logos-qt-sdk
into logos-plugin-qt, which publishes them as the `logos-qt-host` package
with the CMake target logos-qt-host::logos_qt_host. Point at that target.

Those three headers were the ONLY thing this repo took from logos-qt-sdk —
it emits no Qt consumer wrappers, ships no UI plugin, and never touches
logos_qt_lp_bridge.h or logos_ui_plugin_context.h — so the logos-qt-sdk
input is dropped outright rather than kept alongside. LOGOS_QT_SDK_ROOT
becomes LOGOS_QT_HOST_ROOT in all three derivations (build, tests,
buildPortable), and `--version` now reports the logos-plugin-qt commit.

Both new failure modes are hard errors, never silent skips: an unset
LOGOS_QT_HOST_ROOT is a FATAL_ERROR before find_package runs, and a
find_package that somehow does not define the imported target is a
FATAL_ERROR too.

logos-qt-host needs TokenManager::forIdentity/isolateIdentity, which
logos-protocol only grew on its per-client-token-store commit, so the
lock moves there. logos-plugin-qt is rev-pinned for now because
nix/qt-host.nix does not exist on its default branch yet.

Verified on aarch64-darwin: `nix build .#checks.aarch64-darwin.tests`
passes 21/21 with the committed lock and no overrides (same 21 as the
pre-change baseline), .#cli and .#cli-bundle-dir build, and the set of
LogosAPI/LogosAPIProvider/LogosProviderObject/qtArgDecode symbols in the
logoscore binary is identical to the pre-change build.

* chore(deps): re-pin the SDK stack onto the pushed b4 revs

Rebased onto master, so the inputs have to name the revs the rest of the b4
stack was actually pushed at rather than each input's default branch:

  logos-cpp-sdk           a04b2788  b3 codegen tip; a strict descendant of
                                    cpp-sdk master, so forward-only
  logos-protocol          c8bab12   per-client token store — logos-qt-host
                                    calls TokenManager::forIdentity, which
                                    exists nowhere else
  logos-plugin-qt         cc24fa1   was 8ccb1fc. The superset branch that
                                    logos-liblogos and logos-module-builder
                                    also pin, so exactly ONE logos-qt-host
                                    is in the closure — this CLI links it
                                    directly AND through liblogos_core
  logos-liblogos          f2a15ef   the liblogos built on that same qt-host
  logos-capability-module 0cb33fb   master, pinned explicitly — see below

All five are rev-pinned in the URL rather than left to the lock: every one is
a branch commit, so an unpinned url lets `nix flake update` silently relock
onto a default branch that does not build here.

capability_module deliberately does NOT move to the universal port (07dba1f).
That port declares metadata.json#host_services and fails closed until a host
calls logos_module_grant_host_services — and nothing in this stack calls it
yet (neither logos-liblogos nor logos-plugin-qt contains a single call site).
Built against it, the daemon's capability gate refuses EVERY requestModule
with "not granted the token_registry host service", so no module can call
another; the new access-policy integration test caught exactly that. 0cb33fb
is what logos-liblogos and logos-standalone-app lock too.

Verified on aarch64-darwin with the committed lock and no overrides:
  .#checks.aarch64-darwin.tests-logosctl   191 + 25 + 21 tests, all PASSED
  .#checks.aarch64-darwin.tests-logoscore   20 + 24 tests, all PASSED
  .#checks.aarch64-darwin.tests             built (exit 0)
  .#packages.aarch64-darwin.{cli,ctl}       built (exit 0)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps): rev-pin logos-test-modules at the b4 qt-host tip

The daemon-backed integration checks load these plugins into the daemon
this repo builds, so the two share one host runtime in one process image
-- the same constraint that already rev-pins logos-liblogos. a639b934
links the test modules against logos-qt-host rather than logos-qt-sdk and
carries the matching B4 stack pins; the previous lock sat on master
(f8077fab), which predates that repoint.

The URL had to change, not just the lock. The input was an UNPINNED url,
so it resolved to the default branch -- and f8077fab IS master's tip.
`nix flake update logos-test-modules` was therefore a silent no-op that
would leave the ten b4 commits behind while reporting success.

f8077fab is a strict ancestor of a639b934 (verified on a non-shallow
clone), so this is forward-only, not a lineage switch.

Two behaviour changes ride along and were checked against this repo's
assertions rather than assumed safe:
  * test_basic_module and test_extlib_module migrate to
    interface "universal". Neither declares metadata.json#host_services,
    so the fail-closed gate that keeps logos-capability-module pinned off
    its universal port does not apply here.
  * stringLength now answers in CHARACTERS, not bytes. Every assertion
    here is ASCII ("abcdef" -> 6), so the two agree.

The access-policy fixture still has its pair: test_ipc_module declares
[test_basic_module, test_extlib_module] and test_basic_module declares
none, so basic -> extlib stays undeclared.

Checks built by name, all exit 0: tests-logosctl, tests-logoscore,
tests. 281 tests, 0 failures, 0 skips.

* test: use test_ipc_new_api_module as the transitive-dependency fixture

These integration tests pick a module that DECLARES the other two, so one
load-module has to pull all three, and then request a token across that edge.
test_ipc_module was that fixture; it is being retired as a duplicate. Its
successor declares exactly the same dependency pair, so the fixture role
transfers unchanged.

Worth doing in the same breath as the retirement rather than after: these call
GTEST_SKIP() when the module is missing, so deleting the module out from under
them would not have turned anything red — the dependency-resolution and
token-request coverage would simply have stopped running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(windows): refuse an unknown target instead of silently skipping it

`logos_use_shared_runtime_from_dll` empties the static archive of each named
IMPORTED target so the symbol resolves to liblogos_core.dll's exported copy
instead. It skipped any name that was not a target, which makes a typo or a
moved target silent — and the failure it hides is the duplicate-statics class:
the image keeps its own static copy of the shared runtime alongside the DLL's,
and PE has no interposition to collapse the two.

That hazard was already WRITTEN DOWN at basecamp's call site ("naming the old
target here would be a silent no-op … Windows would regress to the 29
'rejecting unauthorized call' lines this shim exists to prevent") — documented,
but not enforced. This enforces it.

Taken from feat/sdk-codegen-phase-a, which hardened its logoscore-cli copy and
never fixed basecamp's; feat/sdk-codegen-b3 has neither. It is the one place
where reconciling onto b3 would otherwise lose work, so both copies get it.

Behaviour is unchanged for every current caller: the function early-returns off
Windows, and both call sites pass the same two targets
(logos-qt-host::logos_qt_host, logos-protocol::logos_protocol) that phase-a's
hardened copy already accepts. x86_64-windows still evaluates (386 packages).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* ci: use logos-co/setup-nix-cache-action for Nix setup and caching

Replaces the per-repo installer + cachix pair with the shared action, which
installs Nix with the Logos Attic cache (cache.nix.logos.co) preconfigured and
publishes what the job builds — master to the public cache, every other ref to
ci.

Each converted job also gains

    environment: ${{ github.ref == 'refs/heads/master' && 'public-cache' || '' }}

because ATTIC_TOKEN_PUBLIC only exists inside that environment. Without it the
secret resolves empty on master and publishing is silently skipped — the job
still passes, so the omission would not show up as a failure.

The action installs Nix itself on every runner, macOS included. That is a
deliberate reversal of the workaround these files carried: the comments here
said cachix/install-nix-action collides with the runner's pre-existing _nixbld
users (eDSRecordAlreadyExists), so DeterminateSystems' installer was used
instead. It no longer reproduces — logos-delivery-module has already been
converted the plain way and its `build-and-test (macos-latest)` leg passes.
Keeping the workaround would have meant a second installer plus a duplicated
substituter/key block in ten files, guarding against something two green runs
say does not happen. If it ever recurs it fails loudly at install, which is
recoverable; the silent-skip above is the failure mode worth engineering
against.

One property is deliberately NOT carried over: the old cachix step ran with
`continue-on-error: true` so a failed cache push could not fail a job whose
tests passed. The action exposes no equivalent, and adding one here would also
swallow genuine setup failures now that the same step installs Nix rather than
only publishing at the end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: drop references to removed generator flags and interfaces

README and docs described module authoring in terms of LogosProviderBase,
LOGOS_METHOD and --provider-header, none of which exist. Updated to the
universal model, keeping the retired shapes named as history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(deps): track master for protocol, cpp-sdk and plugin-qt

logos-protocol#59, logos-cpp-sdk#138 and logos-plugin-qt#19 merged, so the three
rev pins bridging to them are retired, each with its rationale rewritten to name
the PR that closed the gap.

Left pinned: logos-liblogos, logos-capability-module and logos-test-modules —
their branches are still in flight and no merged upstream was confirmed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 12:47:31 -03:00

1304 lines
60 KiB
C++

// ─────────────────────────────────────────────────────────────────────────────
// logoscore's copy of this suite.
//
// While both binaries ship, both get tested. This file is a duplicate of the
// logosctl suite frozen against logoscore's surface -- the old flags, the flat
// subcommands, ~/.logoscore -- so a change that quietly altered the tool people
// actually use fails here rather than in someone's deployment.
//
// Deliberately a copy rather than a parameterised shared suite: the two
// surfaces genuinely differ, and this whole file gets deleted when logoscore
// is retired. Keeping them separate makes that a delete instead of an unpick.
// ─────────────────────────────────────────────────────────────────────────────
// Daemon-backed integration tests.
//
// Unlike test_cli.cpp (which only pokes the binary in no-daemon / inline
// mode), these spin up a *real* logoscore daemon in the background against
// a real test-module directory and drive it through the client subcommands
// — the same shape as logos-logoscore-py's integration suite.
//
// Coverage:
// * Error paths (ErrorPathTest): unknown-module load, calling methods on
// unknown / unloaded modules, unknown method on a loaded module,
// module-info on an unknown module. One fresh daemon per test (the
// "module known but not loaded" precondition needs pristine state).
// * Full test_basic_module API + concurrency (LoadedModuleTest): every
// Q_INVOKABLE return/parameter type (void, bool, int, QString,
// LogosResult, QVariant, QJsonArray, QStringList), 0..5-arg fan-out,
// the async delay helper, the event subscription round-trip via
// `watch`, and many simultaneous clients hitting one daemon. The
// whole suite shares ONE daemon (SetUpTestSuite) with the module
// loaded once — per-test daemons made the check take many minutes.
// Mirrors logos-logoscore-py/tests/integration/test_basic_module_methods.py
// and logos-test-modules/test-basic-module — keep them in sync.
//
// Negative `call`/`module-info` cases: this CLI/SDK revision has no fast
// "not loaded / not found" guard — the request rides the SDK's ~100s RPC
// timeout (surfacing as RPC_FAILED / non-zero exit, the exact code not
// stable across revisions). So those are `timeout`-bounded and asserted
// as "must not succeed" rather than waiting ~100s for an exact code.
//
// Requires LOGOSCORE_BINARY + LOGOSCORE_TEST_MODULES_DIR (the flake's
// `tests` check wires both, plus LOGOS_HOST_PATH so modules can load).
// Absent ⇒ everything GTEST_SKIPs so the suite stays green locally.
#include <gtest/gtest.h>
#include <logos_json.h>
#include <algorithm>
#include <cerrno>
#include <chrono>
#include <csignal>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <fstream>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
#include <fcntl.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <sys/un.h>
#include <sys/wait.h>
#include <unistd.h>
namespace fs = std::filesystem;
namespace {
std::string slurp(const fs::path& p)
{
std::ifstream ifs(p);
if (!ifs) return {};
return std::string((std::istreambuf_iterator<char>(ifs)),
std::istreambuf_iterator<char>());
}
// Pull the last well-formed JSON object out of captured client output.
// `call --json` prints one compact envelope line on stdout, but a stray
// qWarning on stderr (captured via 2>&1) could precede it — scan from
// the end for the first line that parses as a JSON object.
nlohmann::json lastJsonObject(const std::string& out)
{
std::vector<std::string> lines;
std::string line;
for (char c : out) {
if (c == '\n') { lines.push_back(line); line.clear(); }
else line += c;
}
if (!line.empty()) lines.push_back(line);
for (auto it = lines.rbegin(); it != lines.rend(); ++it) {
if (it->empty()) continue;
try {
nlohmann::json d = nlohmann::json::parse(*it);
if (d.is_object()) return d;
} catch (...) {}
}
return nlohmann::json::object();
}
// A real logoscore daemon in an isolated config/HOME, plus helpers to
// drive clients against it. Not a gtest fixture so it can be owned
// per-test (error paths) or once per suite (the API/concurrency matrix).
class LogoscoreDaemon {
public:
bool envReady(std::string& why) {
const char* b = std::getenv("LOGOSCORE_BINARY");
const char* m = std::getenv("LOGOSCORE_TEST_MODULES_DIR");
if (!b || !fs::exists(b)) { why = "LOGOSCORE_BINARY not set/found"; return false; }
if (!m || !fs::exists(m)) { why = "LOGOSCORE_TEST_MODULES_DIR not set/found"; return false; }
binary = fs::canonical(b);
modulesDir = fs::canonical(m);
return true;
}
void start(const std::string& tag) {
prepare(tag);
launch();
}
// Lay out the isolated tree without starting anything, so a test can seed
// daemon/config.json or derive paths under `base` before the process runs.
void prepare(const std::string& tag) {
base = fs::temp_directory_path() / ("logoscore_it_" + tag + "_" + std::to_string(getpid()));
configDir = base / "config";
homeDir = base / "home";
daemonLog = base / "daemon.log";
fs::create_directories(configDir);
fs::create_directories(homeDir);
if (!socketDir.empty()) fs::create_directories(socketDir);
}
// Spawn the daemon into the tree `prepare` laid out. `extraArgs` is
// appended after the standard `daemon -m <modules>`.
void launch() {
std::vector<std::string> args{"daemon", "-m", modulesDir.string()};
args.insert(args.end(), extraArgs.begin(), extraArgs.end());
pid = spawnBg(args, daemonLog);
}
// fork + setsid + exec a logoscore subprocess (daemon or watch) with
// this daemon's isolated env; stdout+stderr → logFile, stdin
// detached. setsid ⇒ the pid leads a process group so the whole
// tree (incl. logos_host children) tears down together.
pid_t spawnBg(const std::vector<std::string>& cliArgs, const fs::path& logFile) {
pid_t p = fork();
if (p < 0) return -1;
if (p == 0) {
setsid();
setenv("LOGOSCORE_CONFIG_DIR", configDir.c_str(), 1);
setenv("HOME", homeDir.c_str(), 1);
// QLocalServer resolves a bare server name against QDir::tempPath(),
// which honours $TMPDIR — so this decides where the node's sockets
// land. Tests that assert on socket files set it to get a private
// directory instead of sharing the machine-wide temp dir (where a
// dev box's leftovers would swamp the assertions).
if (!socketDir.empty()) setenv("TMPDIR", socketDir.c_str(), 1);
FILE* lf = std::fopen(logFile.c_str(), "w");
if (lf) { dup2(fileno(lf), STDOUT_FILENO); dup2(fileno(lf), STDERR_FILENO); }
int dn = open("/dev/null", O_RDONLY);
if (dn >= 0) dup2(dn, STDIN_FILENO);
std::vector<char*> argv;
argv.push_back(const_cast<char*>("logoscore"));
for (const auto& a : cliArgs) argv.push_back(const_cast<char*>(a.c_str()));
argv.push_back(nullptr);
execv(binary.c_str(), argv.data());
dprintf(STDERR_FILENO, "execv failed: errno=%d\n", errno);
_exit(127);
}
return p;
}
bool waitReady() {
// Bound each probe: a partially-started daemon (client config
// written, RPC not yet answering) would otherwise make `status`
// block the full SDK timeout per iteration, blowing the ~20s
// readiness budget into minutes.
for (int i = 0; i < 200; ++i) {
int st = 0;
if (pid > 0 && waitpid(pid, &st, WNOHANG) == pid) { pid = -1; return false; }
if (run("status", nullptr, /*timeoutSecs=*/5) == 0) return true;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return false;
}
// Run `logoscore <args> --json` against this daemon. timeoutSecs>0
// wraps it in coreutils `timeout` (exit 124 if it fires). Safe to
// call concurrently from multiple threads — each call is its own
// process and FILE*, sharing no mutable state on this object.
int run(const std::string& args, std::string* out, int timeoutSecs = 0) const {
std::string cmd =
"LOGOSCORE_CONFIG_DIR='" + configDir.string() + "' " +
"HOME='" + homeDir.string() + "' ";
// The client dials the same bare socket name, so it must resolve
// QDir::tempPath() to the same place the daemon bound in.
if (!socketDir.empty()) cmd += "TMPDIR='" + socketDir.string() + "' ";
if (timeoutSecs > 0) cmd += "timeout " + std::to_string(timeoutSecs) + " ";
cmd += "'" + binary.string() + "' " + args + " --json 2>&1";
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) return -1;
char buf[256];
std::string captured;
while (fgets(buf, sizeof(buf), pipe)) captured += buf;
if (out) *out = captured;
int status = pclose(pipe);
return WEXITSTATUS(status);
}
void killGroup(pid_t p) {
if (p <= 0) return;
kill(-p, SIGTERM);
for (int i = 0; i < 30; ++i) {
int st = 0;
if (waitpid(p, &st, WNOHANG) == p) return;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
kill(-p, SIGKILL);
int st = 0;
waitpid(p, &st, 0);
}
void shutdown() {
killGroup(pid);
pid = -1;
std::error_code ec;
if (!base.empty()) fs::remove_all(base, ec);
}
// Extra CLI arguments for the daemon, appended after `daemon -m <modules>`.
// Empty for every test that only needs the stock daemon.
std::vector<std::string> extraArgs;
fs::path binary, modulesDir, base, configDir, homeDir, daemonLog;
// Optional private socket directory ($TMPDIR for the node). Empty ⇒
// inherit the ambient temp dir, which is what every non-socket test wants.
fs::path socketDir;
pid_t pid = -1;
};
// ── socket-file helpers (shared by the socket-lifecycle tests) ─────────────
// Unix socket paths are hard-capped by sockaddr_un::sun_path — 104 bytes on
// macOS, 108 on Linux — and the node appends "/logos_<module>_<12 hex>" to
// $TMPDIR. The longest name in play is "logos_capability_module_" + 12 hex
// = 36 chars, so the directory itself has to stay well under the cap or every
// listen() fails with HostNotFoundError.
//
// macOS's default temp dir (/var/folders/<18 chars>/<18 chars>/T) is already
// ~50 chars, and a per-test subdirectory under it overflows — so prefer the
// ambient temp dir only while it fits, and fall back to a short /tmp path.
// Returns an empty path when neither fits, which the fixture turns into a skip
// rather than a confusing bind failure.
constexpr std::size_t kSunPathCap = 104; // the stricter of the two platforms
constexpr std::size_t kLongestName = 40; // "/logos_capability_module_<12hex>" + slack
fs::path shortSocketDir(const std::string& tag)
{
const std::string leaf = "lsit_" + std::to_string(getpid()) + "_" + tag;
for (const fs::path& parent : {fs::temp_directory_path(), fs::path("/tmp")}) {
const fs::path cand = parent / leaf;
if (cand.string().size() + kLongestName < kSunPathCap) return cand;
}
return {};
}
// Names of the unix-socket files in `dir` starting with "logos_".
// Only S_ISSOCK entries count, so a regular file sharing the prefix is
// excluded from the tally the assertions are written against.
std::vector<std::string> socketNames(const fs::path& dir)
{
std::vector<std::string> out;
std::error_code ec;
for (const auto& e : fs::directory_iterator(dir, ec)) {
const std::string name = e.path().filename().string();
if (name.rfind("logos_", 0) != 0) continue;
struct stat st{};
if (::lstat(e.path().c_str(), &st) != 0) continue;
if (S_ISSOCK(st.st_mode)) out.push_back(name);
}
std::sort(out.begin(), out.end());
return out;
}
// Bind a unix socket at `p` and listen. Returns the fd (>=0) or -1.
int bindListen(const fs::path& p)
{
int fd = ::socket(AF_UNIX, SOCK_STREAM, 0);
if (fd < 0) return -1;
sockaddr_un addr{};
addr.sun_family = AF_UNIX;
const std::string s = p.string();
if (s.size() >= sizeof(addr.sun_path)) { ::close(fd); return -1; }
std::snprintf(addr.sun_path, sizeof(addr.sun_path), "%s", s.c_str());
::unlink(s.c_str());
if (::bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0
|| ::listen(fd, 1) != 0) {
::close(fd);
return -1;
}
return fd;
}
// Reap a spawned subprocess (e.g. `watch`) on every exit path —
// including a fatal gtest assertion that `return`s out of the test —
// so background watchers can't leak past the test.
struct ProcGuard {
LogoscoreDaemon* d;
pid_t pid;
~ProcGuard() { if (d && pid > 0) d->killGroup(pid); }
};
constexpr int kNegativeBudgetSecs = 12;
} // namespace
// ═══════════════════════════════════════════════════════════════════════════
// Error paths — fresh daemon per test (needs pristine "not loaded" state)
// ═══════════════════════════════════════════════════════════════════════════
class ErrorPathTest : public ::testing::Test {
protected:
LogoscoreDaemon d;
void SetUp() override {
std::string why;
if (!d.envReady(why)) GTEST_SKIP() << why;
d.start(::testing::UnitTest::GetInstance()->current_test_info()->name());
ASSERT_TRUE(d.waitReady())
<< "daemon did not become reachable.\n--- daemon log ---\n"
<< slurp(d.daemonLog);
}
void TearDown() override { d.shutdown(); }
};
TEST_F(ErrorPathTest, NoLoadNegativePaths) {
std::string out;
// Daemon reachable + module discoverable but not loaded.
ASSERT_EQ(d.run("status", &out), 0) << out;
ASSERT_EQ(d.run("list-modules", &out), 0) << out;
EXPECT_NE(out.find("test_basic"), std::string::npos)
<< "test_basic_module should be discoverable.\n" << out;
ASSERT_EQ(d.run("list-modules --loaded", &out), 0) << out;
EXPECT_EQ(out.find("test_basic_module"), std::string::npos)
<< "module must start unloaded.\n" << out;
// Loading an unknown module must fail.
EXPECT_NE(d.run("load-module definitely_not_a_real_module_xyz",
&out, kNegativeBudgetSecs), 0)
<< "load-module unknown should not succeed.\n" << out;
// Calling a method on an unknown module must fail.
EXPECT_NE(d.run("call definitely_not_a_real_module_xyz whatever",
&out, kNegativeBudgetSecs), 0)
<< "call on unknown module should not succeed.\n" << out;
// Calling a method on a known-but-unloaded module must fail.
EXPECT_NE(d.run("call test_basic_module returnTrue",
&out, kNegativeBudgetSecs), 0)
<< "call on unloaded module should not succeed.\n" << out;
// module-info on an unknown module must fail.
EXPECT_NE(d.run("module-info definitely_not_a_real_module_xyz",
&out, kNegativeBudgetSecs), 0)
<< "module-info on unknown module should not succeed.\n" << out;
}
// Regression for #59: the version, sourced from each module's embedded
// metadata, must appear in list-modules / module-info / load-module — and,
// critically, for modules that are merely KNOWN (not yet loaded), since that
// reads from on-disk metadata rather than a running plugin. test_basic_module
// declares version 1.0.0 in its metadata.json.
TEST_F(ErrorPathTest, ReportsModuleVersion) {
std::string out;
const std::string kVersion = "\"version\":\"1.0.0\"";
// Unloaded module: version is read from metadata, so it is present even
// before the module is loaded.
ASSERT_EQ(d.run("list-modules", &out), 0) << out;
EXPECT_NE(out.find("test_basic_module"), std::string::npos) << out;
EXPECT_NE(out.find(kVersion), std::string::npos)
<< "list-modules must report the metadata version for a known module.\n"
<< out;
ASSERT_EQ(d.run("module-info test_basic_module", &out), 0) << out;
EXPECT_NE(out.find(kVersion), std::string::npos)
<< "module-info must report the module version.\n" << out;
// module-info is now backed by the generic modules-info dump, so the
// dependency graph is reported too (test_basic_module has no deps).
EXPECT_NE(out.find("\"dependencies\""), std::string::npos)
<< "module-info must include the dependencies array.\n" << out;
// Uptime is loaded-only: an unloaded module reports no uptime_seconds.
EXPECT_EQ(out.find("uptime_seconds"), std::string::npos)
<< "unloaded module-info must not report uptime_seconds.\n" << out;
// Loading it returns the version too, and list-modules keeps reporting it.
ASSERT_EQ(d.run("load-module test_basic_module", &out, kNegativeBudgetSecs), 0)
<< "test_basic_module must load (LOGOS_HOST_PATH wired?).\n" << out;
EXPECT_NE(out.find(kVersion), std::string::npos)
<< "load-module response must include the version.\n" << out;
ASSERT_EQ(d.run("list-modules", &out), 0) << out;
EXPECT_NE(out.find(kVersion), std::string::npos)
<< "list-modules must still report the version once loaded.\n" << out;
// Once loaded, uptime is derived from the load timestamp and reported.
ASSERT_EQ(d.run("module-info test_basic_module", &out), 0) << out;
EXPECT_NE(out.find("uptime_seconds"), std::string::npos)
<< "loaded module-info must report uptime_seconds.\n" << out;
}
TEST_F(ErrorPathTest, UnknownMethodOnLoadedModule) {
std::string out;
ASSERT_EQ(d.run("load-module test_basic_module", &out), 0)
<< "test_basic_module must load (LOGOS_HOST_PATH wired?).\n" << out
<< "\n--- daemon log ---\n" << slurp(d.daemonLog);
EXPECT_NE(d.run("call test_basic_module thisMethodDoesNotExist",
&out, kNegativeBudgetSecs), 0)
<< "unknown method on a loaded module should not succeed.\n" << out;
// A real method still works fast — proves the failure above was
// method-scoped, not a wedged daemon.
ASSERT_EQ(d.run("call test_basic_module returnTrue", &out), 0) << out;
EXPECT_NE(out.find("true"), std::string::npos) << out;
}
// Crash-isolation: modules run in a separate logos_host subprocess
// (logos_core_start spawns it in remote mode). A faulty module that
// SIGSEGVs must take down only that host process — the logoscore
// daemon itself must keep answering clients. Uses test_basic_module's
// crashOnDemand() (a null-pointer deref). Fresh-daemon-per-test
// because killing the host pollutes shared state for everything else.
//
// "Daemon survived" alone is too weak — a missing/renamed method or
// an error-returning stub also exits non-zero. Belt + braces:
// (1) precondition: module-info lists crashOnDemand → we're really
// calling the method we think we are;
// (2) positive crash evidence: after the call, the daemon observes
// the host die and flips the module out of "loaded" state
// (module_manager.cpp's onTerminated → registry.markUnloaded).
// A method that merely returned an error would leave it loaded.
TEST_F(ErrorPathTest, CrashedModuleDoesNotKillDaemon) {
std::string out;
// Module loads and is callable — baseline that the daemon + host
// are healthy before we intentionally crash one of them.
ASSERT_EQ(d.run("load-module test_basic_module", &out), 0)
<< "test_basic_module must load before crash test.\n" << out
<< "\n--- daemon log ---\n" << slurp(d.daemonLog);
ASSERT_EQ(d.run("call test_basic_module returnTrue", &out), 0)
<< "module must be callable pre-crash.\n" << out;
EXPECT_NE(out.find("true"), std::string::npos) << out;
// Precondition (1): the method we're about to call really exists
// on the loaded module — otherwise the "non-zero exit" assertion
// below would pass for the wrong reason (method-not-found, not
// host crash). Pulls the methods list from module-info --json.
ASSERT_EQ(d.run("module-info test_basic_module", &out), 0)
<< "module-info should succeed for a loaded module.\n" << out;
nlohmann::json pre = lastJsonObject(out);
ASSERT_EQ(pre.value("status", std::string{}), "loaded")
<< "module should be loaded before the crash.\n" << out;
nlohmann::json methods = pre.value("methods", nlohmann::json::array());
bool hasCrash = false;
for (const auto& v : methods) {
if (v.value("name", std::string{}) == "crashOnDemand") {
hasCrash = true;
break;
}
}
ASSERT_TRUE(hasCrash)
<< "test_basic_module must expose crashOnDemand — otherwise "
"this test would pass for the wrong reason (method-not-found).\n"
<< out;
// Crash the host. The RPC will not return cleanly: either the
// peer dies mid-call (RPC_FAILED, non-zero exit) or the SDK
// burns its full timeout. `timeout` keeps us bounded in either
// case — we only assert "must not succeed", not a specific code.
EXPECT_NE(d.run("call test_basic_module crashOnDemand",
&out, kNegativeBudgetSecs), 0)
<< "crashOnDemand must not return success.\n" << out;
// Positive crash evidence (2): the daemon's SIGCHLD handler runs
// asynchronously, so poll for up to ~5s waiting to observe the
// module flip out of "loaded" (markUnloaded). If crashOnDemand
// had merely returned an error envelope without dying, the host
// would still be alive and module-info would keep reporting
// status=="loaded" — failing this assertion.
std::string lastStatus = "<none>";
bool unloaded = false;
for (int i = 0; i < 50; ++i) {
if (d.run("module-info test_basic_module", &out, /*timeoutSecs=*/5) == 0) {
lastStatus = lastJsonObject(out).value("status", std::string{});
if (lastStatus != "loaded") { unloaded = true; break; }
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
EXPECT_TRUE(unloaded)
<< "daemon never observed the host crash — module still reports "
<< "status='" << lastStatus << "' (expected anything but 'loaded'). "
<< "Either crashOnDemand didn't actually crash, or the daemon's "
<< "subprocess supervisor stopped detecting host exits.\n"
<< "--- daemon log ---\n" << slurp(d.daemonLog);
// Original isolation assertion: a quick `status` round-trip proves
// the daemon is still serving clients after the host subprocess
// died. If the daemon went down with the module, `status` would
// either fail to connect or `timeout` would fire.
ASSERT_EQ(d.run("status", &out, /*timeoutSecs=*/10), 0)
<< "daemon must survive a module crash.\n" << out
<< "\n--- daemon log ---\n" << slurp(d.daemonLog);
// `list-modules` exercises a different code path than `status`
// (catalog scan vs. liveness check) — both must keep working.
ASSERT_EQ(d.run("list-modules", &out, /*timeoutSecs=*/10), 0)
<< "list-modules must still work after a module crash.\n" << out;
EXPECT_NE(out.find("test_basic"), std::string::npos)
<< "module must remain discoverable after its host crashed.\n" << out;
}
// Auth-token regression: the client must present a token core_service
// accepts on every RPC.
//
// The daemon issues one `auto` bearer token at boot and registers its raw
// value in its TokenManager (under "cli_client"). core_service validates an
// incoming token *by value* (ModuleProxy::isAuthorized scans every stored
// token), so the client only authenticates if it actually *transmits* that
// token. But the SDK picks which token to send by the TARGET name —
// getToken(objectName) with objectName == "core_service" — so the client
// must have the bearer token filed under the "core_service" key
// (RpcClient::connect, src/client/client.cpp). With that registration
// missing, getToken("core_service") misses, the SDK falls into the
// capability_module requestModule fallback (which can't mint a token for
// the in-process core_service), an unrecognized token reaches the daemon,
// and EVERY business RPC is rejected — list-modules, load-module, call,
// status, stop all fail. (Pre-enforcement SDKs ignored the token, hiding
// this; once ModuleProxy started enforcing, the gap became fatal.)
//
// This test pins that path: a load + a real method call must succeed, AND
// the daemon log must carry no "rejecting unauthorized" line — so a
// regression that breaks the client's token registration fails here loudly,
// not as some unrelated RPC error.
TEST_F(ErrorPathTest, ClientAuthenticatesToCoreService) {
std::string out;
// list-modules is the cheapest authenticated core_service RPC (no
// module load involved). If the client isn't presenting a token
// core_service accepts, this already fails.
ASSERT_EQ(d.run("list-modules", &out), 0)
<< "list-modules (an authenticated core_service RPC) must succeed — "
"the client must present a token core_service accepts.\n" << out
<< "\n--- daemon log ---\n" << slurp(d.daemonLog);
// A real business dispatch end-to-end: load the module, then call a
// method on it. Both legs are authenticated core_service RPCs; the
// second also drives core_service -> module. A broken client token
// registration makes load-module fail outright.
ASSERT_EQ(d.run("load-module test_basic_module", &out), 0)
<< "load-module must succeed for an authenticated client "
"(LOGOS_HOST_PATH wired?).\n" << out
<< "\n--- daemon log ---\n" << slurp(d.daemonLog);
ASSERT_EQ(d.run("call test_basic_module returnTrue", &out), 0)
<< "an authenticated call must round-trip and succeed.\n" << out
<< "\n--- daemon log ---\n" << slurp(d.daemonLog);
EXPECT_NE(out.find("true"), std::string::npos) << out;
// Root-cause pin: prove the calls above weren't authorized by some
// unrelated accident. core_service logs exactly this string when it
// rejects an unrecognized token (ModuleProxy::callRemoteMethod). Its
// presence means the client sent a token core_service didn't accept —
// i.e. the very regression this test guards against.
const std::string log = slurp(d.daemonLog);
EXPECT_EQ(log.find("rejecting unauthorized"), std::string::npos)
<< "core_service rejected a client token as unauthorized — the "
"client is not presenting a token it accepts (check the "
"core_service token registration in RpcClient::connect).\n"
"--- daemon log ---\n" << log;
}
// ═══════════════════════════════════════════════════════════════════════════
// Full API + concurrency — ONE shared daemon, module loaded once
// ═══════════════════════════════════════════════════════════════════════════
class LoadedModuleTest : public ::testing::Test {
protected:
static LogoscoreDaemon* s_d;
static bool s_skip;
static std::string s_skipWhy;
static void SetUpTestSuite() {
s_d = new LogoscoreDaemon();
if (!s_d->envReady(s_skipWhy)) { s_skip = true; return; }
s_d->start("loaded_suite");
ASSERT_TRUE(s_d->waitReady())
<< "daemon did not become reachable.\n--- daemon log ---\n"
<< slurp(s_d->daemonLog);
// Load once for the whole suite. If it fails (e.g. the daemon's
// modules dir is missing capability_module so the request hangs
// on capability negotiation), skip the API/concurrency tests
// with the daemon log attached rather than letting every test
// burn the ~20s RPC timeout into a hard failure.
std::string out;
if (s_d->run("load-module test_basic_module", &out, 30) != 0) {
s_skip = true;
s_skipWhy = "test_basic_module failed to load — skipping API/"
"concurrency suite.\n" + out +
"\n--- daemon log ---\n" + slurp(s_d->daemonLog);
}
}
static void TearDownTestSuite() {
// shutdown() is a no-op when the daemon was never started
// (env missing) — pid<=0 and base empty — so it's always safe.
if (s_d) { s_d->shutdown(); delete s_d; s_d = nullptr; }
}
void SetUp() override {
if (s_skip) GTEST_SKIP() << s_skipWhy;
}
// `call test_basic_module <method> [args]` → `result` of the success
// envelope {"status":"ok","module":...,"result":<v>}.
nlohmann::json call(const std::string& method, const std::string& args = "") {
std::string out;
const std::string cmd =
"call test_basic_module " + method + (args.empty() ? "" : " " + args);
EXPECT_EQ(s_d->run(cmd, &out), 0) << cmd << "\n" << out;
nlohmann::json env = lastJsonObject(out);
EXPECT_EQ(env.value("status", std::string{}), "ok")
<< cmd << "\n" << out;
return env.value("result", nlohmann::json{});
}
};
LogoscoreDaemon* LoadedModuleTest::s_d = nullptr;
bool LoadedModuleTest::s_skip = false;
std::string LoadedModuleTest::s_skipWhy;
// ── void / bool / int (void surfaces as `true` — call_executor.cpp) ──────────
TEST_F(LoadedModuleTest, VoidAndBoolReturns) {
EXPECT_TRUE(call("doNothing").get<bool>());
EXPECT_TRUE(call("doNothingWithArgs", "hello 7").get<bool>());
EXPECT_TRUE(call("returnTrue").get<bool>());
EXPECT_FALSE(call("returnFalse").get<bool>());
EXPECT_TRUE(call("isPositive", "5").get<bool>());
EXPECT_FALSE(call("isPositive", "-3").get<bool>());
EXPECT_FALSE(call("isPositive", "0").get<bool>());
}
TEST_F(LoadedModuleTest, IntReturns) {
EXPECT_EQ(call("returnInt").get<int>(), 42);
EXPECT_EQ(call("addInts", "2 3").get<int>(), 5);
EXPECT_EQ(call("stringLength", "abcdef").get<int>(), 6);
EXPECT_EQ(call("echoInt", "123").get<int>(), 123);
EXPECT_EQ(call("byteArraySize", "abcde").get<int>(), 5);
}
TEST_F(LoadedModuleTest, StringReturns) {
EXPECT_EQ(call("returnString").get<std::string>(), "test_basic_module");
EXPECT_EQ(call("echo", "roundtrip").get<std::string>(), "roundtrip");
EXPECT_EQ(call("concat", "foo bar").get<std::string>(), "foobar");
EXPECT_EQ(call("urlToString", "https://example.com/p").get<std::string>(),
"https://example.com/p");
}
TEST_F(LoadedModuleTest, LogosResultShapes) {
nlohmann::json ok = call("successResult");
EXPECT_TRUE(ok["success"].get<bool>());
EXPECT_EQ(ok["value"].get<std::string>(), "operation succeeded");
EXPECT_TRUE(ok["error"].is_null());
nlohmann::json err = call("errorResult");
EXPECT_FALSE(err["success"].get<bool>());
EXPECT_TRUE(err["value"].is_null());
EXPECT_EQ(err["error"].get<std::string>(), "deliberate error for testing");
nlohmann::json m = call("resultWithMap")["value"];
EXPECT_EQ(m["name"].get<std::string>(), "test");
EXPECT_EQ(m["count"].get<int>(), 42);
EXPECT_TRUE(m["active"].get<bool>());
nlohmann::json lst = call("resultWithList")["value"];
ASSERT_EQ(lst.size(), 2u);
EXPECT_EQ(lst[0]["label"].get<std::string>(), "first");
EXPECT_EQ(lst[1]["id"].get<int>(), 2);
nlohmann::json vOk = call("validateInput", "hello");
EXPECT_TRUE(vOk["success"].get<bool>());
EXPECT_EQ(vOk["value"]["length"].get<int>(), 5);
nlohmann::json vErr = call("validateInput", "''");
EXPECT_FALSE(vErr["success"].get<bool>());
EXPECT_EQ(vErr["error"].get<std::string>(), "input cannot be empty");
}
TEST_F(LoadedModuleTest, VariantAndCollectionReturns) {
EXPECT_EQ(call("returnVariantInt").get<int>(), 99);
EXPECT_EQ(call("returnVariantString").get<std::string>(), "variant_string");
nlohmann::json vm = call("returnVariantMap");
EXPECT_EQ(vm["key"].get<std::string>(), "value");
EXPECT_EQ(vm["number"].get<int>(), 7);
nlohmann::json vl = call("returnVariantList");
ASSERT_EQ(vl.size(), 3u);
EXPECT_EQ(vl[0].get<std::string>(), "alpha");
EXPECT_EQ(vl[2].get<std::string>(), "gamma");
nlohmann::json ja = call("returnJsonArray");
ASSERT_EQ(ja.size(), 3u);
EXPECT_EQ(ja[0].get<int>(), 1);
EXPECT_EQ(ja[2].get<int>(), 3);
nlohmann::json mk = call("makeJsonArray", "x y");
ASSERT_EQ(mk.size(), 2u);
EXPECT_EQ(mk[1].get<std::string>(), "y");
nlohmann::json sl = call("returnStringList");
ASSERT_EQ(sl.size(), 3u);
EXPECT_EQ(sl[1].get<std::string>(), "two");
nlohmann::json sp = call("splitString", "a,b,c");
ASSERT_EQ(sp.size(), 3u);
EXPECT_EQ(sp[0].get<std::string>(), "a");
EXPECT_EQ(sp[2].get<std::string>(), "c");
}
TEST_F(LoadedModuleTest, ArgCountFanOut) {
EXPECT_EQ(call("noArgs").get<std::string>(), "noArgs()");
EXPECT_EQ(call("oneArg", "x").get<std::string>(), "oneArg(x)");
EXPECT_EQ(call("twoArgs", "x 7").get<std::string>(), "twoArgs(x, 7)");
EXPECT_EQ(call("threeArgs", "x 7 true").get<std::string>(),
"threeArgs(x, 7, true)");
EXPECT_EQ(call("fourArgs", "x 7 false y").get<std::string>(),
"fourArgs(x, 7, false, y)");
EXPECT_EQ(call("fiveArgs", "x 7 true y 9").get<std::string>(),
"fiveArgs(x, 7, true, y, 9)");
EXPECT_TRUE(call("echoBool", "true").get<bool>());
EXPECT_FALSE(call("echoBool", "false").get<bool>());
}
TEST_F(LoadedModuleTest, AsyncEchoWithDelay) {
auto start = std::chrono::steady_clock::now();
EXPECT_EQ(call("echoWithDelay", "pong 200").get<std::string>(), "pong");
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
std::chrono::steady_clock::now() - start).count();
EXPECT_GE(ms, 200) << "echoWithDelay returned too fast (" << ms << "ms)";
}
// ── Events: subscribe via `watch`, fire via a method call ────────────────────
// Re-emit the event on a cadence while polling the watch log instead of
// sleeping a fixed time and emitting once: emitting is idempotent, so
// this races neither the watcher's subscription nor a slow CI worker —
// whenever the subscription becomes active, a subsequent emit lands.
// The ProcGuard reaps the watcher even if a fatal assertion fires.
TEST_F(LoadedModuleTest, EmitTestEventRoundTrip) {
const fs::path log = s_d->base / "watch_single.log";
pid_t w = s_d->spawnBg({"watch", "test_basic_module", "--event", "testEvent"}, log);
ASSERT_GT(w, 0);
ProcGuard guard{s_d, w};
// Give the watcher time to connect and register its subscription
std::this_thread::sleep_for(std::chrono::milliseconds(500));
bool got = false;
for (int i = 0; i < 300 && !got; ++i) { // up to ~30s
std::string out;
s_d->run("call test_basic_module emitTestEvent payload123", &out);
if (slurp(log).find("payload123") != std::string::npos) { got = true; break; }
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
EXPECT_TRUE(got) << "testEvent payload not observed.\n--- watch log ---\n"
<< slurp(log);
}
TEST_F(LoadedModuleTest, EmitMultiArgEvent) {
const fs::path log = s_d->base / "watch_multi.log";
pid_t w = s_d->spawnBg({"watch", "test_basic_module", "--event", "multiArgEvent"}, log);
ASSERT_GT(w, 0);
ProcGuard guard{s_d, w};
// Give the watcher time to connect and register its subscription
std::this_thread::sleep_for(std::chrono::milliseconds(500));
bool got = false;
for (int i = 0; i < 300 && !got; ++i) { // up to ~30s
std::string out;
s_d->run("call test_basic_module emitMultiArgEvent label 42", &out);
const std::string l = slurp(log);
if (l.find("label") != std::string::npos && l.find("42") != std::string::npos) {
got = true; break;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
EXPECT_TRUE(got) << "multiArgEvent not observed.\n--- watch log ---\n" << slurp(log);
}
// ── Concurrency: many independent clients hitting one daemon at once ─────────
// Each thread is its own `logoscore call` process. Worker threads do NO
// gtest assertions (not thread-safe) — they record (ok, detail) into
// private slots; the main thread asserts after join.
TEST_F(LoadedModuleTest, ConcurrentEchoFromManyClients) {
constexpr int N = 12;
std::vector<std::thread> ts;
std::vector<int> ok(N, 0);
std::vector<std::string> detail(N);
for (int i = 0; i < N; ++i) {
ts.emplace_back([&, i] {
const std::string tok = "tok" + std::to_string(i);
std::string out;
int rc = s_d->run("call test_basic_module echo " + tok, &out);
std::string got;
try {
got = lastJsonObject(out)["result"].get<std::string>();
} catch (...) {}
ok[i] = (rc == 0 && got == tok) ? 1 : 0;
if (!ok[i]) detail[i] = "rc=" + std::to_string(rc) +
" got='" + got + "' want='" + tok + "'\n" + out;
});
}
for (auto& t : ts) t.join();
for (int i = 0; i < N; ++i)
EXPECT_EQ(ok[i], 1) << "client " << i << ": " << detail[i];
}
TEST_F(LoadedModuleTest, ConcurrentMixedMethodsFromManyClients) {
constexpr int N = 16;
std::vector<std::thread> ts;
std::vector<int> ok(N, 0);
std::vector<std::string> detail(N);
for (int i = 0; i < N; ++i) {
ts.emplace_back([&, i] {
std::string out;
int rc = -1;
bool good = false;
try {
switch (i % 4) {
case 0: { // addInts(i, i) == 2*i
rc = s_d->run("call test_basic_module addInts " +
std::to_string(i) + " " + std::to_string(i), &out);
good = lastJsonObject(out)["result"].get<int>() == 2 * i;
break;
}
case 1: { // echoInt(i) == i
rc = s_d->run("call test_basic_module echoInt " + std::to_string(i), &out);
good = lastJsonObject(out)["result"].get<int>() == i;
break;
}
case 2: { // returnString() == "test_basic_module"
rc = s_d->run("call test_basic_module returnString", &out);
good = lastJsonObject(out)["result"].get<std::string>() == "test_basic_module";
break;
}
default: { // stringLength("xxxx..i..") == i
rc = s_d->run("call test_basic_module stringLength " +
std::string(static_cast<size_t>(i), 'x'), &out);
good = lastJsonObject(out)["result"].get<int>() == i;
break;
}
}
} catch (...) {}
ok[i] = (rc == 0 && good) ? 1 : 0;
if (!ok[i]) detail[i] = "case=" + std::to_string(i % 4) +
" rc=" + std::to_string(rc) + "\n" + out;
});
}
for (auto& t : ts) t.join();
for (int i = 0; i < N; ++i)
EXPECT_EQ(ok[i], 1) << "client " << i << ": " << detail[i];
}
// ═══════════════════════════════════════════════════════════════════════════
// Socket lifecycle — the node must not leave unix-socket files behind
//
// The reported symptom was dozens of stale /tmp/logos_* files: the module
// host died instantly on SIGTERM, so QCoreApplication::exec() never returned
// and QLocalServer's destructor — the only thing that unlinks the socket —
// never ran. Every clean shutdown leaked one file per module.
//
// Two halves, because they fail independently:
// * a graceful stop unlinks everything it bound (the shutdown handler);
// * whatever a *hard* kill leaves behind is reaped at the next boot (the
// reaper), which is the only path available for SIGKILL / crashes.
//
// Both run in a private $TMPDIR so the assertions describe this node's
// sockets and not a developer box's accumulated leftovers.
// ═══════════════════════════════════════════════════════════════════════════
class SocketLifecycleTest : public ::testing::Test {
protected:
LogoscoreDaemon d;
void SetUp() override {
std::string why;
if (!d.envReady(why)) GTEST_SKIP() << why;
d.socketDir = shortSocketDir(
::testing::UnitTest::GetInstance()->current_test_info()->name());
if (d.socketDir.empty())
GTEST_SKIP() << "no temp directory short enough for AF_UNIX sun_path";
std::error_code ec;
fs::remove_all(d.socketDir, ec);
fs::create_directories(d.socketDir, ec);
if (ec) GTEST_SKIP() << "cannot create " << d.socketDir << ": " << ec.message();
}
void TearDown() override {
d.shutdown();
std::error_code ec;
fs::remove_all(d.socketDir, ec);
}
};
TEST_F(SocketLifecycleTest, NoSocketsSurviveGracefulStop)
{
d.start("sockets_stop");
ASSERT_TRUE(d.waitReady()) << slurp(d.daemonLog);
// Load a module so the tally covers a logos_host child socket too — that
// subprocess is a separate binary with its own shutdown path, and it is
// where the bulk of the reported leak came from.
std::string out;
ASSERT_EQ(d.run("load-module test_basic_module", &out), 0) << out;
const auto live = socketNames(d.socketDir);
// core_service + capability_module + test_basic_module.
ASSERT_GE(live.size(), 2u)
<< "expected the running node to have bound sockets in " << d.socketDir
<< "; found " << live.size() << ". Without this the test would pass "
<< "vacuously.\n" << slurp(d.daemonLog);
ASSERT_EQ(d.run("stop", &out), 0) << out;
// The daemon acks `stop` before its children have finished unwinding, so
// give the module hosts a bounded moment to run their destructors.
std::vector<std::string> remaining;
for (int i = 0; i < 100; ++i) {
remaining = socketNames(d.socketDir);
if (remaining.empty()) break;
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
std::string leaked;
for (const auto& n : remaining) leaked += "\n " + n;
EXPECT_TRUE(remaining.empty())
<< "sockets survived a graceful stop:" << leaked << "\n"
<< slurp(d.daemonLog);
}
TEST_F(SocketLifecycleTest, BootReapsStaleSocketsButSparesLiveOnesAndFiles)
{
fs::create_directories(d.socketDir);
// A *stale* socket: bound, then the listener closed. The path stays on
// disk and connect() is refused — exactly what a SIGKILLed node leaves.
const fs::path stale = d.socketDir / "logos_stale_aaaaaaaaaaaa";
const int staleFd = bindListen(stale);
ASSERT_GE(staleFd, 0) << "could not bind " << stale;
::close(staleFd);
ASSERT_TRUE(fs::exists(stale));
// A *live* socket owned by this test process, held open for the duration.
// Reaping it would mean the reaper can kill a co-resident node's endpoints.
const fs::path live = d.socketDir / "logos_live_bbbbbbbbbbbb";
const int liveFd = bindListen(live);
ASSERT_GE(liveFd, 0) << "could not bind " << live;
// A regular file that merely shares the prefix. A glob-based cleanup would
// delete it; the real one only ever unlinks S_ISSOCK inodes. (Not
// hypothetical: a multi-hundred-MB logos_*.lgx sits in the temp dir on a
// dev box.)
const fs::path plain = d.socketDir / "logos_execution_zone-1.0.0.lgx";
{ std::ofstream f(plain); f << "not a socket"; }
ASSERT_TRUE(fs::exists(plain));
d.start("sockets_reap");
const bool ready = d.waitReady();
::close(liveFd);
ASSERT_TRUE(ready) << slurp(d.daemonLog);
EXPECT_FALSE(fs::exists(stale))
<< "a stale socket survived the boot reaper: " << stale << "\n"
<< slurp(d.daemonLog);
EXPECT_TRUE(fs::exists(live))
<< "the reaper unlinked a LIVE socket — a co-resident node would lose "
<< "its endpoint: " << live;
ASSERT_TRUE(fs::exists(plain))
<< "the reaper deleted a regular file sharing the prefix: " << plain;
EXPECT_EQ(slurp(plain), "not a socket") << "regular file was modified";
}
// ═══════════════════════════════════════════════════════════════════════════
// --persistence-path must actually MOVE the data — not merely exist in --help
//
// Regression: the flag parsed and was then dropped. The CLI merge wrote only
// DaemonConfig::persistencePath, while Daemon::start redirects the session's
// data directory from cfg.dirs.data. The alias that folds one spelling into
// the other lives inside daemonConfigFromJson, so it only ever ran while
// READING a config file — never after the CLI merge. Net effect: with no
// config.json the flag was ignored, and with one the FILE beat the command
// line. The only coverage was an assertion that "--persistence-path" appears
// in the help text, which the broken build passed.
//
// Asserted behaviourally rather than through the config plumbing: loading a
// module makes logos_core resolve — and mkpath — <data dir>/<module>/<12hex>,
// so the directory tree on disk is direct evidence of where the data went.
// Each case checks both halves: it landed under the flag's directory, AND it
// did not land in the directory that would win if the flag were still dead.
// ═══════════════════════════════════════════════════════════════════════════
namespace {
// The per-instance directories logos_core created for `module` under `base`.
// Empty when the module never persisted there (including when `base` itself
// does not exist).
std::vector<std::string> instanceDirs(const fs::path& base, const std::string& module)
{
std::vector<std::string> out;
std::error_code ec;
for (const auto& e : fs::directory_iterator(base / module, ec))
if (e.is_directory()) out.push_back(e.path().filename().string());
std::sort(out.begin(), out.end());
return out;
}
// What the seeded daemon/config.json says about the data directory.
enum class DiskConfig {
None, // no config.json at all
DirsData, // the new spelling, pointing at a decoy
PersistencePath, // the old spelling, pointing at a decoy
};
} // namespace
class PersistencePathTest : public ::testing::Test {
protected:
LogoscoreDaemon d;
fs::path custom; // what --persistence-path names
fs::path decoy; // what the seeded config.json names, if any
void SetUp() override {
std::string why;
if (!d.envReady(why)) GTEST_SKIP() << why;
}
void TearDown() override {
d.shutdown();
if (!cwdLitter.empty()) {
std::error_code ec;
fs::remove_all(fs::current_path() / cwdLitter, ec);
}
}
// Boot a daemon with `--persistence-path <flagValue>` over the given disk
// config, then load a module so something actually persists. `flagValue`
// empty ⇒ an absolute directory under this daemon's own tree, which is
// what every case but the relative-path one wants. Fatal assertions inside
// a helper only return from the helper, so every caller re-checks
// HasFatalFailure() before asserting on the result.
void bootAndLoad(const std::string& tag, DiskConfig disk,
const std::string& flagValue = {}) {
d.prepare(tag);
custom = flagValue.empty() ? d.base / "custom-data" : fs::path(flagValue);
decoy = d.base / "decoy-data";
if (disk != DiskConfig::None) {
nlohmann::json doc;
doc["version"] = 2;
if (disk == DiskConfig::DirsData) doc["dirs"]["data"] = decoy.string();
else doc["persistence_path"] = decoy.string();
fs::create_directories(d.configDir / "daemon");
std::ofstream f(d.configDir / "daemon" / "config.json");
ASSERT_TRUE(f.good()) << "could not seed daemon/config.json";
f << doc.dump(2);
f.close();
}
d.extraArgs = {"--persistence-path", custom.string()};
d.launch();
ASSERT_TRUE(d.waitReady())
<< "daemon did not become reachable.\n--- daemon log ---\n"
<< slurp(d.daemonLog);
std::string out;
ASSERT_EQ(d.run("load-module test_basic_module", &out, kNegativeBudgetSecs), 0)
<< "test_basic_module must load before its persistence dir means "
"anything.\n" << out << "\n--- daemon log ---\n" << slurp(d.daemonLog);
}
// The directory the daemon falls back to when nothing redirects it.
fs::path defaultDataDir() const { return d.configDir / "data"; }
// Set by the relative-path case so TearDown can sweep the tree the daemon
// created next to the test binary.
std::string cwdLitter;
};
// No config.json: the flag is the only source of intent, and used to lose to
// the built-in default.
TEST_F(PersistencePathTest, FlagRedirectsModuleData) {
bootAndLoad("pp_default", DiskConfig::None);
if (HasFatalFailure() || IsSkipped()) return;
EXPECT_FALSE(instanceDirs(custom, "test_basic_module").empty())
<< "--persistence-path was ignored: no instance directory under "
<< custom << "\n--- daemon log ---\n" << slurp(d.daemonLog);
EXPECT_FALSE(fs::exists(defaultDataDir() / "test_basic_module"))
<< "module data went to the DEFAULT data dir (" << defaultDataDir()
<< ") even though --persistence-path named " << custom;
}
// config.json carries the new spelling. Precedence is explicit command line >
// config file, so the flag still wins.
TEST_F(PersistencePathTest, FlagBeatsConfigFileDirsData) {
bootAndLoad("pp_dirsdata", DiskConfig::DirsData);
if (HasFatalFailure() || IsSkipped()) return;
EXPECT_FALSE(instanceDirs(custom, "test_basic_module").empty())
<< "--persistence-path lost to dirs.data in config.json; nothing under "
<< custom << "\n--- daemon log ---\n" << slurp(d.daemonLog);
EXPECT_FALSE(fs::exists(decoy / "test_basic_module"))
<< "config.json's dirs.data (" << decoy << ") beat the command line";
}
// config.json carries the OLD spelling. The file-level alias resolves it into
// dirs.data while reading, which is correct as a file rule — but the flag,
// being explicit operator intent, still has to win over it.
TEST_F(PersistencePathTest, FlagBeatsConfigFilePersistencePath) {
bootAndLoad("pp_oldspelling", DiskConfig::PersistencePath);
if (HasFatalFailure() || IsSkipped()) return;
EXPECT_FALSE(instanceDirs(custom, "test_basic_module").empty())
<< "--persistence-path lost to persistence_path in config.json; nothing "
"under " << custom << "\n--- daemon log ---\n" << slurp(d.daemonLog);
EXPECT_FALSE(fs::exists(decoy / "test_basic_module"))
<< "config.json's persistence_path (" << decoy << ") beat the command line";
}
// A relative --persistence-path is relative to the SHELL's cwd, the way this
// flag has always behaved and the way --modules-dir still behaves. It is not
// relative to the config dir: that rule belongs to `dirs:` entries written in
// config.json, and letting it capture the command line too would silently
// reparent `--persistence-path ./data` under ~/.logoscore.
TEST_F(PersistencePathTest, RelativeFlagIsRelativeToCwd) {
cwdLitter = "logoscore_pp_rel_" + std::to_string(getpid());
bootAndLoad("pp_relative", DiskConfig::None, cwdLitter);
if (HasFatalFailure() || IsSkipped()) return;
const fs::path expected = fs::current_path() / cwdLitter;
EXPECT_FALSE(instanceDirs(expected, "test_basic_module").empty())
<< "a relative --persistence-path did not resolve against the cwd: "
"nothing under " << expected << "\n--- daemon log ---\n"
<< slurp(d.daemonLog);
EXPECT_FALSE(fs::exists(fs::path(d.configDir) / cwdLitter))
<< "a relative --persistence-path was reparented under the config dir";
EXPECT_FALSE(fs::exists(defaultDataDir() / "test_basic_module"))
<< "module data went to the DEFAULT data dir despite --persistence-path "
<< cwdLitter;
}
// A leading `~/` is the one relative-looking form that is NOT resolved against
// the cwd: resolveOverride expands it against $HOME (src/config.cpp), and the
// flag has to agree with a config.json `dirs.data` about the same string.
// Absolutising it first would produce `$PWD/~/x` -- a real directory literally
// named `~`. The shell expands an unquoted `~/x` before the binary sees it, so
// this covers the quoted or script-built value, which is exactly the case where
// the user cannot have meant a directory called `~`.
TEST_F(PersistencePathTest, TildeFlagExpandsAgainstHome) {
bootAndLoad("pp_tilde", DiskConfig::None, "~/tilde-data");
if (HasFatalFailure() || IsSkipped()) return;
const fs::path expected = d.homeDir / "tilde-data";
EXPECT_FALSE(instanceDirs(expected, "test_basic_module").empty())
<< "a `~/` --persistence-path did not expand against HOME: nothing "
"under " << expected << "\n--- daemon log ---\n" << slurp(d.daemonLog);
EXPECT_FALSE(fs::exists(fs::current_path() / "~"))
<< "`~` was absolutised into a literal directory beside the cwd";
EXPECT_FALSE(fs::exists(defaultDataDir() / "test_basic_module"))
<< "module data went to the DEFAULT data dir despite --persistence-path";
}
// ═══════════════════════════════════════════════════════════════════════════
// `--access-policy enforce` — the shorthand, on a real daemon
// ═══════════════════════════════════════════════════════════════════════════
//
// The runtime gate itself is proved against the surviving tool in
// test_integration.cpp, which arms the same policy through the session config.
// What is logoscore-only, and therefore what this file has to cover, is the
// SPELLING: the literal `enforce` expanding to the deny-by-default document
// (src/daemon/access_policy_arg.h) and reaching the runtime from a flag.
//
// One daemon, both directions, because a shorthand that resolved to "refuse
// everything" would satisfy the denial on its own:
// test_ipc_new_api_module declares [test_basic_module, test_extlib_module]
// test_basic_module declares [] ← so basic -> extlib is UNdeclared
namespace {
// requestModule is the two-arg gate every inter-module call passes through:
// it returns the minted token, or "" when the policy refuses. nullopt means
// the client call itself failed (a broken daemon, not a policy decision).
std::optional<std::string> requestModuleToken(const LogoscoreDaemon& d,
const std::string& caller,
const std::string& target,
std::string* raw)
{
std::string out;
const std::string cmd =
"call capability_module requestModule " + caller + " " + target;
const int rc = d.run(cmd, &out, /*timeoutSecs=*/20);
if (raw) *raw = out;
if (rc != 0) return std::nullopt;
nlohmann::json env = lastJsonObject(out);
if (env.value("status", std::string{}) != "ok") return std::nullopt;
const nlohmann::json result = env.value("result", nlohmann::json{});
if (result.is_string()) return result.get<std::string>();
if (result.is_null()) return std::string{};
return std::nullopt;
}
// True when the daemon log carries a refusal naming BOTH modules, caller
// first. Matched structurally rather than by exact text: capability_module has
// more than one implementation in this tree and they quote the names
// differently ('x' vs "x"). What must not vary is that both names are there.
bool logDeniesPair(const std::string& log,
const std::string& caller,
const std::string& target)
{
std::istringstream in(log);
std::string line;
while (std::getline(in, line)) {
if (line.find("denies") == std::string::npos) continue;
const auto c = line.find(caller);
const auto t = line.find(target);
if (c != std::string::npos && t != std::string::npos && c < t) return true;
}
return false;
}
} // namespace
TEST(AccessPolicyFlagTest, EnforceShorthandDeniesUndeclaredAndKeepsDeclared) {
LogoscoreDaemon d;
std::string why;
if (!d.envReady(why)) GTEST_SKIP() << why;
d.extraArgs = {"--access-policy", "enforce"};
d.start("access_policy_enforce");
struct Cleanup { LogoscoreDaemon* p; ~Cleanup() { p->shutdown(); } } cleanup{&d};
ASSERT_TRUE(d.waitReady())
<< "daemon did not become reachable.\n--- daemon log ---\n"
<< slurp(d.daemonLog);
// test_ipc_new_api_module declares both others, so one load pulls all three.
std::string out;
if (d.run("load-module test_ipc_new_api_module", &out, /*timeoutSecs=*/30) != 0)
GTEST_SKIP() << "test_ipc_new_api_module not available in this modules dir:\n" << out;
ASSERT_EQ(d.run("list-modules --loaded", &out), 0) << out;
for (const char* m : {"test_ipc_new_api_module", "test_basic_module", "test_extlib_module"})
ASSERT_NE(out.find(m), std::string::npos)
<< m << " must be loaded before probing the gate.\n" << out
<< "\n--- daemon log ---\n" << slurp(d.daemonLog);
// Undeclared: refused, and the refusal names both modules. A silent denial
// presents as a call returning empty, which is the failure mode this
// codebase has been debugged for twice.
std::string raw;
auto denied = requestModuleToken(d, "test_basic_module", "test_extlib_module", &raw);
ASSERT_TRUE(denied.has_value()) << "requestModule call failed outright:\n" << raw;
EXPECT_TRUE(denied->empty())
<< "an undeclared pair must be REFUSED under --access-policy enforce — "
"the shorthand did not arm the policy.\n"
<< raw << "\n--- daemon log ---\n" << slurp(d.daemonLog);
EXPECT_TRUE(logDeniesPair(slurp(d.daemonLog), "test_basic_module", "test_extlib_module"))
<< "the refusal must be logged with both module names.\n"
<< slurp(d.daemonLog);
// Declared: still minted. This is the half that matters — a shorthand that
// refused everything would pass the assertion above and break every real
// deployment.
auto allowed = requestModuleToken(d, "test_ipc_new_api_module", "test_basic_module", &raw);
ASSERT_TRUE(allowed.has_value()) << "requestModule call failed outright:\n" << raw;
EXPECT_FALSE(allowed->empty())
<< "a DECLARED caller must still be allowed under enforce — otherwise "
"the shorthand just breaks everything.\n"
<< raw << "\n--- daemon log ---\n" << slurp(d.daemonLog);
}