mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-20 05:49:37 +08:00
Why: the test runner isolated HOME and CBM_CACHE_DIR but not
CBM_RUNTIME_DIR, and neither of the first two moves the daemon
rendezvous. Its key is a constant product hash (cbm_daemon_rendezvous_key,
"c888acc3ae367a1e"), and with no explicit parent and CBM_RUNTIME_DIR unset
cbm_daemon_bootstrap_endpoint_new falls back to the account-wide default
parent (/private/tmp on macOS, /tmp on Linux, LocalAppData on Windows).
Every endpoint a test resolved without an explicit parent was therefore
byte-identical to the developer's live daemon socket
(/private/tmp/cbm-daemon-<uid>/cbm-c888acc3ae367a1e.sock), whatever HOME
said.
The only in-runner product caller is CLI activation
(cli_activation_production_context_init, reached from cbm_cmd_install,
cbm_cmd_uninstall and update). Only a process-global seam kept it off the
default: SUITE(cli) sets that seam on entry and clears it on exit. On the
default endpoint, activation admits into the live daemon's cohort. When
the cohort is ours or busy, or the active cache fingerprint is unreadable,
activation prints "Stopping active CBM sessions" and drains them. That is
the same class of failure as an old binary's install, which stopped a
user daemon and six MCP sessions. One test outside SUITE(cli), or one
fixture that drops the variable, was enough to reach it.
daemon_bootstrap_runtime_dir_env_relocates_rendezvous did exactly that:
it restored by unsetting CBM_RUNTIME_DIR.
Fix:
- tests/test_main.c: tf_setup_cache_sentinel creates a fresh per-run
runtime parent (th_secure_runtime_parent_new "run", under /tmp on POSIX
and LocalAppData on Windows) and sets CBM_RUNTIME_DIR to it for the
whole run. It replaces any inherited value, the rule
scripts/test-runtime.sh applies to the shell harness. The existing
atexit cleanup removes the directory.
- src/daemon/bootstrap.c, CBM_ENABLE_TEST_SEAMS only: once a test runner
arms the guard, an endpoint request with no explicit parent and no
CBM_RUNTIME_DIR is refused with a stderr diagnostic instead of
resolving the default. No product binary arms it.
- tests/repro/repro_main.c arms the same guard. No reproduction needs the
rendezvous.
- tests/test_daemon_bootstrap.c: the relocation test restores the value
it found instead of unsetting it.
- tests/test_helpers.h: th_secure_runtime_base() names the anchor that
th_secure_runtime_parent_new already used.
Proof:
- Before, on origin/main 1f9b4db1: runner_isolation is RED 3/3 at its
first assertion (CBM_RUNTIME_DIR is NULL during the run).
- After: green 3/3. The endpoint resolved through the product resolver
lies inside /private/tmp/cbm-runtime-run-XXXXXX/, never at
/private/tmp/cbm-daemon-<uid>. All 43 activation-guard scopes logged
by the suites below are fixture or cli-suite directories, and the
guard never fired. The per-run directory is gone after exit, and the
live daemon and its runtime directory are unchanged.
- Revert: dropping the setenv gives RED (value is NULL). Disarming the
guard gives RED (refused_without).
Tests:
- runner_isolation: runner_isolation_daemon_runtime_dir_is_private,
runner_isolation_refuses_default_daemon_runtime
- Green on macOS: cli (328), mcp + mcp_mutation_guard (344),
daemon_runtime + daemon_application (106), daemon + daemon_version +
daemon_frontend + daemon_bootstrap + daemon_ipc (117), runner_isolation
+ activation_transaction + version_cohort + project_lock +
index_supervisor + private_file_lock + lock_registry (73). All skips
are existing Windows- or Linux-only platform skips.
- make -f Makefile.cbm lint-ci passes. test-repro-runner builds.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
1201 lines
55 KiB
C
1201 lines
55 KiB
C
/* RED contract for early process-role classification. */
|
|
#include "test_framework.h"
|
|
#include "test_helpers.h"
|
|
|
|
#include "daemon/bootstrap.h"
|
|
#include "daemon/host.h"
|
|
#include "daemon/ipc.h"
|
|
#include "daemon/ipc_internal.h"
|
|
#include "daemon/runtime.h"
|
|
#include "daemon/service.h"
|
|
#include "foundation/compat.h"
|
|
#include "foundation/compat_fs.h"
|
|
#include "foundation/compat_thread.h"
|
|
#include "foundation/platform.h"
|
|
|
|
#include <errno.h>
|
|
#include <stdatomic.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
#ifndef _WIN32
|
|
#include <signal.h>
|
|
#include <sys/wait.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
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);
|
|
}
|
|
|
|
/* Compare against canonical parents only: the endpoint canonicalizes its parent
|
|
* before building the runtime path (/var/folders/... becomes /private/var/... on
|
|
* macOS), so a raw prefix compare would miss a correct relocation. */
|
|
static bool bootstrap_path_has_parent(const char *path, const char *parent) {
|
|
size_t length = parent ? strlen(parent) : 0;
|
|
return path && length > 0 && strncmp(path, parent, length) == 0 &&
|
|
(path[length] == '/' || path[length] == '\\');
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/* #1574/#1621: the shipped build must be able to relocate the rendezvous when
|
|
* the default ancestry (%LOCALAPPDATA%, /private/tmp) cannot pass the
|
|
* private-directory walk — otherwise every command fails, `config list`
|
|
* included, and the operator cannot reconfigure their way out. CBM_RUNTIME_DIR
|
|
* moves WHERE the rendezvous lives; it never relaxes HOW it is checked, so a
|
|
* value that cannot be a private runtime parent must be refused rather than
|
|
* silently replaced by the default. An explicit parent — the compile-time test
|
|
* seam, the lifecycle guards' isolated namespace — keeps precedence over it. */
|
|
TEST(daemon_bootstrap_runtime_dir_env_relocates_rendezvous) {
|
|
char override_parent[BOOTSTRAP_TEST_PATH_CAP] = {0};
|
|
char canonical_override[BOOTSTRAP_TEST_PATH_CAP] = {0};
|
|
char canonical_explicit[BOOTSTRAP_TEST_PATH_CAP] = {0};
|
|
char relocated_runtime[BOOTSTRAP_TEST_PATH_CAP] = {0};
|
|
char explicit_runtime[BOOTSTRAP_TEST_PATH_CAP] = {0};
|
|
char unusable[BOOTSTRAP_TEST_PATH_CAP] = {0};
|
|
/* The runner isolates the rendezvous for the whole run through this same
|
|
* variable, so the test must hand back exactly what it found. */
|
|
char previous_value[BOOTSTRAP_TEST_PATH_CAP] = {0};
|
|
const char *previous =
|
|
cbm_safe_getenv("CBM_RUNTIME_DIR", previous_value, sizeof(previous_value), NULL);
|
|
int written = snprintf(override_parent, sizeof(override_parent),
|
|
"%s/cbm-bootstrap-runtime-env-XXXXXX", cbm_tmpdir());
|
|
if (written <= 0 || written >= (int)sizeof(override_parent) || !cbm_mkdtemp(override_parent)) {
|
|
FAIL("could not create the override runtime parent");
|
|
}
|
|
written = snprintf(unusable, sizeof(unusable), "%s/absent/nested", override_parent);
|
|
bool prepared =
|
|
written > 0 && written < (int)sizeof(unusable) &&
|
|
cbm_canonical_path(override_parent, canonical_override, sizeof(canonical_override)) != 0 &&
|
|
cbm_setenv("CBM_RUNTIME_DIR", override_parent, 1) == 0;
|
|
|
|
/* NULL parent == every product call site: daemon, MCP client, local CLI,
|
|
* index worker, activation. */
|
|
cbm_daemon_ipc_endpoint_t *relocated =
|
|
prepared ? cbm_daemon_bootstrap_endpoint_new(NULL) : NULL;
|
|
const char *relocated_dir = relocated ? cbm_daemon_ipc_endpoint_runtime_dir(relocated) : NULL;
|
|
if (relocated_dir) {
|
|
(void)snprintf(relocated_runtime, sizeof(relocated_runtime), "%s", relocated_dir);
|
|
}
|
|
|
|
/* Same environment, explicit parent: the caller still wins. */
|
|
bootstrap_endpoint_fixture_t fixture = {0};
|
|
bool explicit_started = prepared && bootstrap_endpoint_fixture_start(&fixture, "runtime-env");
|
|
bool explicit_canonical =
|
|
explicit_started &&
|
|
cbm_canonical_path(fixture.parent, canonical_explicit, sizeof(canonical_explicit)) != 0;
|
|
if (explicit_started) {
|
|
(void)snprintf(explicit_runtime, sizeof(explicit_runtime), "%s", fixture.runtime_dir);
|
|
}
|
|
|
|
/* A named parent that cannot pass validation is refused, never ignored. */
|
|
bool unusable_set = prepared && cbm_setenv("CBM_RUNTIME_DIR", unusable, 1) == 0;
|
|
cbm_daemon_ipc_endpoint_t *refused =
|
|
unusable_set ? cbm_daemon_bootstrap_endpoint_new(NULL) : NULL;
|
|
|
|
/* Restore before asserting: a failed assertion returns immediately, and a
|
|
* leaked override, or a dropped run value, would follow every later suite
|
|
* in this process. */
|
|
bool restored = previous ? cbm_setenv("CBM_RUNTIME_DIR", previous_value, 1) == 0
|
|
: cbm_unsetenv("CBM_RUNTIME_DIR") == 0;
|
|
cbm_daemon_ipc_endpoint_free(refused);
|
|
cbm_daemon_ipc_endpoint_free(relocated);
|
|
if (relocated_runtime[0] != '\0') {
|
|
(void)cbm_rmdir(relocated_runtime);
|
|
}
|
|
if (explicit_started) {
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
}
|
|
(void)cbm_rmdir(override_parent);
|
|
|
|
ASSERT_TRUE(restored);
|
|
ASSERT_TRUE(prepared);
|
|
ASSERT_TRUE(explicit_started);
|
|
ASSERT_TRUE(explicit_canonical);
|
|
ASSERT_TRUE(unusable_set);
|
|
ASSERT_NOT_NULL(relocated);
|
|
ASSERT_TRUE(bootstrap_path_has_parent(relocated_runtime, canonical_override));
|
|
ASSERT_TRUE(bootstrap_path_has_parent(explicit_runtime, canonical_explicit));
|
|
ASSERT_FALSE(bootstrap_path_has_parent(explicit_runtime, canonical_override));
|
|
ASSERT_NULL(refused);
|
|
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();
|
|
}
|
|
|
|
/* 2026-08-29 zombie regression: a connect that reached a live process but got
|
|
* no answer names that holder in muted_endpoint_holder_pid. That endpoint is
|
|
* owned regardless of what the advisory locks read, so classification must be
|
|
* RESERVED on every lifetime answer — never absence, never a spawn license. */
|
|
TEST(daemon_bootstrap_mute_endpoint_holder_is_reserved_and_never_unavailable) {
|
|
cbm_daemon_runtime_connect_result_t mute = {0};
|
|
mute.status = CBM_DAEMON_RUNTIME_CONNECT_ERROR;
|
|
mute.muted_endpoint_holder_pid = 4242;
|
|
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&mute, 1),
|
|
CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED);
|
|
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&mute, 0),
|
|
CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED);
|
|
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&mute, -1),
|
|
CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED);
|
|
|
|
/* A protocol-level rejection still wins over the holder pid: an answering
|
|
* generation is more precise evidence than a silent one. */
|
|
cbm_daemon_runtime_connect_result_t rejected = {0};
|
|
rejected.status = CBM_DAEMON_RUNTIME_CONNECT_REJECTED;
|
|
rejected.muted_endpoint_holder_pid = 4242;
|
|
snprintf(rejected.message, sizeof(rejected.message), "CBM daemon is stopping");
|
|
ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&rejected, 0),
|
|
CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL);
|
|
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
|
|
|
|
#ifndef _WIN32
|
|
enum { BOOTSTRAP_ENOSPC_MAX_CHILDREN = 16, BOOTSTRAP_ENOSPC_LOG_CAP = 65536 };
|
|
|
|
typedef struct {
|
|
char parent[BOOTSTRAP_TEST_PATH_CAP];
|
|
cbm_daemon_build_identity_t identity;
|
|
pid_t children[BOOTSTRAP_ENOSPC_MAX_CHILDREN];
|
|
int exit_status[BOOTSTRAP_ENOSPC_MAX_CHILDREN];
|
|
bool reaped[BOOTSTRAP_ENOSPC_MAX_CHILDREN];
|
|
size_t child_count;
|
|
size_t spawn_calls;
|
|
} bootstrap_enospc_host_t;
|
|
|
|
/* The production spawn exec's the product binary; this one forks a REAL daemon
|
|
* host (cbm_daemon_host_run, the same entry `--cbm-daemon-internal` reaches)
|
|
* whose record publication fails with ENOSPC through the inherited seam. */
|
|
static bool bootstrap_enospc_host_spawn(void *opaque,
|
|
const cbm_daemon_bootstrap_launch_spec_t *spec) {
|
|
bootstrap_enospc_host_t *state = opaque;
|
|
if (!spec || !spec->detached) {
|
|
return false;
|
|
}
|
|
state->spawn_calls++;
|
|
if (state->child_count >= BOOTSTRAP_ENOSPC_MAX_CHILDREN) {
|
|
/* RED-run guard only: a client that keeps respawning a doomed daemon
|
|
* for its whole deadline must not fork without bound. */
|
|
return true;
|
|
}
|
|
pid_t child = fork();
|
|
if (child < 0) {
|
|
return false;
|
|
}
|
|
if (child == 0) {
|
|
cbm_daemon_ipc_endpoint_t *endpoint = cbm_daemon_bootstrap_endpoint_new(state->parent);
|
|
atomic_int stop_requested = ATOMIC_VAR_INIT(0);
|
|
cbm_daemon_host_config_t config = {
|
|
.endpoint = endpoint,
|
|
.identity = state->identity,
|
|
.executable_path = "/enospc-host-test",
|
|
.stop_requested = &stop_requested,
|
|
};
|
|
int run_result = endpoint ? cbm_daemon_host_run(&config) : 0;
|
|
_exit(run_result == -1 ? 0 : 50);
|
|
}
|
|
/* SYNCHRONOUS on purpose. The daemon host writes its start-failure record
|
|
* and only then lets go of its lifetime reservation and exits, so once it
|
|
* has been reaped "record on disk, reservation released" is a STABLE state
|
|
* pinned by construction. The client's first look after the spawn (the
|
|
* wait loop always runs once and its first failure check is unthrottled)
|
|
* therefore finds the record however long the host took to get there.
|
|
*
|
|
* Returning while the host was still running made the verdict ride on the
|
|
* client's 30 s startup deadline: before it listens the host SHA-256s its
|
|
* own image, which for the ~450 MB test runner is ~3 s natively and ~26 s
|
|
* under MSan (48 CI samples: 20-32 s). The deadline won that race on slow
|
|
* runs and the reaper then SIGKILLed a host that had not failed yet --
|
|
* `ASSERT(daemon_named_cause)` red on PRs that touch no C at all (#2245,
|
|
* #2158, #2140). A deadline must never decide a test (O9). */
|
|
size_t slot = state->child_count++;
|
|
state->children[slot] = child;
|
|
int status = 0;
|
|
pid_t waited;
|
|
do {
|
|
waited = waitpid(child, &status, 0);
|
|
} while (waited < 0 && errno == EINTR);
|
|
state->reaped[slot] = waited == child;
|
|
state->exit_status[slot] = status;
|
|
return true;
|
|
}
|
|
|
|
static void bootstrap_enospc_reap(bootstrap_enospc_host_t *state, int *nonzero_exits) {
|
|
*nonzero_exits = 0;
|
|
for (size_t i = 0; i < state->child_count; i++) {
|
|
int status = state->exit_status[i];
|
|
if (!state->reaped[i]) {
|
|
/* Only reachable when the synchronous wait in the spawn itself
|
|
* failed; never leave a host behind. */
|
|
pid_t waited;
|
|
(void)kill(state->children[i], SIGKILL);
|
|
do {
|
|
waited = waitpid(state->children[i], &status, 0);
|
|
} while (waited < 0 && errno == EINTR);
|
|
}
|
|
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
|
|
(*nonzero_exits)++;
|
|
}
|
|
}
|
|
}
|
|
|
|
static bool bootstrap_read_file(const char *path, char *out, size_t capacity) {
|
|
FILE *file = cbm_fopen(path, "rb");
|
|
if (!file) {
|
|
return false;
|
|
}
|
|
size_t used = fread(out, 1, capacity - 1, file);
|
|
out[used] = '\0';
|
|
(void)fclose(file);
|
|
return true;
|
|
}
|
|
|
|
/* The record contract behind the fail-fast: a real listener failure in this
|
|
* process is recorded and read back with stage, errno, and path; a record
|
|
* older than the reader's spawn or for another endpoint is not evidence. */
|
|
TEST(daemon_bootstrap_start_failure_record_round_trip) {
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
bootstrap_endpoint_fixture_t other;
|
|
ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "failure-record"));
|
|
char other_parent[BOOTSTRAP_TEST_PATH_CAP];
|
|
int other_written =
|
|
snprintf(other_parent, sizeof(other_parent), "%s/other-XXXXXX", fixture.parent);
|
|
ASSERT(other_written > 0 && other_written < (int)sizeof(other_parent));
|
|
ASSERT_TRUE(cbm_mkdtemp(other_parent) != NULL);
|
|
memset(&other, 0, sizeof(other));
|
|
other.endpoint = cbm_daemon_ipc_endpoint_new("1828000000000002", other_parent);
|
|
ASSERT_TRUE(other.endpoint != NULL);
|
|
char logs[BOOTSTRAP_TEST_PATH_CAP];
|
|
int logs_written = snprintf(logs, sizeof(logs), "%s/logs", fixture.parent);
|
|
ASSERT(logs_written > 0 && logs_written < (int)sizeof(logs));
|
|
char expected_path[BOOTSTRAP_TEST_PATH_CAP];
|
|
int path_written = snprintf(expected_path, sizeof(expected_path), "%s.pending.tmp",
|
|
cbm_daemon_ipc_endpoint_address(fixture.endpoint));
|
|
ASSERT(path_written > 0 && path_written < (int)sizeof(expected_path));
|
|
|
|
cbm_daemon_ipc_posix_record_write_failure_set_for_test(ENOSPC);
|
|
cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(fixture.endpoint);
|
|
cbm_daemon_ipc_posix_record_write_failure_set_for_test(0);
|
|
ASSERT_TRUE(listener == NULL);
|
|
|
|
uint64_t now_s = (uint64_t)time(NULL);
|
|
bool recorded = cbm_daemon_bootstrap_start_failure_record(logs, fixture.endpoint, "runtime");
|
|
cbm_daemon_bootstrap_start_failure_t failure;
|
|
int found =
|
|
cbm_daemon_bootstrap_start_failure_read(logs, fixture.endpoint, now_s - 2, &failure);
|
|
cbm_daemon_bootstrap_start_failure_t stale;
|
|
int stale_found =
|
|
cbm_daemon_bootstrap_start_failure_read(logs, fixture.endpoint, now_s + 60, &stale);
|
|
cbm_daemon_bootstrap_start_failure_t foreign;
|
|
int foreign_found =
|
|
cbm_daemon_bootstrap_start_failure_read(logs, other.endpoint, now_s - 2, &foreign);
|
|
char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE];
|
|
cbm_daemon_bootstrap_start_failure_format(&failure, logs, message, sizeof(message));
|
|
|
|
cbm_daemon_ipc_endpoint_free(other.endpoint);
|
|
(void)th_rmtree(fixture.parent);
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
|
|
ASSERT_TRUE(recorded);
|
|
ASSERT_EQ(found, 1);
|
|
ASSERT_STR_EQ(failure.component, "runtime");
|
|
ASSERT_STR_EQ(failure.stage, "pending_publication");
|
|
ASSERT_EQ(failure.errno_value, ENOSPC);
|
|
ASSERT_STR_EQ(failure.path, expected_path);
|
|
ASSERT_TRUE(failure.pid == (uint64_t)getpid());
|
|
ASSERT_EQ(stale_found, 0);
|
|
ASSERT_EQ(foreign_found, 0);
|
|
ASSERT_TRUE(strstr(message, "CBM daemon failed to start: pending_publication failed with "
|
|
"ENOSPC (") != NULL);
|
|
ASSERT_TRUE(strstr(message, expected_path) != NULL);
|
|
ASSERT_TRUE(strstr(message, "/cbm-daemon.log") != NULL);
|
|
PASS();
|
|
}
|
|
|
|
/* #1828: a daemon that dies at publication (full /tmp) left every client
|
|
* waiting the full 30 s and then reporting "active or starting" -- the
|
|
* opposite of the truth. A real host is spawned against a runtime directory
|
|
* whose record writes fail with ENOSPC; the client must report "failed to
|
|
* start" naming the errno and the path by ending the wait on the recorded
|
|
* cause, not by exhausting the deadline. The proof is the surfaced record and
|
|
* the single spawn (see the assertions), never a wall-clock measurement. */
|
|
TEST(daemon_bootstrap_fails_fast_when_daemon_dies_at_publication) {
|
|
const char *old_cache = getenv("CBM_CACHE_DIR");
|
|
char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL;
|
|
bool snapshot_ok = !old_cache || saved_cache;
|
|
|
|
bootstrap_endpoint_fixture_t fixture;
|
|
bool fixture_ok = snapshot_ok && bootstrap_endpoint_fixture_start(&fixture, "enospc-host");
|
|
char cache[BOOTSTRAP_TEST_PATH_CAP] = {0};
|
|
char daemon_log[BOOTSTRAP_TEST_PATH_CAP] = {0};
|
|
char expected_path[BOOTSTRAP_TEST_PATH_CAP] = {0};
|
|
int cache_written =
|
|
fixture_ok ? snprintf(cache, sizeof(cache), "%s/cache", fixture.parent) : -1;
|
|
int log_written =
|
|
fixture_ok ? snprintf(daemon_log, sizeof(daemon_log), "%s/logs/cbm-daemon.log", cache) : -1;
|
|
int path_written = fixture_ok ? snprintf(expected_path, sizeof(expected_path), "%s.pending.tmp",
|
|
cbm_daemon_ipc_endpoint_address(fixture.endpoint))
|
|
: -1;
|
|
bool environment_ready = cache_written > 0 && cache_written < (int)sizeof(cache) &&
|
|
log_written > 0 && log_written < (int)sizeof(daemon_log) &&
|
|
path_written > 0 && path_written < (int)sizeof(expected_path) &&
|
|
cbm_mkdir_p(cache, 0700) && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0;
|
|
|
|
char self_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0};
|
|
bool identity_ready = environment_ready && cbm_daemon_runtime_process_build_fingerprint(
|
|
(uint64_t)getpid(), self_build);
|
|
static bootstrap_enospc_host_t host;
|
|
memset(&host, 0, sizeof(host));
|
|
(void)snprintf(host.parent, sizeof(host.parent), "%s", fixture_ok ? fixture.parent : "");
|
|
host.identity = bootstrap_identity("2.4.0", self_build);
|
|
|
|
cbm_daemon_bootstrap_config_t config = {
|
|
.role = CBM_DAEMON_PROCESS_MCP_CLIENT,
|
|
.endpoint = fixture.endpoint,
|
|
.identity = &host.identity,
|
|
.executable_path = "/enospc-host-test",
|
|
.connect_timeout_ms = 200,
|
|
.startup_timeout_ms = 30000,
|
|
};
|
|
cbm_daemon_bootstrap_result_t result;
|
|
memset(&result, 0, sizeof(result));
|
|
cbm_daemon_bootstrap_status_t status = CBM_DAEMON_BOOTSTRAP_FAILED;
|
|
if (identity_ready) {
|
|
cbm_daemon_ipc_posix_record_write_failure_set_for_test(ENOSPC);
|
|
cbm_daemon_bootstrap_spawn_override_set_for_test(bootstrap_enospc_host_spawn, &host);
|
|
status = cbm_daemon_bootstrap_execute(&config, &result);
|
|
cbm_daemon_bootstrap_spawn_override_set_for_test(NULL, NULL);
|
|
cbm_daemon_ipc_posix_record_write_failure_set_for_test(0);
|
|
}
|
|
int nonzero_exits = 0;
|
|
bootstrap_enospc_reap(&host, &nonzero_exits);
|
|
|
|
static char log[BOOTSTRAP_ENOSPC_LOG_CAP];
|
|
log[0] = '\0';
|
|
bool log_read = bootstrap_read_file(daemon_log, log, sizeof(log));
|
|
const char *listen_failed = strstr(log, "msg=daemon.ipc.listen_failed");
|
|
bool daemon_named_cause = listen_failed && strstr(listen_failed, "errno=ENOSPC") != NULL &&
|
|
strstr(listen_failed, expected_path) != NULL;
|
|
bool message_names_failure = strstr(result.message, "failed to start") != NULL;
|
|
bool message_names_errno = strstr(result.message, "ENOSPC") != NULL;
|
|
bool message_names_path = strstr(result.message, expected_path) != NULL;
|
|
bool stale_wording = strstr(result.message, "active or starting") != NULL;
|
|
|
|
if (saved_cache) {
|
|
(void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1);
|
|
} else {
|
|
(void)cbm_unsetenv("CBM_CACHE_DIR");
|
|
}
|
|
free(saved_cache);
|
|
if (fixture_ok) {
|
|
(void)th_rmtree(fixture.parent);
|
|
bootstrap_endpoint_fixture_finish(&fixture);
|
|
}
|
|
|
|
ASSERT_TRUE(snapshot_ok);
|
|
ASSERT_TRUE(fixture_ok);
|
|
ASSERT_TRUE(environment_ready);
|
|
ASSERT_TRUE(identity_ready);
|
|
ASSERT_EQ(status, CBM_DAEMON_BOOTSTRAP_FAILED);
|
|
ASSERT_TRUE(result.daemon_spawned);
|
|
ASSERT_TRUE(host.child_count >= 1);
|
|
ASSERT_TRUE(log_read);
|
|
ASSERT_TRUE(daemon_named_cause);
|
|
/* Fast-fail is proven by the MECHANISM, never by wall-clock (O9: a gate
|
|
* never asserts a transient timing window). The recorded ENOSPC cause is
|
|
* surfaced verbatim ("failed to start" + errno + path) and the slow
|
|
* "active or starting" timeout wording is absent -- that message is emitted
|
|
* ONLY on the fast-fail break (cbm_daemon_bootstrap_start_failure_format),
|
|
* never on the 30 s deadline path -- and the client stopped after exactly
|
|
* one spawn instead of respawning a doomed daemon until the deadline. Any
|
|
* regression to the pre-#1828 30 s hang trips these deterministically. */
|
|
ASSERT_FALSE(stale_wording);
|
|
ASSERT_TRUE(message_names_failure);
|
|
ASSERT_TRUE(message_names_errno);
|
|
ASSERT_TRUE(message_names_path);
|
|
ASSERT_EQ(host.child_count, 1U);
|
|
ASSERT_EQ(nonzero_exits, 0);
|
|
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_runtime_dir_env_relocates_rendezvous);
|
|
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_mute_endpoint_holder_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
|
|
#ifndef _WIN32
|
|
RUN_TEST(daemon_bootstrap_start_failure_record_round_trip);
|
|
RUN_TEST(daemon_bootstrap_fails_fast_when_daemon_dies_at_publication);
|
|
#endif
|
|
}
|