Files
logos-logoscore-cli/tests/test_cli.cpp
T
Dario Gabriel LipicarandClaude Opus 5 0f0be25959 fix(client): fail at once when the daemon is gone, and stop reporting that as data
Against a session whose daemon is no longer there, `logosctl module ls` waited
22 seconds and then printed `[]` and exited 0. Not "failed slowly" -- reported
success, with an empty module list, about a daemon that did not exist. `stats`
did the same. `call`, `package`, `catalog` and `key` waited the same 20 seconds
before reporting RPC_FAILED. Only `stop` and `status` were quick, because #100
gave them a guard the other fourteen commands never got.

The mechanism is the one #100 diagnosed. A LocalSocket client "connects" to a
socket path with no listener without complaint, QtRO surfaces no transport
error for an absent peer, and the request is therefore neither answered nor
refused -- so it waits out Timeout(20000) (logos-protocol, cpp/logos_mode.h)
and a dead daemon is indistinguishable from a slow one until the deadline
fires. Connecting is not the check it looks like.

A session outlives its daemon in two shapes, and they need different evidence.

CRASHED SESSION. daemon/state.json is still on disk naming a pid that is gone.
This is #100's check, and it was copied into stop_command and status_command.
It now lives in one place -- detectStaleSession(), called from
Command::ensureConnected() -- which is the single door every RPC-opening
command goes through, so all of them inherit it instead of the two that had it
hand-written. StatusCommand still calls the helper itself, one step earlier,
because its answer to "no daemon" is a status report rather than an error.

#100's instance_id gate is preserved exactly: the guard fires only when the
state file describes the daemon THIS client dials. A remote client can have a
co-resident daemon's leftovers sitting in its own session directory, and its
dial spec carries no instance_id at all, so an empty one never matches. The
liveness syscall now runs before the client-config read, so the common path
(daemon running) does not parse client/config.yaml twice per command.

STOPPED SESSION. The tidier way to get here, and the one the pid guard cannot
see: a clean `daemon stop` REMOVES daemon/state.json, leaving client/config.yaml
and the token behind with no pid left to find dead. Every command still waited
the full 20s. RpcClient::connect() now asks the socket instead, before it
builds a LogosAPIClient (localEndpointProvablyAbsent, src/local_endpoint.h):
the dial resolves to QDir::tempPath()/logos_core_service_<instance_id>, because
the SDK asks for the bare name (LogosInstance::id) and Qt resolves a bare
QLocalSocket/QLocalServer name against the temp dir. Deriving it the same way
is what makes the answer sound rather than a guess.

A stat alone is NOT enough, which cost this patch a wrong first draft. The
socket file outlives the daemon: a hard kill leaves it, and a clean stop leaves
it for the window between the shutdown reply and QLocalServer's destructor --
which is exactly when the next command gets typed. Measured through the new
CLI sweep, stat-only vs stat-plus-connect over the same abandoned socket: 85.3s
(every command timed out) vs 0.8s. So presence settles nothing and being
REFUSED does; ECONNREFUSED is the same signal logos::isSocketDead uses to
decide a socket is safe for the daemon's boot reaper to unlink. That function
is not reused directly only because it sits behind the logos-protocol link,
which logosctl_testlib deliberately does without.

The check fails closed on everything short of proof: a socket that accepts us,
any other connect() error, a path too long for sun_path, a non-socket inode, a
tcp/tcp_ssl dial, an empty instance_id, Windows (named pipes, no inode).
Refusing a reachable daemon would be far worse than the wait being removed.

AN UNANSWERED QUERY IS NOT AN EMPTY ONE. The exit-0 half is a separate defect
and survives independently of the timing: listModules() and getModuleStats()
answered a failed RPC with LogosList::array(), the only two calls in the client
that reported failure as data. Both now return optional<LogosList>, and the
commands report DAEMON_UNREACHABLE with exit 2. `status` had the same shape by
a different route -- RpcClient::getStatus synthesises a not_running report and
marks it `rpc_error`, and that report has a "daemon" key, so it reached the
success branch and exited 0 while printing "not running". It exits 1 now, as
docs/project.md always said it did.

`status` also connects directly rather than through ensureConnected(): that
helper PRINTS a NO_DAEMON envelope, and letting it do so put two JSON documents
on stdout for one command, which no `jq` invocation survives.

Nothing opts out of the guard. `watch` is the one command with a case for
waiting -- a daemon that has not started yet is a reasonable thing to watch for
-- but it does no waiting today: it connects once and gives up, so failing in
milliseconds is what it already meant to do. The four commands the issue listed
that are NOT covered (`token issue|revoke|list`, `daemon|client config`) never
call ensureConnected at all: they read and write the session's own files and
have no daemon to be absent.

