mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-28 07:03:20 +08:00
Daemon lifecycle and Windows correctness, verified on a real Windows 11 ARM64 VM through the maintained test-infrastructure/vm drivers, plus the macOS and Linux arm64 suites and the container lint gate. Daemon lifecycle: - daemon start/stop/status subcommands. `daemon start` launches a PERMANENT daemon (spawn shape is byte-exact argv; survives idle periods and session ends) and reports an already-active daemon instead of failing. Permanence is honored at every stop latch: last-committed-client disconnect, host initial-client window, coordinator release, and application final-session close — a permanent daemon also keeps admitting new sessions after its last one closes. - daemon stop refuses while sessions are active and lists the blocking peers (pid/role) that must finish first; an idle daemon drains through the activation-shutdown machinery with the ACK ordered after connection interrupts. A second stop is idempotent. The wire ops are no-cohort first-frame requests with peer fingerprint authentication, so stop/status never conflict with an exact-build admission gate. - One-shot CLI commands now execute through the daemon (index workers keep their local supervised path). A cold CLI run that had to spawn a temporary daemon prints a hint that `daemon start` removes the per-command startup tax; a warm daemon is recycled silently. - Hooks are connect-only fail-open: with no daemon present the hook emits a visible, rate-limited notice (Claude-dialect systemMessage plus stderr for other dialects) and always exits 0 — augmentation is never allowed to block the caller's tool use. - Version skew: a newer-build client automatically drains an older-build permanent daemon (strict semantic-version triples only; dev builds never auto-drain) and the build-conflict message names `cbm daemon stop` as the manual escape hatch. Windows IPC/runtime (real-VM verified): - ipc(win): persistent pending overlapped ConnectNamedPipe. The accept path used to destroy its listening pipe instance on every 20 ms poll timeout; a client attaching in the teardown window was severed or left on an orphaned pipe object whose HELLO no server handle could ever read, absorbing the connect until the client's own timeout expired. The pending connect now survives poll timeouts and nothing is destroyed while a client could be attaching. - ipc(win): drain-before-close for final responses. Closing a named-pipe server handle can discard a just-sent response before the peer reads it (POSIX stream sockets never lose buffered data on close). A bounded cbm_daemon_ipc_connection_drain (read-until-peer-EOF; no-op on POSIX, immediate on interrupted connections) now precedes close in runtime_worker_finish and runtime_reject_inline, so hello-conflict, capacity and disconnect acknowledgements reliably reach the peer. - runtime: CLOSE_INTENT wire frame. A Windows named-pipe client has no transport half-close, so close_begin now announces departure with an explicit frame (ordered after APPLICATION_CANCEL, before the local interrupt); the server releases the client's admission on receipt instead of waiting for the handle to close. Admission-drop timing is now identical to POSIX shutdown() semantics on every platform. - runtime(win): client close cancellation. close_begin serializes with request publication under the send lock, best-effort sends the active token's APPLICATION_CANCEL frame, then interrupts local I/O; the server cancels MCP/subprocess work promptly. Contract tests accept both correct outcomes (interrupted transport or decoded CANCELLED). - runtime: activation acknowledgement ordering. The activation ACK is the requester's license to act on "snapshotted and draining", so every connection interrupt is now initiated before the ACK is sent; a session could previously get one more request serviced after the requester observed the ACK. - service(win): deadline-bounded private-file prepare. The conflict-log prepare retry loop (100 x Sleep(2), which rounds up to the ~16 ms timer granularity) burned ~1.6 s against permanently obstructed paths, stalling hello rejections past the client's timeout. The retry budget is now a 250 ms deadline; transient share collisions still retry. - subprocess(win): cmd.exe /C payload encoder quotes metacharacters correctly (root cause of the git-on-Windows failure cluster). - watcher: SHA-256 buffer sizing (CBM_SZ_64 -> CBM_SZ_128) and a native Windows stop/unwatch cancellation test with exact-image verification. - httpd: send_all writes in bounded 64 KiB slices. A single giant nonblocking send() on Windows is absorbed wholesale into AFD kernel buffering regardless of SO_SNDBUF, so send deadlines and interrupts could never engage against a slow peer (and the full payload was pinned in nonpaged pool). Slicing restores a deterministic backpressure point; a test hook pins SO_SNDBUF for the deadline and interrupt tests. - ui/http: shutdown lifecycle — interrupt checks, response-wide send deadline, explicit connection states, refusal to free a server while a listener-owned connection is active. Windows long-path support: - Central path-aware wide conversion (canonicalize via GetFullPathNameW and prepend the extended-length prefix for absolute paths >=240) at the compat chokepoints (cbm_fopen/compat_fs/mkstemp/mkdtemp), sqlite store opens, and the daemon build-fingerprint/log paths. Deep managed installs (a 64-hex generation directory routinely exceeds MAX_PATH) now index, stage and activate correctly. - activation transaction: its own file APIs and the component-walking ancestry validators now operate in the extended-length namespace; the launcher path is canonicalized (and prefixed when deep) once at entry so every downstream exact-string comparison stays form-consistent. - Executable self-resolution uses the wide APIs (GetModuleFileNameW, GetFileAttributesW) so non-ASCII install paths survive argv[0] resolution. Windows launcher install/uninstall transaction: - FileRenameInfoEx names are NUL-terminated in an over-allocated buffer. FileNameLength governs per the contract, but filter drivers read FileName as NUL-terminated and appended adjacent heap bytes to created names — a flaky, garbage-suffixed rename target. Both the CLI and the launcher rename helpers are fixed. - Uninstall retires state via rename-aside (.cbm -> .cbm-retired-v1-<tag>-<pid>) with the retired tag shortened to 16 hex chars so the bare rename target stays under the FileRenameInfoEx NT-conversion ceiling at guard depths; 64 bits still uniquely identify the generation. - When the running launcher's mapped generation backings pin .cbm against rename, the backings are relocated to activation-<pid>-N .retired tombstones beside the install (a mapped image may be renamed, never deleted; the launcher's liveness-guarded sweep reclaims stale tombstones). Every relocation is recorded, and a FAILED uninstall reverses the moves after restoring .cbm — via MoveFileExW with extended-length paths on both arguments, since the deep generation target is beyond the handle-based rename's bare-path reach — so a restored install keeps its generation backings and stays runnable. - After a committed uninstall the retired tree's backings are relocated out so the tree is shallow enough for the detached cleanup's rd, and the cleanup's working directory strips the extended-length prefix (CreateProcessW lpCurrentDirectory silently ignores prefixed paths). - Files created under Administrators-default-owner directories (CopyFileW destinations, CREATE_NEW tombstones, probe directories) are explicitly owner-stamped so the exact-owner validators hold on runner images; guard fixtures stamp hand-built trees the same way. Diagnostics, tests and infra: - diagnostics: discovery is now an always-delivered JSON control record (new cbm_log_control) that survives CBM_LOG_LEVEL suppression and paths containing spaces; placement honors $TMPDIR with /tmp fallback via a diagnostics-local helper; the soak parser reads the JSON record; documented in docs/CONFIGURATION.md. Red-first coverage for suppressed log levels, TMPDIR-with-spaces, and native Windows output-contract assertions. - tests(win): daemon_ipc/daemon_frontend fixtures now build endpoint parents with production-shaped ancestry (LocalAppData on Windows, via th_secure_runtime_parent_new) — the runtime ancestry validation correctly refuses temp roots whose ancestors grant mutation rights to Authenticated Users (C:/msys64/tmp, GitHub-runner work dirs) — and drive the documented startup-owner publication flow before reading generation-bound endpoint addresses. This turns the 26 Windows failures previously visible in CI's full-test job green without weakening any validation. - tests(win): the launcher guard covers the full permanent-launcher contract including failed-uninstall restore and immediate reinstall after uninstall; new daemon lifecycle and reworked hook-augment guards run the start/recycle/stop flow end to end. - tests: CBM_SKIP_PERF is now actually consumed by the test runner (it was set by CI but never read, so perf suites ran everywhere); four throughput/bench suites are classified as perf, the heavy store_arch suite moved to the slow-timeout tier, and two wall-clock-sensitive assertions were rewritten as invariant checks with coarse hang-detector backstops. - build/test infra: build-dir safety contract, UI dev-proxy security contract, soak daemon-recovery contract, path-safety helper, the Windows VM worktree-sync contract wired into scripts/test.sh, and vm/win.sh guards building its clean embedded-UI product in an isolated BUILD_DIR so it cannot clobber the incremental test build. provision-windows.sh now installs Node.js for the guards UI build. Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
796 lines
36 KiB
C
796 lines
36 KiB
C
/* RED contract for early process-role classification. */
|
|
#include "test_framework.h"
|
|
|
|
#include "daemon/bootstrap.h"
|
|
#include "daemon/ipc.h"
|
|
#include "daemon/service.h"
|
|
#include "foundation/compat.h"
|
|
#include "foundation/compat_fs.h"
|
|
#include "foundation/compat_thread.h"
|
|
#include "foundation/platform.h"
|
|
|
|
#include <stdatomic.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
|
|
enum {
|
|
BOOTSTRAP_TEST_PATH_CAP = 1024,
|
|
BOOTSTRAP_TEST_TIMEOUT_MS = 2000,
|
|
BOOTSTRAP_TEST_SHORT_TIMEOUT_MS = 20,
|
|
};
|
|
|
|
static const char BOOTSTRAP_BUILD_A[] =
|
|
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
static const char BOOTSTRAP_BUILD_B[] =
|
|
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
|
|
|
|
typedef struct {
|
|
char parent[BOOTSTRAP_TEST_PATH_CAP];
|
|
char runtime_dir[BOOTSTRAP_TEST_PATH_CAP];
|
|
cbm_daemon_ipc_endpoint_t *endpoint;
|
|
} bootstrap_endpoint_fixture_t;
|
|
|
|
typedef struct {
|
|
atomic_int cohort_acquire_count;
|
|
atomic_int cohort_release_count;
|
|
atomic_int lock_held;
|
|
atomic_int spawn_count;
|
|
atomic_int probe_count;
|
|
atomic_int lock_attempt_count;
|
|
atomic_int handoff_count;
|
|
atomic_int diagnostic_count;
|
|
atomic_int reserved_probes_remaining;
|
|
atomic_int terminal_probes_remaining;
|
|
atomic_bool available;
|
|
atomic_bool connect_after_reserved;
|
|
atomic_bool connect_requires_unlocked;
|
|
cbm_daemon_bootstrap_probe_status_t forced_probe;
|
|
cbm_version_cohort_status_t forced_cohort;
|
|
char diagnostic[CBM_DAEMON_CONFLICT_MESSAGE_SIZE];
|
|
} bootstrap_fake_ops_t;
|
|
|
|
typedef struct {
|
|
const cbm_daemon_bootstrap_config_t *config;
|
|
const cbm_daemon_bootstrap_ops_t *ops;
|
|
atomic_int *ready;
|
|
atomic_bool *go;
|
|
cbm_daemon_bootstrap_result_t result;
|
|
cbm_daemon_bootstrap_status_t status;
|
|
} bootstrap_thread_call_t;
|
|
|
|
static cbm_daemon_build_identity_t bootstrap_identity(const char *version, const char *build) {
|
|
cbm_daemon_build_identity_t identity = {
|
|
.semantic_version = version,
|
|
.build_fingerprint = build,
|
|
.cache_fingerprint = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
|
|
.protocol_abi = 3,
|
|
.store_abi = 11,
|
|
.feature_abi = 7,
|
|
};
|
|
return identity;
|
|
}
|
|
|
|
static bool bootstrap_endpoint_fixture_start(bootstrap_endpoint_fixture_t *fixture,
|
|
const char *tag) {
|
|
memset(fixture, 0, sizeof(*fixture));
|
|
int written = snprintf(fixture->parent, sizeof(fixture->parent), "%s/cbm-bootstrap-%s-XXXXXX",
|
|
cbm_tmpdir(), tag);
|
|
if (written <= 0 || written >= (int)sizeof(fixture->parent) || !cbm_mkdtemp(fixture->parent)) {
|
|
return false;
|
|
}
|
|
fixture->endpoint = cbm_daemon_bootstrap_endpoint_new(fixture->parent);
|
|
const char *runtime_dir =
|
|
fixture->endpoint ? cbm_daemon_ipc_endpoint_runtime_dir(fixture->endpoint) : NULL;
|
|
if (!runtime_dir) {
|
|
return false;
|
|
}
|
|
written = snprintf(fixture->runtime_dir, sizeof(fixture->runtime_dir), "%s", runtime_dir);
|
|
return written > 0 && written < (int)sizeof(fixture->runtime_dir);
|
|
}
|
|
|
|
static void bootstrap_endpoint_fixture_finish(bootstrap_endpoint_fixture_t *fixture) {
|
|
cbm_daemon_ipc_endpoint_free(fixture->endpoint);
|
|
if (fixture->runtime_dir[0] != '\0') {
|
|
(void)cbm_rmdir(fixture->runtime_dir);
|
|
}
|
|
if (fixture->parent[0] != '\0') {
|
|
(void)cbm_rmdir(fixture->parent);
|
|
}
|
|
memset(fixture, 0, sizeof(*fixture));
|
|
}
|
|
|
|
static cbm_daemon_bootstrap_probe_status_t bootstrap_fake_probe(
|
|
void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint,
|
|
const cbm_daemon_build_identity_t *identity, uint32_t timeout_ms,
|
|
cbm_daemon_runtime_client_t **client_out, cbm_daemon_runtime_connect_result_t *result_out) {
|
|
(void)endpoint;
|
|
(void)identity;
|
|
(void)timeout_ms;
|
|
bootstrap_fake_ops_t *fake = opaque;
|
|
atomic_fetch_add(&fake->probe_count, 1);
|
|
memset(result_out, 0, sizeof(*result_out));
|
|
*client_out = NULL;
|
|
int reserved_remaining = atomic_load(&fake->reserved_probes_remaining);
|
|
while (reserved_remaining > 0 &&
|
|
!atomic_compare_exchange_weak(&fake->reserved_probes_remaining, &reserved_remaining,
|
|
reserved_remaining - 1)) {}
|
|
if (reserved_remaining > 0) {
|
|
if (reserved_remaining == 1 && atomic_load(&fake->connect_after_reserved)) {
|
|
atomic_store(&fake->available, true);
|
|
}
|
|
return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED;
|
|
}
|
|
int terminal_remaining = atomic_load(&fake->terminal_probes_remaining);
|
|
while (terminal_remaining > 0 &&
|
|
!atomic_compare_exchange_weak(&fake->terminal_probes_remaining, &terminal_remaining,
|
|
terminal_remaining - 1)) {}
|
|
if (terminal_remaining > 0) {
|
|
return CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL;
|
|
}
|
|
if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED) {
|
|
return fake->forced_probe;
|
|
}
|
|
if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT) {
|
|
result_out->status = CBM_DAEMON_RUNTIME_CONNECT_CONFLICT;
|
|
result_out->hello_status = CBM_DAEMON_HELLO_BUILD_CONFLICT;
|
|
snprintf(result_out->message, sizeof(result_out->message),
|
|
"CBM daemon could not start: conflicting versions");
|
|
return fake->forced_probe;
|
|
}
|
|
if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL) {
|
|
return fake->forced_probe;
|
|
}
|
|
if (atomic_load(&fake->available) && atomic_load(&fake->connect_requires_unlocked) &&
|
|
atomic_load(&fake->lock_held) != 0) {
|
|
return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED;
|
|
}
|
|
if (atomic_load(&fake->available)) {
|
|
result_out->status = CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED;
|
|
result_out->hello_status = CBM_DAEMON_HELLO_COMPATIBLE;
|
|
result_out->client_id = 1;
|
|
*client_out = (cbm_daemon_runtime_client_t *)(uintptr_t)1;
|
|
return CBM_DAEMON_BOOTSTRAP_PROBE_CONNECTED;
|
|
}
|
|
return CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE;
|
|
}
|
|
|
|
static cbm_version_cohort_status_t bootstrap_fake_cohort_acquire(
|
|
void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint,
|
|
const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms,
|
|
cbm_daemon_bootstrap_cohort_t *cohort_out, cbm_daemon_conflict_t *conflict_out) {
|
|
(void)endpoint;
|
|
(void)deadline_ms;
|
|
bootstrap_fake_ops_t *fake = opaque;
|
|
atomic_fetch_add(&fake->cohort_acquire_count, 1);
|
|
*cohort_out = NULL;
|
|
memset(conflict_out, 0, sizeof(*conflict_out));
|
|
if (fake->forced_cohort == CBM_VERSION_COHORT_CONFLICT) {
|
|
cbm_daemon_build_identity_t active = bootstrap_identity("2.3.0", BOOTSTRAP_BUILD_A);
|
|
(void)cbm_daemon_hello_compare(&active, identity, conflict_out);
|
|
return fake->forced_cohort;
|
|
}
|
|
if (fake->forced_cohort != CBM_VERSION_COHORT_OK) {
|
|
return fake->forced_cohort;
|
|
}
|
|
*cohort_out = fake;
|
|
return CBM_VERSION_COHORT_OK;
|
|
}
|
|
|
|
static void bootstrap_fake_cohort_release(void *opaque, cbm_daemon_bootstrap_cohort_t cohort) {
|
|
bootstrap_fake_ops_t *fake = opaque;
|
|
if (cohort == fake) {
|
|
atomic_fetch_add(&fake->cohort_release_count, 1);
|
|
}
|
|
}
|
|
|
|
static int bootstrap_fake_lock(void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint,
|
|
cbm_daemon_bootstrap_lock_t *lock_out) {
|
|
(void)endpoint;
|
|
bootstrap_fake_ops_t *fake = opaque;
|
|
atomic_fetch_add(&fake->lock_attempt_count, 1);
|
|
int expected = 0;
|
|
if (!atomic_compare_exchange_strong(&fake->lock_held, &expected, 1)) {
|
|
return 0;
|
|
}
|
|
*lock_out = fake;
|
|
return 1;
|
|
}
|
|
|
|
static bool bootstrap_fake_unlock(void *opaque, cbm_daemon_bootstrap_lock_t *lock_io) {
|
|
bootstrap_fake_ops_t *fake = opaque;
|
|
if (lock_io && *lock_io == fake) {
|
|
atomic_store(&fake->lock_held, 0);
|
|
*lock_io = NULL;
|
|
return true;
|
|
}
|
|
return lock_io && !*lock_io;
|
|
}
|
|
|
|
static bool bootstrap_fake_handoff(void *opaque, cbm_daemon_bootstrap_lock_t lock) {
|
|
bootstrap_fake_ops_t *fake = opaque;
|
|
if (lock != fake || atomic_load(&fake->lock_held) != 1) {
|
|
return false;
|
|
}
|
|
atomic_fetch_add(&fake->handoff_count, 1);
|
|
return true;
|
|
}
|
|
|
|
static bool bootstrap_fake_spawn(void *opaque, const cbm_daemon_bootstrap_launch_spec_t *spec) {
|
|
bootstrap_fake_ops_t *fake = opaque;
|
|
/* Client bootstrap must only ever spawn the EPHEMERAL two-argument
|
|
* shape; the permanent shape belongs exclusively to `daemon start`. */
|
|
bool exact = spec && spec->argc == 2U && spec->argv[0] &&
|
|
spec->argv[1] && !spec->argv[2] &&
|
|
strcmp(spec->argv[1], CBM_DAEMON_INTERNAL_ARG) == 0 && spec->detached &&
|
|
!spec->inherit_standard_handles && !spec->use_shell &&
|
|
atomic_load(&fake->handoff_count) > 0 && atomic_load(&fake->lock_held) == 1;
|
|
if (!exact) {
|
|
return false;
|
|
}
|
|
atomic_fetch_add(&fake->spawn_count, 1);
|
|
atomic_store(&fake->available, true);
|
|
return true;
|
|
}
|
|
|
|
static void bootstrap_fake_diagnostic(void *opaque, const char *message) {
|
|
bootstrap_fake_ops_t *fake = opaque;
|
|
atomic_fetch_add(&fake->diagnostic_count, 1);
|
|
snprintf(fake->diagnostic, sizeof(fake->diagnostic), "%s", message ? message : "");
|
|
}
|
|
|
|
static cbm_daemon_bootstrap_ops_t bootstrap_fake_callbacks(bootstrap_fake_ops_t *fake) {
|
|
cbm_daemon_bootstrap_ops_t ops = {
|
|
.context = fake,
|
|
.cohort_acquire = bootstrap_fake_cohort_acquire,
|
|
.cohort_release = bootstrap_fake_cohort_release,
|
|
.probe = bootstrap_fake_probe,
|
|
.startup_lock_try_acquire = bootstrap_fake_lock,
|
|
.startup_lock_prepare_handoff = bootstrap_fake_handoff,
|
|
.startup_lock_release = bootstrap_fake_unlock,
|
|
.spawn_daemon = bootstrap_fake_spawn,
|
|
.visible_diagnostic = bootstrap_fake_diagnostic,
|
|
};
|
|
return ops;
|
|
}
|
|
|
|
static void *bootstrap_thread_execute(void *opaque) {
|
|
bootstrap_thread_call_t *call = opaque;
|
|
atomic_fetch_add(call->ready, 1);
|
|
while (!atomic_load(call->go)) {
|
|
const struct timespec pause = {0, 1000000L};
|
|
(void)cbm_nanosleep(&pause, NULL);
|
|
}
|
|
call->status = cbm_daemon_bootstrap_execute_with_ops(call->config, call->ops, &call->result);
|
|
return NULL;
|
|
}
|
|
|
|
static cbm_daemon_process_role_t classify(int argc, char **argv) {
|
|
return cbm_daemon_process_role(argc, argv);
|
|
}
|
|
|
|
TEST(daemon_bootstrap_classifies_default_and_ui_as_mcp_clients) {
|
|
char *plain[] = {"codebase-memory-mcp", NULL};
|
|
char *ui[] = {"codebase-memory-mcp", "--ui=true", "--port=9750", NULL};
|
|
ASSERT_EQ(classify(1, plain), CBM_DAEMON_PROCESS_MCP_CLIENT);
|
|
ASSERT_EQ(classify(3, ui), CBM_DAEMON_PROCESS_MCP_CLIENT);
|
|
ASSERT_TRUE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_MCP_CLIENT));
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_classifies_stateless_commands_without_client) {
|
|
char *version[] = {"codebase-memory-mcp", "--version", NULL};
|
|
char *help[] = {"codebase-memory-mcp", "--profile", "--help", NULL};
|
|
char *install[] = {"codebase-memory-mcp", "install", "--dry-run", NULL};
|
|
char *uninstall[] = {"codebase-memory-mcp", "uninstall", NULL};
|
|
char *update[] = {"codebase-memory-mcp", "update", "-n", NULL};
|
|
ASSERT_EQ(classify(2, version), CBM_DAEMON_PROCESS_STATELESS);
|
|
ASSERT_EQ(classify(3, help), CBM_DAEMON_PROCESS_STATELESS);
|
|
ASSERT_EQ(classify(3, install), CBM_DAEMON_PROCESS_STATELESS);
|
|
ASSERT_EQ(classify(2, uninstall), CBM_DAEMON_PROCESS_STATELESS);
|
|
ASSERT_EQ(classify(3, update), CBM_DAEMON_PROCESS_STATELESS);
|
|
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_STATELESS));
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_classifies_config_as_coordinated_local_cli) {
|
|
char *list[] = {"codebase-memory-mcp", "config", "list", NULL};
|
|
char *set[] = {"codebase-memory-mcp", "config", "set", "auto_watch", "false", NULL};
|
|
char *help[] = {"codebase-memory-mcp", "config", "--help", NULL};
|
|
ASSERT_EQ(classify(3, list), CBM_DAEMON_PROCESS_LOCAL_CLI);
|
|
ASSERT_EQ(classify(5, set), CBM_DAEMON_PROCESS_LOCAL_CLI);
|
|
ASSERT_EQ(classify(3, help), CBM_DAEMON_PROCESS_STATELESS);
|
|
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_LOCAL_CLI));
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_cli_help_is_stateless_but_tool_calls_are_local) {
|
|
char *tool_help[] = {"codebase-memory-mcp", "cli", "search_graph", "--help", NULL};
|
|
char *tool_call[] = {"codebase-memory-mcp", "cli", "search_graph", "{}", NULL};
|
|
ASSERT_EQ(classify(4, tool_help), CBM_DAEMON_PROCESS_STATELESS);
|
|
ASSERT_EQ(classify(4, tool_call), CBM_DAEMON_PROCESS_LOCAL_CLI);
|
|
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_LOCAL_CLI));
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_cli_arguments_cannot_reclassify_the_process) {
|
|
char *install_value[] = {
|
|
"codebase-memory-mcp", "cli", "search_code", "--query", "install", NULL};
|
|
char *version_value[] = {"codebase-memory-mcp", "cli", "search_code", "--query",
|
|
"--version", NULL};
|
|
ASSERT_EQ(classify(5, install_value), CBM_DAEMON_PROCESS_LOCAL_CLI);
|
|
ASSERT_EQ(classify(5, version_value), CBM_DAEMON_PROCESS_LOCAL_CLI);
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_internal_roles_never_take_client_leases) {
|
|
static char build[] = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
char *daemon[] = {"codebase-memory-mcp", CBM_DAEMON_INTERNAL_ARG, NULL};
|
|
char *worker[] = {"codebase-memory-mcp", "cli", "--index-worker", "--index-worker-build", build,
|
|
"index_repository", "{}", "--response-out", "/tmp/response", NULL};
|
|
char *malformed_worker[] = {"codebase-memory-mcp", "cli", "--index-worker",
|
|
"index_repository", "{}", NULL};
|
|
char *reserved_user_value[] = {"codebase-memory-mcp", "cli", "search_code", "--query",
|
|
"--index-worker", NULL};
|
|
char *hook[] = {"codebase-memory-mcp", "hook-augment", NULL};
|
|
ASSERT_EQ(classify(2, daemon), CBM_DAEMON_PROCESS_DAEMON);
|
|
ASSERT_EQ(classify(9, worker), CBM_DAEMON_PROCESS_WORKER);
|
|
ASSERT_EQ(classify(5, malformed_worker), CBM_DAEMON_PROCESS_INVALID);
|
|
ASSERT_EQ(classify(5, reserved_user_value), CBM_DAEMON_PROCESS_INVALID);
|
|
ASSERT_EQ(classify(2, hook), CBM_DAEMON_PROCESS_HOOK_CLIENT);
|
|
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_DAEMON));
|
|
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_WORKER));
|
|
ASSERT_TRUE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_HOOK_CLIENT));
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_rejects_ambiguous_internal_daemon_argv) {
|
|
char *missing[] = {NULL};
|
|
char *mixed[] = {"codebase-memory-mcp", CBM_DAEMON_INTERNAL_ARG, "cli", NULL};
|
|
ASSERT_EQ(classify(0, missing), CBM_DAEMON_PROCESS_INVALID);
|
|
ASSERT_EQ(classify(3, mixed), CBM_DAEMON_PROCESS_INVALID);
|
|
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_INVALID));
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_uses_one_stable_per_account_endpoint) {
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "stable-endpoint"));
|
|
cbm_daemon_ipc_endpoint_t *second = cbm_daemon_bootstrap_endpoint_new(fixture.parent);
|
|
ASSERT_NOT_NULL(second);
|
|
ASSERT_STR_EQ(cbm_daemon_ipc_endpoint_address(fixture.endpoint),
|
|
cbm_daemon_ipc_endpoint_address(second));
|
|
cbm_daemon_ipc_endpoint_free(second);
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_launches_only_exact_detached_hidden_role) {
|
|
cbm_daemon_bootstrap_launch_spec_t spec;
|
|
ASSERT_TRUE(cbm_daemon_bootstrap_launch_spec_init("/tmp/cbm exact", &spec));
|
|
ASSERT_EQ(spec.argc, 2U);
|
|
ASSERT_STR_EQ(spec.executable_path, "/tmp/cbm exact");
|
|
ASSERT_STR_EQ(spec.argv[0], "/tmp/cbm exact");
|
|
ASSERT_STR_EQ(spec.argv[1], CBM_DAEMON_INTERNAL_ARG);
|
|
ASSERT_NULL(spec.argv[2]);
|
|
ASSERT_TRUE(spec.detached);
|
|
ASSERT_FALSE(spec.inherit_standard_handles);
|
|
ASSERT_FALSE(spec.use_shell);
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_permanent_daemon_argv_is_byte_exact) {
|
|
char *permanent[] = {"codebase-memory-mcp", CBM_DAEMON_INTERNAL_ARG, CBM_DAEMON_PERMANENT_ARG,
|
|
NULL};
|
|
char *reordered[] = {"codebase-memory-mcp", CBM_DAEMON_PERMANENT_ARG, CBM_DAEMON_INTERNAL_ARG,
|
|
NULL};
|
|
char *repeated[] = {"codebase-memory-mcp", CBM_DAEMON_INTERNAL_ARG, CBM_DAEMON_INTERNAL_ARG,
|
|
NULL};
|
|
char *extended[] = {"codebase-memory-mcp", CBM_DAEMON_INTERNAL_ARG, CBM_DAEMON_PERMANENT_ARG,
|
|
"extra", NULL};
|
|
char *wrong_flag[] = {"codebase-memory-mcp", CBM_DAEMON_INTERNAL_ARG, "--permanent", NULL};
|
|
ASSERT_EQ(classify(3, permanent), CBM_DAEMON_PROCESS_DAEMON);
|
|
ASSERT_EQ(classify(3, reordered), CBM_DAEMON_PROCESS_INVALID);
|
|
ASSERT_EQ(classify(3, repeated), CBM_DAEMON_PROCESS_INVALID);
|
|
ASSERT_EQ(classify(4, extended), CBM_DAEMON_PROCESS_INVALID);
|
|
ASSERT_EQ(classify(3, wrong_flag), CBM_DAEMON_PROCESS_INVALID);
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_daemon_ctl_token_routes_after_cli) {
|
|
char *start[] = {"codebase-memory-mcp", "daemon", "start", NULL};
|
|
char *stop[] = {"codebase-memory-mcp", "daemon", "stop", NULL};
|
|
char *status[] = {"codebase-memory-mcp", "daemon", "status", NULL};
|
|
char *help[] = {"codebase-memory-mcp", "daemon", "--help", NULL};
|
|
/* `daemon` after `cli` is opaque tool input, never a control command. */
|
|
char *opaque[] = {"codebase-memory-mcp", "cli", "search_code", "daemon", "start", NULL};
|
|
ASSERT_EQ(classify(3, start), CBM_DAEMON_PROCESS_DAEMON_CTL);
|
|
ASSERT_EQ(classify(3, stop), CBM_DAEMON_PROCESS_DAEMON_CTL);
|
|
ASSERT_EQ(classify(3, status), CBM_DAEMON_PROCESS_DAEMON_CTL);
|
|
ASSERT_EQ(classify(3, help), CBM_DAEMON_PROCESS_STATELESS);
|
|
ASSERT_EQ(classify(5, opaque), CBM_DAEMON_PROCESS_LOCAL_CLI);
|
|
ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_DAEMON_CTL));
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_permanent_launch_spec_is_exact) {
|
|
cbm_daemon_bootstrap_launch_spec_t spec;
|
|
ASSERT_TRUE(cbm_daemon_bootstrap_launch_spec_init_permanent("/tmp/cbm exact", &spec));
|
|
ASSERT_EQ(spec.argc, 3U);
|
|
ASSERT_STR_EQ(spec.argv[0], "/tmp/cbm exact");
|
|
ASSERT_STR_EQ(spec.argv[1], CBM_DAEMON_INTERNAL_ARG);
|
|
ASSERT_STR_EQ(spec.argv[2], CBM_DAEMON_PERMANENT_ARG);
|
|
ASSERT_NULL(spec.argv[3]);
|
|
ASSERT_TRUE(spec.detached);
|
|
ASSERT_FALSE(spec.inherit_standard_handles);
|
|
ASSERT_FALSE(spec.use_shell);
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_stateless_roles_bypass_every_daemon_operation) {
|
|
bootstrap_fake_ops_t fake = {0};
|
|
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
|
cbm_daemon_bootstrap_config_t config = {
|
|
.role = CBM_DAEMON_PROCESS_STATELESS,
|
|
};
|
|
cbm_daemon_bootstrap_result_t result;
|
|
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
|
CBM_DAEMON_BOOTSTRAP_BYPASSED);
|
|
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_BYPASSED);
|
|
ASSERT_EQ(atomic_load(&fake.cohort_acquire_count), 0);
|
|
ASSERT_EQ(atomic_load(&fake.probe_count), 0);
|
|
ASSERT_EQ(atomic_load(&fake.spawn_count), 0);
|
|
ASSERT_EQ(atomic_load(&fake.diagnostic_count), 0);
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_cohort_conflict_is_visible_before_probe_or_spawn) {
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "cohort-conflict"));
|
|
bootstrap_fake_ops_t fake = {0};
|
|
fake.forced_cohort = CBM_VERSION_COHORT_CONFLICT;
|
|
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
|
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_B);
|
|
cbm_daemon_bootstrap_config_t config = {
|
|
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
|
.endpoint = fixture.endpoint,
|
|
.identity = &identity,
|
|
.executable_path = "/tmp/cbm",
|
|
.connect_timeout_ms = 50,
|
|
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
|
};
|
|
cbm_daemon_bootstrap_result_t result;
|
|
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
|
CBM_DAEMON_BOOTSTRAP_CONFLICT);
|
|
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONFLICT);
|
|
ASSERT_EQ(atomic_load(&fake.cohort_acquire_count), 1);
|
|
ASSERT_EQ(atomic_load(&fake.cohort_release_count), 0);
|
|
ASSERT_EQ(atomic_load(&fake.probe_count), 0);
|
|
ASSERT_EQ(atomic_load(&fake.lock_attempt_count), 0);
|
|
ASSERT_EQ(atomic_load(&fake.spawn_count), 0);
|
|
ASSERT_EQ(atomic_load(&fake.diagnostic_count), 1);
|
|
ASSERT_NOT_NULL(strstr(fake.diagnostic, "conflicting CBM process is active"));
|
|
ASSERT_NOT_NULL(strstr(fake.diagnostic, "Close all CBM sessions and commands"));
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_existing_exact_daemon_connects_without_spawn) {
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "existing"));
|
|
bootstrap_fake_ops_t fake = {0};
|
|
atomic_store(&fake.available, true);
|
|
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
|
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
|
cbm_daemon_bootstrap_config_t config = {
|
|
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
|
.endpoint = fixture.endpoint,
|
|
.identity = &identity,
|
|
.executable_path = "/tmp/cbm",
|
|
.connect_timeout_ms = 50,
|
|
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
|
};
|
|
cbm_daemon_bootstrap_result_t result;
|
|
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
|
CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
|
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
|
ASSERT_NOT_NULL(result.client);
|
|
ASSERT_FALSE(result.daemon_spawned);
|
|
ASSERT_EQ(atomic_load(&fake.spawn_count), 0);
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_conflict_is_visible_and_never_spawns) {
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "conflict"));
|
|
bootstrap_fake_ops_t fake = {0};
|
|
fake.forced_probe = CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT;
|
|
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
|
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_B);
|
|
cbm_daemon_bootstrap_config_t config = {
|
|
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
|
.endpoint = fixture.endpoint,
|
|
.identity = &identity,
|
|
.executable_path = "/tmp/cbm",
|
|
.connect_timeout_ms = 50,
|
|
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
|
};
|
|
cbm_daemon_bootstrap_result_t result;
|
|
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
|
CBM_DAEMON_BOOTSTRAP_CONFLICT);
|
|
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONFLICT);
|
|
ASSERT_NULL(result.client);
|
|
ASSERT_EQ(atomic_load(&fake.spawn_count), 0);
|
|
ASSERT_EQ(atomic_load(&fake.diagnostic_count), 1);
|
|
ASSERT_NOT_NULL(strstr(fake.diagnostic, "conflicting versions"));
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_terminal_generation_that_never_exits_is_not_replaced) {
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "terminal"));
|
|
bootstrap_fake_ops_t fake = {0};
|
|
fake.forced_probe = CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL;
|
|
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
|
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
|
cbm_daemon_bootstrap_config_t config = {
|
|
.role = CBM_DAEMON_PROCESS_HOOK_CLIENT,
|
|
.endpoint = fixture.endpoint,
|
|
.identity = &identity,
|
|
.executable_path = "/tmp/cbm",
|
|
.connect_timeout_ms = 1,
|
|
.startup_timeout_ms = BOOTSTRAP_TEST_SHORT_TIMEOUT_MS,
|
|
};
|
|
cbm_daemon_bootstrap_result_t result;
|
|
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
|
CBM_DAEMON_BOOTSTRAP_FAILED);
|
|
ASSERT_EQ(atomic_load(&fake.lock_held), 0);
|
|
ASSERT_EQ(atomic_load(&fake.spawn_count), 0);
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
PASS();
|
|
}
|
|
|
|
/* RED for final-session/new-session overlap: STOPPING is a temporary state of
|
|
* the previous same-build generation. Once that generation disappears, the
|
|
* already-running bootstrap attempt must serialize and become the new first
|
|
* client instead of forcing the coding agent to restart its MCP process. */
|
|
TEST(daemon_bootstrap_terminal_then_absent_spawns_replacement) {
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "terminal-absent"));
|
|
bootstrap_fake_ops_t fake = {0};
|
|
atomic_store(&fake.terminal_probes_remaining, 1);
|
|
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
|
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
|
cbm_daemon_bootstrap_config_t config = {
|
|
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
|
.endpoint = fixture.endpoint,
|
|
.identity = &identity,
|
|
.executable_path = "/tmp/cbm",
|
|
.connect_timeout_ms = 1,
|
|
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
|
};
|
|
cbm_daemon_bootstrap_result_t result;
|
|
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
|
CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
|
ASSERT_TRUE(result.daemon_spawned);
|
|
ASSERT_EQ(atomic_load(&fake.spawn_count), 1);
|
|
ASSERT(atomic_load(&fake.lock_attempt_count) >= 1);
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_reserved_generation_becomes_connectable_without_spawn) {
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "reserved-connect"));
|
|
bootstrap_fake_ops_t fake = {0};
|
|
atomic_store(&fake.reserved_probes_remaining, 2);
|
|
atomic_store(&fake.connect_after_reserved, true);
|
|
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
|
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
|
cbm_daemon_bootstrap_config_t config = {
|
|
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
|
.endpoint = fixture.endpoint,
|
|
.identity = &identity,
|
|
.executable_path = "/tmp/cbm",
|
|
.connect_timeout_ms = 1,
|
|
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
|
};
|
|
cbm_daemon_bootstrap_result_t result;
|
|
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
|
CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
|
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
|
ASSERT_FALSE(result.daemon_spawned);
|
|
ASSERT_EQ(atomic_load(&fake.lock_attempt_count), 0);
|
|
ASSERT_EQ(atomic_load(&fake.spawn_count), 0);
|
|
ASSERT(atomic_load(&fake.probe_count) >= 3);
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
PASS();
|
|
}
|
|
|
|
/* RED for the equivalent race when the old listener vanishes without first
|
|
* returning the explicit STOPPING response. Startup serialization decides
|
|
* whether absence is now safe; a historical RESERVED sample is not sticky. */
|
|
TEST(daemon_bootstrap_reserved_then_absent_spawns_replacement) {
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "reserved-absent"));
|
|
bootstrap_fake_ops_t fake = {0};
|
|
atomic_store(&fake.reserved_probes_remaining, 1);
|
|
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
|
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
|
cbm_daemon_bootstrap_config_t config = {
|
|
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
|
.endpoint = fixture.endpoint,
|
|
.identity = &identity,
|
|
.executable_path = "/tmp/cbm",
|
|
.connect_timeout_ms = 1,
|
|
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
|
};
|
|
cbm_daemon_bootstrap_result_t result;
|
|
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
|
CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
|
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
|
ASSERT_TRUE(result.daemon_spawned);
|
|
ASSERT(atomic_load(&fake.lock_attempt_count) >= 1);
|
|
ASSERT_EQ(atomic_load(&fake.spawn_count), 1);
|
|
ASSERT(atomic_load(&fake.probe_count) > 1);
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
PASS();
|
|
}
|
|
|
|
/* RED for the native Windows lock order: after spawn, the generation claim or
|
|
* lifetime reservation becomes visible before the client can connect. Daemon
|
|
* participant teardown needs startup ownership, so bootstrap must release its
|
|
* handoff once that generation is observable. */
|
|
TEST(daemon_bootstrap_releases_handoff_when_spawned_generation_is_reserved) {
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "spawn-admission"));
|
|
bootstrap_fake_ops_t fake = {0};
|
|
atomic_store(&fake.connect_requires_unlocked, true);
|
|
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
|
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
|
cbm_daemon_bootstrap_config_t config = {
|
|
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
|
.endpoint = fixture.endpoint,
|
|
.identity = &identity,
|
|
.executable_path = "/tmp/cbm",
|
|
.connect_timeout_ms = 1,
|
|
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
|
};
|
|
cbm_daemon_bootstrap_result_t result;
|
|
ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result),
|
|
CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
|
ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
|
ASSERT_TRUE(result.daemon_spawned);
|
|
ASSERT_NOT_NULL(result.client);
|
|
ASSERT_EQ(atomic_load(&fake.spawn_count), 1);
|
|
ASSERT_EQ(atomic_load(&fake.lock_held), 0);
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_rejected_connect_is_reserved_and_never_unavailable) {
|
|
cbm_daemon_runtime_connect_result_t capacity = {0};
|
|
capacity.status = CBM_DAEMON_RUNTIME_CONNECT_REJECTED;
|
|
snprintf(capacity.message, sizeof(capacity.message), "CBM daemon connection capacity reached");
|
|
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, 1),
|
|
CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED);
|
|
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, 0),
|
|
CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED);
|
|
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, -1),
|
|
CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED);
|
|
|
|
cbm_daemon_runtime_connect_result_t stopping = capacity;
|
|
snprintf(stopping.message, sizeof(stopping.message), "CBM daemon is stopping");
|
|
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&stopping, 1),
|
|
CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL);
|
|
|
|
cbm_daemon_runtime_connect_result_t absent = {0};
|
|
absent.status = CBM_DAEMON_RUNTIME_CONNECT_ERROR;
|
|
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&absent, 1),
|
|
CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED);
|
|
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&absent, 0),
|
|
CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE);
|
|
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&absent, -1),
|
|
CBM_DAEMON_BOOTSTRAP_PROBE_ERROR);
|
|
PASS();
|
|
}
|
|
|
|
TEST(daemon_bootstrap_concurrent_first_clients_spawn_one_daemon) {
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "startup-race"));
|
|
bootstrap_fake_ops_t fake = {0};
|
|
cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake);
|
|
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
|
cbm_daemon_bootstrap_config_t config = {
|
|
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
|
.endpoint = fixture.endpoint,
|
|
.identity = &identity,
|
|
.executable_path = "/tmp/cbm",
|
|
.connect_timeout_ms = 10,
|
|
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
|
};
|
|
atomic_int ready = 0;
|
|
atomic_bool go = false;
|
|
bootstrap_thread_call_t calls[2] = {
|
|
{.config = &config, .ops = &ops, .ready = &ready, .go = &go},
|
|
{.config = &config, .ops = &ops, .ready = &ready, .go = &go},
|
|
};
|
|
cbm_thread_t threads[2];
|
|
ASSERT_EQ(cbm_thread_create(&threads[0], 0, bootstrap_thread_execute, &calls[0]), 0);
|
|
ASSERT_EQ(cbm_thread_create(&threads[1], 0, bootstrap_thread_execute, &calls[1]), 0);
|
|
uint64_t ready_deadline = cbm_now_ms() + BOOTSTRAP_TEST_TIMEOUT_MS;
|
|
while (atomic_load(&ready) != 2 && cbm_now_ms() < ready_deadline) {
|
|
const struct timespec pause = {0, 1000000L};
|
|
(void)cbm_nanosleep(&pause, NULL);
|
|
}
|
|
atomic_store(&go, true);
|
|
ASSERT_EQ(cbm_thread_join(&threads[0]), 0);
|
|
ASSERT_EQ(cbm_thread_join(&threads[1]), 0);
|
|
ASSERT_EQ(calls[0].status, CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
|
ASSERT_EQ(calls[1].status, CBM_DAEMON_BOOTSTRAP_CONNECTED);
|
|
ASSERT_EQ(atomic_load(&fake.spawn_count), 1);
|
|
ASSERT_TRUE(calls[0].result.daemon_spawned != calls[1].result.daemon_spawned);
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
PASS();
|
|
}
|
|
|
|
#ifdef __APPLE__
|
|
/* RED: the old double-fork returned success before its grandchild attempted
|
|
* posix_spawn, hiding an immediate launch error behind the full timeout. */
|
|
TEST(daemon_bootstrap_darwin_launch_failure_is_synchronous) {
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "darwin-missing"));
|
|
cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A);
|
|
char missing[BOOTSTRAP_TEST_PATH_CAP];
|
|
int written = snprintf(missing, sizeof(missing), "%s/definitely-missing-cbm", fixture.parent);
|
|
ASSERT(written > 0 && written < (int)sizeof(missing));
|
|
cbm_daemon_bootstrap_config_t config = {
|
|
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
|
.endpoint = fixture.endpoint,
|
|
.identity = &identity,
|
|
.executable_path = missing,
|
|
.connect_timeout_ms = 1,
|
|
.startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS,
|
|
};
|
|
cbm_daemon_bootstrap_result_t result;
|
|
uint64_t started = cbm_now_ms();
|
|
ASSERT_EQ(cbm_daemon_bootstrap_execute(&config, &result), CBM_DAEMON_BOOTSTRAP_FAILED);
|
|
uint64_t elapsed = cbm_now_ms() - started;
|
|
ASSERT_FALSE(result.daemon_spawned);
|
|
ASSERT(elapsed < BOOTSTRAP_TEST_TIMEOUT_MS / 2U);
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
PASS();
|
|
}
|
|
#endif
|
|
|
|
SUITE(daemon_bootstrap) {
|
|
RUN_TEST(daemon_bootstrap_classifies_default_and_ui_as_mcp_clients);
|
|
RUN_TEST(daemon_bootstrap_classifies_stateless_commands_without_client);
|
|
RUN_TEST(daemon_bootstrap_classifies_config_as_coordinated_local_cli);
|
|
RUN_TEST(daemon_bootstrap_cli_help_is_stateless_but_tool_calls_are_local);
|
|
RUN_TEST(daemon_bootstrap_cli_arguments_cannot_reclassify_the_process);
|
|
RUN_TEST(daemon_bootstrap_internal_roles_never_take_client_leases);
|
|
RUN_TEST(daemon_bootstrap_rejects_ambiguous_internal_daemon_argv);
|
|
RUN_TEST(daemon_bootstrap_uses_one_stable_per_account_endpoint);
|
|
RUN_TEST(daemon_bootstrap_launches_only_exact_detached_hidden_role);
|
|
RUN_TEST(daemon_bootstrap_permanent_daemon_argv_is_byte_exact);
|
|
RUN_TEST(daemon_bootstrap_daemon_ctl_token_routes_after_cli);
|
|
RUN_TEST(daemon_bootstrap_permanent_launch_spec_is_exact);
|
|
RUN_TEST(daemon_bootstrap_stateless_roles_bypass_every_daemon_operation);
|
|
RUN_TEST(daemon_bootstrap_cohort_conflict_is_visible_before_probe_or_spawn);
|
|
RUN_TEST(daemon_bootstrap_existing_exact_daemon_connects_without_spawn);
|
|
RUN_TEST(daemon_bootstrap_conflict_is_visible_and_never_spawns);
|
|
RUN_TEST(daemon_bootstrap_terminal_generation_that_never_exits_is_not_replaced);
|
|
RUN_TEST(daemon_bootstrap_terminal_then_absent_spawns_replacement);
|
|
RUN_TEST(daemon_bootstrap_reserved_generation_becomes_connectable_without_spawn);
|
|
RUN_TEST(daemon_bootstrap_reserved_then_absent_spawns_replacement);
|
|
RUN_TEST(daemon_bootstrap_releases_handoff_when_spawned_generation_is_reserved);
|
|
RUN_TEST(daemon_bootstrap_rejected_connect_is_reserved_and_never_unavailable);
|
|
RUN_TEST(daemon_bootstrap_concurrent_first_clients_spawn_one_daemon);
|
|
#ifdef __APPLE__
|
|
RUN_TEST(daemon_bootstrap_darwin_launch_failure_is_synchronous);
|
|
#endif
|
|
}
|