TESTS.
  * CLITest.{Crashed,CleanlyStopped}Session_EveryRpcCommandFailsAtOnce and
    SocketLeftOverWithNoListener_EveryRpcCommandFailsAtOnce: all 17 commands
    against all three shapes, end-to-end, killed at 5s so exit 124 means the
    command was still waiting. Driven against the pre-fix binary via
    $LOGOSCTL_BINARY these fail with 124 on 15 of 17 commands, 80.3s.
  * CLITest.*_StatusReportsNotRunningAtOnce: exit 1, names the pid where there
    is one, and exactly one JSON document.
  * CommandTest.EveryRpcCommand_*: the 17 commands x 4 session shapes, against
    a mock, asserting on connectAttempts/rpcCalls -- a guard that fired is
    visible as the ABSENCE of contact. Three of the four shapes are the
    controls: live pid, foreign instance_id, and no state file at all must
    still dial.
  * LocalEndpointTest.*: the path derivation against QDir::tempPath(), plus a
    verdict for each shape the path can be in -- missing, socket with no
    listener, LIVE listener, and a regular file wearing the name.
  * CommandTest.{ListModules,Stats}_{UnansweredRpc,AnsweredWithNothing}_* and
    Status_{UnansweredRpc,LiveDaemon}_*: both sides of the empty-vs-unanswered
    line. CommandTest had no Status_ coverage at all, which is how exit 0
    survived.

Before/after over the shipped binaries, same stale session, macOS:
  module ls   exit 0 after 22s printing []   ->  exit 2 in <1s, names the pid
  stats       exit 0 after 20s printing []   ->  exit 2 in <1s
  status      exit 0 after 20s               ->  exit 1 in <1s
  call/package/catalog/key  20s, RPC_FAILED  ->  exit 2 in <1s
and against a cleanly stopped session, where nothing was fast before, all of
the above are now under a second too.

Live-daemon behaviour is unchanged and checked: 249 unit + 30 CLI + 25
integration tests pass for logosctl and 20 CLI + 24 integration for logoscore
via `nix build .#checks.<sys>.tests-logosctl` / `-logoscore`. The 25
integration tests drive real daemons through logosctl, so a wrong socket path
would fail them loudly rather than silently refusing live sessions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 11:33:56 -03:00

867 lines
37 KiB
C++

#include <gtest/gtest.h>
#include <chrono>
#include <cstdlib>
#include <cstring>
#include <filesystem>
#include <fstream>
#include <functional>
#include <string>
#include <sys/socket.h>
#include <sys/un.h>
#include <sys/wait.h>
#include <unistd.h>
#include <vector>
#ifdef __APPLE__
#include <mach-o/dyld.h>
#elif defined(__linux__)
#include <unistd.h>
#include <climits>
#endif
namespace fs = std::filesystem;
// Helper function to get the directory of the current executable
static fs::path getExecutableDir() {
#ifdef __APPLE__
char path[PATH_MAX];
uint32_t size = sizeof(path);
if (_NSGetExecutablePath(path, &size) == 0) {
return fs::path(path).parent_path();
}
#elif defined(__linux__)
char path[PATH_MAX];
ssize_t len = readlink("/proc/self/exe", path, sizeof(path) - 1);
if (len != -1) {
path[len] = '\0';
return fs::path(path).parent_path();
}
#endif
return fs::path();
}
class CLITest : public ::testing::Test {
protected:
fs::path logosctlBinary;
void SetUp() override {
// Check for LOGOSCTL_BINARY environment variable first
const char* envBinary = std::getenv("LOGOSCTL_BINARY");
if (envBinary && fs::exists(envBinary)) {
logosctlBinary = envBinary;
return;
}
// Get the directory where the test executable is located
fs::path execDir = getExecutableDir();
// Find the logosctl binary - try multiple locations
std::vector<fs::path> searchPaths;
// First, check in the same directory as the test executable (Nix builds)
if (!execDir.empty()) {
searchPaths.push_back(execDir / "logosctl");
}
// Then try paths relative to current working directory
searchPaths.push_back(fs::current_path() / ".." / "bin" / "logosctl");
searchPaths.push_back(fs::current_path() / "bin" / "logosctl");
searchPaths.push_back(fs::current_path() / ".." / ".." / "bin" / "logosctl");
searchPaths.push_back(fs::current_path().parent_path() / "logosctl");
for (const auto& path : searchPaths) {
if (fs::exists(path)) {
logosctlBinary = fs::canonical(path);
return;
}
}
// Binary not found, skip tests
std::string triedPaths;
for (size_t i = 0; i < searchPaths.size(); ++i) {
if (i > 0) triedPaths += ", ";
triedPaths += "\"" + searchPaths[i].string() + "\"";
}
GTEST_SKIP() << "logosctl binary not found. Set LOGOSCTL_BINARY env var or build the binary first. Tried: "
<< triedPaths;
}
// Helper to run logosctl command
int runLogosctl(const std::string& args, std::string* output = nullptr) {
std::string cmd = logosctlBinary.string() + " " + args;
if (output) {
cmd += " 2>&1";
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) return -1;
char buffer[128];
while (fgets(buffer, sizeof(buffer), pipe)) {
*output += buffer;
}
int status = pclose(pipe);
return WEXITSTATUS(status);
} else {
int status = system(cmd.c_str());
return WEXITSTATUS(status);
}
}
// Helper to run logosctl with timeout (for commands that run event loop)
int runLogosctlWithTimeout(const std::string& args, std::string* output, int timeoutSecs = 2) {
std::string cmd = "timeout " + std::to_string(timeoutSecs) + " " + logosctlBinary.string() + " " + args + " 2>&1";
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) return -1;
char buffer[128];
while (fgets(buffer, sizeof(buffer), pipe)) {
*output += buffer;
}
int status = pclose(pipe);
return WEXITSTATUS(status);
}
};
// ═════════════════════════════════════════════════════════════════════════════
// Help and version tests
// ═════════════════════════════════════════════════════════════════════════════
TEST_F(CLITest, HelpCommand) {
std::string output;
int exitCode = runLogosctl("--help", &output);
EXPECT_EQ(exitCode, 0);
// New help text includes subcommands
EXPECT_NE(output.find("logosctl"), std::string::npos) << "Help should contain app name";
EXPECT_NE(output.find("status"), std::string::npos) << "Help should list status command";
// Help lists the groups, not the internal hyphenated dispatch tokens.
EXPECT_NE(output.find("module"), std::string::npos) << "Help should list the module group";
EXPECT_NE(output.find("package"), std::string::npos) << "Help should list the package group";
EXPECT_NE(output.find("catalog"), std::string::npos) << "Help should list the catalog group";
EXPECT_EQ(output.find("load-module"), std::string::npos)
<< "load-module is an internal dispatch token and must stay out of help";
EXPECT_NE(output.find("call"), std::string::npos) << "Help should list call command";
EXPECT_NE(output.find("watch"), std::string::npos) << "Help should list watch command";
EXPECT_NE(output.find("--json"), std::string::npos) << "Help should document --json flag";
}
TEST_F(CLITest, HelpShortFlag) {
std::string output;
int exitCode = runLogosctl("-h", &output);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("logosctl"), std::string::npos);
}
TEST_F(CLITest, VersionCommand) {
std::string output;
int exitCode = runLogosctl("--version", &output);
EXPECT_EQ(exitCode, 0);
// The version string is build-derived (release version / pre-release sha /
// "dev"), so assert on the stable tool-name prefix rather than a literal
// version number.
EXPECT_NE(output.find("logosctl version"), std::string::npos) << "Version output should identify logosctl";
}
TEST_F(CLITest, NoArgs_ShowsHelp) {
std::string output;
int exitCode = runLogosctl("", &output);
EXPECT_EQ(exitCode, 0);
EXPECT_NE(output.find("logosctl"), std::string::npos) << "No args should show help";
}
// ═════════════════════════════════════════════════════════════════════════════
// Client commands without daemon (should fail gracefully)
// ═════════════════════════════════════════════════════════════════════════════
TEST_F(CLITest, Status_NoDaemon) {
std::string output;
int exitCode = runLogosctl("status --json", &output);
// Should report not_running (exit 1) or connection error (exit 2)
EXPECT_NE(exitCode, 0);
}
TEST_F(CLITest, ListModules_NoDaemon) {
std::string output;
int exitCode = runLogosctl("list-modules --json", &output);
EXPECT_NE(exitCode, 0);
}
TEST_F(CLITest, LoadModule_NoDaemon) {
std::string output;
int exitCode = runLogosctl("load-module waku --json", &output);
EXPECT_NE(exitCode, 0);
}
TEST_F(CLITest, ModuleInfo_NoDaemon) {
std::string output;
int exitCode = runLogosctl("module-info chat --json", &output);
EXPECT_NE(exitCode, 0);
}
TEST_F(CLITest, Stats_NoDaemon) {
std::string output;
int exitCode = runLogosctl("stats --json", &output);
EXPECT_NE(exitCode, 0);
}
// ═════════════════════════════════════════════════════════════════════════════
// Timing tests — client commands must return quickly (catches RPC hangs)
// If the RPC layer has a misconfigured token key or missing timeout,
// commands hang for 20+ seconds waiting for capability_module negotiation.
// ═════════════════════════════════════════════════════════════════════════════
TEST_F(CLITest, Status_NoDaemon_ReturnsFast) {
auto start = std::chrono::steady_clock::now();
std::string output;
int exitCode = runLogosctl("status --json", &output);
auto elapsed = std::chrono::steady_clock::now() - start;
auto secs = std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
EXPECT_LE(secs, 5) << "status should return within 5 seconds (took " << secs << "s). "
<< "Likely an RPC timeout or token key misconfiguration.";
EXPECT_NE(exitCode, 0);
}
TEST_F(CLITest, LoadModule_NoDaemon_ReturnsFast) {
auto start = std::chrono::steady_clock::now();
std::string output;
int exitCode = runLogosctl("load-module test --json", &output);
auto elapsed = std::chrono::steady_clock::now() - start;
auto secs = std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
EXPECT_LE(secs, 5) << "load-module should return within 5 seconds (took " << secs << "s).";
EXPECT_NE(exitCode, 0);
}
TEST_F(CLITest, Stop_NoDaemon_ReturnsFast) {
auto start = std::chrono::steady_clock::now();
std::string output;
int exitCode = runLogosctl("stop --json", &output);
auto elapsed = std::chrono::steady_clock::now() - start;
auto secs = std::chrono::duration_cast<std::chrono::seconds>(elapsed).count();
EXPECT_LE(secs, 5) << "stop should return within 5 seconds (took " << secs << "s).";
EXPECT_NE(exitCode, 0);
}
// ═════════════════════════════════════════════════════════════════════════════
// A session whose daemon is gone
//
// The cases above run against a machine with no session at all, where the
// client gives up before it dials and any command looks fast. The slow case is
// the one an operator actually hits: a session directory left behind by a
// daemon that is no longer there. The dial spec is still present and still
// "works" -- a LocalSocket client connects to a path with no listener without
// complaint -- so the request goes out, nothing answers, QtRO reports nothing,
// and the command waits out Timeout(20000) (logos-protocol, cpp/logos_mode.h).
//
// It comes in two shapes, and they need different evidence:
//
// Crashed daemon/state.json is still there, naming a dead pid.
// Caught by detectStaleSession() in Command::ensureConnected.
//
// CleanlyStopped daemon/state.json was REMOVED and the socket unlinked, but
// client/config.yaml and the token remain. There is no pid to
// check, so the pid guard has nothing to say; what settles it
// is that the socket the dial resolves to is not there.
//
// SocketLeftOver the socket file is still on disk with nobody behind it. A
// hard-killed daemon leaves this, and so does a clean stop
// for the window between its shutdown reply and QLocalServer's
// destructor -- which is exactly when the next command gets
// typed. Presence of the file settles nothing; being REFUSED
// by it does.
//
// Both must fail at once. These are end-to-end on purpose: the unit tests pin
// the logic against mocks, but only a real binary against a real session
// directory can show that the twenty seconds are actually gone.
// ═════════════════════════════════════════════════════════════════════════════
namespace {
// Beyond every platform's pid ceiling (macOS 99998, Linux's default 4194304),
// so it cannot collide with some unrelated process that happens to be running.
constexpr const char* kGonePid = "2147483646";
// An instance id no live daemon on this machine can be using, which is what
// makes the socket check meaningful without having to control $TMPDIR: the
// endpoint `logos_core_service_<this>` cannot exist.
constexpr const char* kDeadInstance = "deadbeef1234";
// Everything a client needs in order to try, and nothing at the other end.
struct DeadSessionFixture {
enum Shape { Crashed, CleanlyStopped, SocketLeftOver };
fs::path dir;
// Non-empty only for SocketLeftOver: a private, SHORT $TMPDIR to hold the
// dead socket. Short because sockaddr_un::sun_path is 104 bytes on macOS
// and the platform temp dir alone eats half of that. Both the daemon and
// the client resolve a bare socket name against $TMPDIR, so handing the
// command this one is what puts the file where its dial will look.
fs::path socketDir;
bool socketStaged = false;
DeadSessionFixture(const std::string& name, Shape shape)
{
dir = fs::temp_directory_path() /
("logosctl_cli_" + name + "_" + std::to_string(::getpid()));
fs::remove_all(dir);
fs::create_directories(dir / "client");
fs::create_directories(dir / "daemon");
// JSON, which is valid YAML, so the same text serves both readers.
put(dir / "client" / "config.yaml",
std::string(R"({"version":2,"token_file":"auto.json","instance_id":")")
+ kDeadInstance
+ R"(","daemon":{"core_service":{"transport":"local"}}})");
put(dir / "client" / "auto.json", R"({"token":"0123456789abcdef"})");
// A clean shutdown removes this file. A crash leaves it behind.
if (shape == Crashed)
put(dir / "daemon" / "state.json",
std::string(R"({"version":2,"instance_id":")") + kDeadInstance
+ R"(","pid":)" + kGonePid
+ R"(,"started_at":"2026-01-01T00:00:00Z"})");
if (shape == SocketLeftOver) {
socketDir = fs::path("/tmp") / ("lgx" + std::to_string(::getpid()));
fs::remove_all(socketDir);
fs::create_directories(socketDir);
socketStaged = bindThenAbandon(
socketDir / (std::string("logos_core_service_") + kDeadInstance));
}
}
~DeadSessionFixture()
{
fs::remove_all(dir);
if (!socketDir.empty()) fs::remove_all(socketDir);
}
// What the command line needs in front of it to make this session's socket
// the one the dial resolves to. Empty for the shapes that do not stage one.
std::string env() const
{
return socketDir.empty() ? std::string{}
: "TMPDIR='" + socketDir.string() + "' ";
}
// Bind and listen, then close the listener WITHOUT unlinking -- leaving an
// inode that exists and refuses. False if the path would not fit in
// sun_path, which the caller reports as a skip rather than a failure.
static bool bindThenAbandon(const fs::path& path)
{
const std::string p = path.string();
sockaddr_un addr{};
if (p.size() >= sizeof(addr.sun_path)) return false;
addr.sun_family = AF_UNIX;
std::memcpy(addr.sun_path, p.c_str(), p.size());
const int fd = ::socket(AF_UNIX, SOCK_STREAM, 0);
if (fd < 0) return false;
::unlink(p.c_str());
if (::bind(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)) != 0
|| ::listen(fd, 4) != 0) {
::close(fd);
return false;
}
::close(fd); // listener gone; the inode stays
return true;
}
static void put(const fs::path& path, const std::string& body)
{
std::ofstream ofs(path, std::ios::trunc);
ofs << body << "\n";
}
};
// Every command whose first act is an RPC, in the spelling a person types.
const std::vector<std::string>& rpcCommands()
{
static const std::vector<std::string> kCommands{
"module ls",
"module load chat",
"module show chat",
"module reload chat",
"module unload chat",
"call chat send hi",
"stats",
"stop",
"watch chat",
"package ls",
"package show chat",
"package deps chat",
"package search chat",
"package download chat",
"package install chat",
"catalog ls",
"key ls",
};
return kCommands;
}
} // namespace
// The shared body. `run` is bound by the test to the fixture's own helper
// (which is protected, so a free function cannot reach it). `expectPid` is
// false for the cleanly-stopped shape, where there is no pid on disk to name
// -- the message points at the missing socket instead.
using LogosctlRunner = std::function<int(const std::string&, std::string*)>;
static void expectEveryRpcCommandFailsAtOnce(const LogosctlRunner& run,
const DeadSessionFixture& fx,
bool expectPid);
TEST_F(CLITest, CrashedSession_EveryRpcCommandFailsAtOnce) {
DeadSessionFixture fx("crashedsession", DeadSessionFixture::Crashed);
expectEveryRpcCommandFailsAtOnce(
[this](const std::string& a, std::string* o) {
return runLogosctlWithTimeout(a, o, 5);
},
fx, /*expectPid=*/true);
}
// The tidier way to end up here, and the one the pid guard cannot see: a
// normal `daemon stop` takes daemon/state.json and the socket with it and
// leaves the dial spec behind.
TEST_F(CLITest, CleanlyStoppedSession_EveryRpcCommandFailsAtOnce) {
DeadSessionFixture fx("cleanstop", DeadSessionFixture::CleanlyStopped);
expectEveryRpcCommandFailsAtOnce(
[this](const std::string& a, std::string* o) {
return runLogosctlWithTimeout(a, o, 5);
},
fx, /*expectPid=*/false);
}
// The window a stat cannot see through: the socket file is still on disk, so
// "is it there?" says yes, and only being REFUSED by it settles the question.
TEST_F(CLITest, SocketLeftOverWithNoListener_EveryRpcCommandFailsAtOnce) {
DeadSessionFixture fx("socketleft", DeadSessionFixture::SocketLeftOver);
if (!fx.socketStaged)
GTEST_SKIP() << "could not bind a short-enough socket path under "
<< fx.socketDir;
// $TMPDIR is how the staged socket becomes the one the dial resolves to;
// the fixture's env() carries it, so this runner cannot go through
// runLogosctlWithTimeout (which has nowhere to put it).
const fs::path bin = logosctlBinary;
const std::string env = fx.env();
expectEveryRpcCommandFailsAtOnce(
[&bin, &env](const std::string& a, std::string* o) -> int {
const std::string cmd = env + "timeout 5 " + bin.string() + " " + a + " 2>&1";
FILE* pipe = popen(cmd.c_str(), "r");
if (!pipe) return -1;
char buf[256];
while (fgets(buf, sizeof(buf), pipe)) *o += buf;
// An lvalue: WEXITSTATUS takes the address of its argument.
int status = pclose(pipe);
return WEXITSTATUS(status);
},
fx, /*expectPid=*/false);
}
// `status` asks the same question and answers it as a status report rather
// than an error, so it exits 1 and describes the session instead.
TEST_F(CLITest, CrashedSession_StatusReportsNotRunningAtOnce) {
DeadSessionFixture fx("crashedstatus", DeadSessionFixture::Crashed);
std::string output;
int exitCode = runLogosctlWithTimeout(
"--config-dir " + fx.dir.string() + " status --json", &output, 5);
EXPECT_EQ(exitCode, 1) << "Output:\n" << output;
EXPECT_NE(output.find("not_running"), std::string::npos) << "Output:\n" << output;
EXPECT_NE(output.find(kGonePid), std::string::npos)
<< "the report must name the pid that is gone. Output:\n" << output;
}
TEST_F(CLITest, CleanlyStoppedSession_StatusReportsNotRunningAtOnce) {
DeadSessionFixture fx("cleanstopstatus", DeadSessionFixture::CleanlyStopped);
std::string output;
int exitCode = runLogosctlWithTimeout(
"--config-dir " + fx.dir.string() + " status --json", &output, 5);
// Exit 1, not 0. This reported "not_running" and exited 0 -- the text was
// right and the exit code said the daemon was fine.
EXPECT_EQ(exitCode, 1)
<< (exitCode == 124 ? "still running after 5s. " : "")
<< "Output:\n" << output;
EXPECT_NE(output.find("not_running"), std::string::npos) << "Output:\n" << output;
// ONE document. `status` formats its own "not running" report, so it must
// not also let the connect helper print an error envelope -- two JSON
// objects on stdout is not something a caller can parse.
int lines = 0;
for (std::size_t i = 0, n = 0; (n = output.find('\n', i)) != std::string::npos; i = n + 1)
if (n > i) ++lines;
EXPECT_EQ(lines, 1) << "expected a single JSON document. Output:\n" << output;
}
static void expectEveryRpcCommandFailsAtOnce(const LogosctlRunner& run,
const DeadSessionFixture& fx,
bool expectPid)
{
for (const std::string& c : rpcCommands()) {
SCOPED_TRACE(c);
std::string output;
// The 5s kill is the assertion's teeth: exit 124 means the command was
// still waiting for a reply that is never coming.
int exitCode = run("--config-dir " + fx.dir.string() + " " + c + " --json",
&output);
EXPECT_EQ(exitCode, 2)
<< (exitCode == 124
? "still running after 5s -- it is sitting out the RPC "
"deadline against a daemon that is already gone. "
: exitCode == 0
? "exited 0 -- it reported a failed RPC as data. "
: "")
<< "Output:\n" << output;
EXPECT_NE(output.find("NO_DAEMON"), std::string::npos)
<< "Output:\n" << output;
if (expectPid) {
EXPECT_NE(output.find(kGonePid), std::string::npos)
<< "the error must name the pid, or there is nothing to act "
"on. Output:\n" << output;
}
}
}
// ═════════════════════════════════════════════════════════════════════════════
// Configuration moved from flags to the session's YAML documents.
//
// The old per-flag surface (-m/--modules-dir, --persistence-path,
// --module-transport, --insecure-tcp, --access-policy, --access-group,
// --persist-config, and the seven --client-* dial flags) is gone. What those
// flags used to validate is now validated when the document is installed, so
// these tests moved with it: a bad value is rejected by `daemon config set` /
// `client config set` rather than at parse time.
// ═════════════════════════════════════════════════════════════════════════════
TEST_F(CLITest, Help_ShowsOnlyTheSurvivingGlobalFlags) {
std::string output;
int exitCode = runLogosctl("--help", &output);
EXPECT_EQ(exitCode, 0);
// --config-dir survives because it selects *which* session to act on, so
// it cannot itself live inside one.
EXPECT_NE(output.find("--config-dir"), std::string::npos)
<< "Output:\n" << output;
for (const char* gone : {"--modules-dir", "--persistence-path",
"--module-transport", "--insecure-tcp",
"--access-policy", "--access-group",
"--persist-config", "--client-transport",
"--client-codec", "--token-file", "--ssl-ca"}) {
EXPECT_EQ(output.find(gone), std::string::npos)
<< gone << " should no longer exist as a flag. Output:\n" << output;
}
}
TEST_F(CLITest, RemovedFlags_AreRejectedNotIgnored) {
// Silently accepting a flag that no longer does anything would leave the
// operator's intent unapplied with nothing to explain it.
std::string output;
int exitCode = runLogosctlWithTimeout("-D --modules-dir /tmp/x", &output, 5);
EXPECT_EQ(exitCode, 109)
<< "A removed flag must be a parse error, not ignored (and must not "
"start a daemon -- 124 would mean it did). Output:\n" << output;
}
TEST_F(CLITest, DaemonConfigSet_RejectsMalformedYaml) {
const fs::path cfgDir = fs::temp_directory_path() /
("logosctl_cli_badyaml_" + std::to_string(::getpid()));
fs::create_directories(cfgDir);
const fs::path doc = cfgDir / "bad.yaml";
{ std::ofstream ofs(doc, std::ios::trunc); ofs << "modules:\n - [unclosed\n"; }
std::string output;
int exitCode = runLogosctlWithTimeout(
"--config-dir " + cfgDir.string() + " daemon config set " + doc.string(),
&output, 5);
EXPECT_EQ(exitCode, 1) << "Output:\n" << output;
// The existing config must survive a rejected document.
EXPECT_FALSE(fs::exists(cfgDir / "daemon" / "config.yaml"))
<< "A malformed document must not be written.";
fs::remove_all(cfgDir);
}
TEST_F(CLITest, DaemonConfigSet_RejectsUnknownKeys) {
// `insecureTcp` is a near-miss for `insecure_tcp`. The loader ignores
// unrecognised keys, so without this check the daemon would boot with the
// operator's intent silently dropped.
const fs::path cfgDir = fs::temp_directory_path() /
("logosctl_cli_badkey_" + std::to_string(::getpid()));
fs::create_directories(cfgDir);
const fs::path doc = cfgDir / "typo.yaml";
{ std::ofstream ofs(doc, std::ios::trunc); ofs << "insecureTcp: true\n"; }
std::string output;
int exitCode = runLogosctlWithTimeout(
"--config-dir " + cfgDir.string() + " daemon config set " + doc.string(),
&output, 5);
EXPECT_EQ(exitCode, 1) << "Output:\n" << output;
EXPECT_NE(output.find("insecure_tcp"), std::string::npos)
<< "The error should name the correct spelling. Output:\n" << output;
fs::remove_all(cfgDir);
}
TEST_F(CLITest, DaemonConfigSet_RoundTripsThroughShow) {
const fs::path cfgDir = fs::temp_directory_path() /
("logosctl_cli_rt_" + std::to_string(::getpid()));
fs::create_directories(cfgDir);
const fs::path doc = cfgDir / "node.yaml";
{
std::ofstream ofs(doc, std::ios::trunc);
ofs << "insecure_tcp: true\n"
"modules:\n"
" core_service:\n"
" - protocol: tcp\n"
" host: 127.0.0.1\n"
" port: 8645\n";
}
std::string output;
int exitCode = runLogosctlWithTimeout(
"--config-dir " + cfgDir.string() + " daemon config set " + doc.string(),
&output, 5);
ASSERT_EQ(exitCode, 0) << "Output:\n" << output;
std::string shown;
exitCode = runLogosctlWithTimeout(
"--config-dir " + cfgDir.string() + " daemon config show --human", &shown, 5);
EXPECT_EQ(exitCode, 0) << "Output:\n" << shown;
EXPECT_NE(shown.find("8645"), std::string::npos) << "Output:\n" << shown;
EXPECT_NE(shown.find("insecure_tcp"), std::string::npos) << "Output:\n" << shown;
fs::remove_all(cfgDir);
}
TEST_F(CLITest, DaemonConfigSet_AcceptsEverySignaturePolicyValue) {
for (const char* policy : {"none", "warn", "require"}) {
const fs::path cfgDir = fs::temp_directory_path() /
("logosctl_cli_sigok_" + std::string(policy) + "_" +
std::to_string(::getpid()));
fs::remove_all(cfgDir);
fs::create_directories(cfgDir);
const fs::path doc = cfgDir / "node.yaml";
{ std::ofstream ofs(doc, std::ios::trunc);
ofs << "signature_policy: " << policy << "\n"; }
std::string output;
int exitCode = runLogosctlWithTimeout(
"--config-dir " + cfgDir.string() + " daemon config set " + doc.string(),
&output, 5);
EXPECT_EQ(exitCode, 0) << policy << " Output:\n" << output;
std::string shown;
exitCode = runLogosctlWithTimeout(
"--config-dir " + cfgDir.string() + " daemon config show --human",
&shown, 5);
EXPECT_EQ(exitCode, 0) << "Output:\n" << shown;
EXPECT_NE(shown.find(policy), std::string::npos) << "Output:\n" << shown;
fs::remove_all(cfgDir);
}
}
TEST_F(CLITest, DaemonConfigSet_RejectsUnknownSignaturePolicy) {
// package_manager ignores a policy it does not recognise, so `required`
// (the key takes `require`) would leave it on the default `warn` while
// `daemon config show` kept reporting the operator's stricter intent.
const fs::path cfgDir = fs::temp_directory_path() /
("logosctl_cli_sigbad_" + std::to_string(::getpid()));
fs::remove_all(cfgDir);
fs::create_directories(cfgDir);
const fs::path doc = cfgDir / "node.yaml";
{ std::ofstream ofs(doc, std::ios::trunc); ofs << "signature_policy: required\n"; }
std::string output;
int exitCode = runLogosctlWithTimeout(
"--config-dir " + cfgDir.string() + " daemon config set " + doc.string(),
&output, 5);
EXPECT_EQ(exitCode, 1) << "Output:\n" << output;
EXPECT_NE(output.find("require"), std::string::npos)
<< "The error should name the accepted values. Output:\n" << output;
fs::remove_all(cfgDir);
}
TEST_F(CLITest, DaemonStart_RefusesTlsListenerWithNoCertificate) {
// A tcp_ssl listener with no material binds fine and then fails every
// handshake with "no shared cipher", which reads like a client fault.
const fs::path cfgDir = fs::temp_directory_path() /
("logosctl_cli_nocert_" + std::to_string(::getpid()));
fs::remove_all(cfgDir);
fs::create_directories(cfgDir);
const fs::path doc = cfgDir / "node.yaml";
{
std::ofstream ofs(doc, std::ios::trunc);
ofs << "modules:\n"
" core_service:\n"
" - protocol: tcp_ssl\n"
" host: 127.0.0.1\n"
" port: 8645\n";
}
std::string output;
ASSERT_EQ(runLogosctlWithTimeout(
"--config-dir " + cfgDir.string() + " daemon config set " + doc.string(),
&output, 5), 0) << "Output:\n" << output;
output.clear();
// 124 would mean a daemon actually started on a certificate-less listener.
int exitCode = runLogosctlWithTimeout(
"--config-dir " + cfgDir.string() + " -D", &output, 15);
EXPECT_EQ(exitCode, 1) << "Output:\n" << output;
EXPECT_NE(output.find("core_service"), std::string::npos)
<< "The error should name the listener. Output:\n" << output;
EXPECT_NE(output.find("ssl:"), std::string::npos)
<< "The error should name the top-level block as one of the two places "
"the material can come from. Output:\n" << output;
fs::remove_all(cfgDir);
}
TEST_F(CLITest, DaemonConfigShow_AbsentIsNotAnError) {
// A session with no config runs on defaults; that is a normal state.
const fs::path cfgDir = fs::temp_directory_path() /
("logosctl_cli_absent_" + std::to_string(::getpid()));
fs::create_directories(cfgDir);
std::string output;
int exitCode = runLogosctlWithTimeout(
"--config-dir " + cfgDir.string() + " daemon config show --human", &output, 5);
EXPECT_EQ(exitCode, 0) << "Output:\n" << output;
fs::remove_all(cfgDir);
}
// ═════════════════════════════════════════════════════════════════════════════
// A malformed document must be an error, never a crash, and never a write.
//
// The reader used nlohmann's `json::value(key, default)`, which throws when the
// key is present with a different type than the default. Nothing caught it, so
// `modules_dirs: /single/path` — a scalar where a list belongs — terminated the
// binary:
//
// libc++abi: terminating due to uncaught exception of type
// nlohmann::detail::type_error: [json.exception.type_error.302]
// type must be array, but is string
//
// A process killed by SIGABRT surfaces here as exit 134, so asserting exit 1
// is what distinguishes "reported it" from "died on it".
// ═════════════════════════════════════════════════════════════════════════════
namespace {
// Per-test config dir plus a document to feed `config set`. Named after the
// test so parallel cases never share one.
struct ConfigFixture {
fs::path dir;
fs::path doc;
ConfigFixture(const std::string& name, const std::string& body)
{
dir = fs::temp_directory_path() /
("logosctl_cli_" + name + "_" + std::to_string(::getpid()));
fs::remove_all(dir);
fs::create_directories(dir);
doc = dir / "doc.yaml";
std::ofstream ofs(doc, std::ios::trunc);
ofs << body;
}
~ConfigFixture() { fs::remove_all(dir); }
fs::path daemonConfig() const { return dir / "daemon" / "config.yaml"; }
fs::path clientConfig() const { return dir / "client" / "config.yaml"; }
};
} // namespace
TEST_F(CLITest, DaemonConfigSet_TypeMismatchIsReportedNotFatal) {
ConfigFixture fx("typemismatch", "modules_dirs: /single/path\n");
std::string output;
int exitCode = runLogosctlWithTimeout(
"--config-dir " + fx.dir.string() + " daemon config set " + fx.doc.string(),
&output, 5);
EXPECT_EQ(exitCode, 1)
<< "A mistyped value must be reported (exit 1), not abort the process "
"(exit 134). Output:\n" << output;
EXPECT_NE(output.find("modules_dirs"), std::string::npos)
<< "The error must name the offending key. Output:\n" << output;
EXPECT_EQ(output.find("terminating due to uncaught exception"), std::string::npos)
<< "Output:\n" << output;
EXPECT_FALSE(fs::exists(fx.daemonConfig()))
<< "A document that cannot be understood must not be written.";
}
TEST_F(CLITest, DaemonConfigSet_SchemaInvalidDocumentIsNotWritten) {
// This document passes the YAML parse and the key allowlist, and fails
// only the schema. Validation used to run AFTER the write, so the command
// exited 1 having already installed a config the daemon would refuse to
// boot from.
ConfigFixture fx("schemainvalid",
"modules:\n"
" core_service:\n"
" - protocol: tcpp\n"
" host: 127.0.0.1\n"
" port: 8645\n");
std::string output;
int exitCode = runLogosctlWithTimeout(
"--config-dir " + fx.dir.string() + " daemon config set " + fx.doc.string(),
&output, 5);
EXPECT_EQ(exitCode, 1) << "Output:\n" << output;
EXPECT_FALSE(fs::exists(fx.daemonConfig()))
<< "A rejected document must not be left on disk — the session would "
"be holding a config the daemon cannot boot from.";
EXPECT_NE(output.find("protocol"), std::string::npos)
<< "The error must name the offending key. Output:\n" << output;
}
TEST_F(CLITest, DaemonConfigSet_RejectionLeavesThePreviousConfigIntact) {
ConfigFixture fx("preserve", "insecure_tcp: true\n");
std::string output;
ASSERT_EQ(runLogosctlWithTimeout(
"--config-dir " + fx.dir.string() + " daemon config set " + fx.doc.string(),
&output, 5), 0) << "Output:\n" << output;
ASSERT_TRUE(fs::exists(fx.daemonConfig()));
const fs::path bad = fx.dir / "bad.yaml";
{ std::ofstream ofs(bad, std::ios::trunc); ofs << "modules_dirs: /single/path\n"; }
output.clear();
EXPECT_EQ(runLogosctlWithTimeout(
"--config-dir " + fx.dir.string() + " daemon config set " + bad.string(),
&output, 5), 1) << "Output:\n" << output;
std::ifstream ifs(fx.daemonConfig());
const std::string body((std::istreambuf_iterator<char>(ifs)),
std::istreambuf_iterator<char>());
EXPECT_NE(body.find("insecure_tcp"), std::string::npos)
<< "The accepted config must survive a rejected one. It now reads:\n" << body;
EXPECT_EQ(body.find("modules_dirs"), std::string::npos)
<< "The rejected document must not have been applied. It now reads:\n" << body;
}
TEST_F(CLITest, ClientConfigSet_TypeMismatchIsReportedNotFatal) {
// The client half of the same hazard, and the same guarantee.
ConfigFixture fx("clienttype",
"token_file: auto.json\n"
"daemon:\n"
" core_service:\n"
" transport: tcp\n"
" host: 127.0.0.1\n"
" port: \"6001\"\n");
std::string output;
int exitCode = runLogosctlWithTimeout(
"--config-dir " + fx.dir.string() + " client config set " + fx.doc.string(),
&output, 5);
EXPECT_EQ(exitCode, 1)
<< "A mistyped value must be reported (exit 1), not abort the process "
"(exit 134). Output:\n" << output;
EXPECT_NE(output.find("port"), std::string::npos)
<< "The error must name the offending key. Output:\n" << output;
EXPECT_FALSE(fs::exists(fx.clientConfig()))
<< "A document that cannot be understood must not be written.";
}