mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
Merge pull request #2412 from DeusData/fix/activation-unknown-daemon-is-foreign
fix(cli): never stop a daemon whose cache is not confirmed as ours
This commit is contained in:
3 files changed
+203
-12
No files matched your search
+60
-12
@@ -226,6 +226,7 @@ typedef struct {
|
||||
static cbm_cli_activation_ops_t g_cli_activation_test_ops;
|
||||
static bool g_cli_activation_test_ops_set = false;
|
||||
static const char *g_cli_activation_runtime_parent_for_test = NULL;
|
||||
static bool g_cli_activation_scope_cache_unreadable_for_test = false;
|
||||
|
||||
static void cli_activation_diagnostic(const cbm_cli_activation_ops_t *ops, const char *message) {
|
||||
const char *diagnostic = message ? message : CLI_ACTIVATION_REFUSED_MESSAGE;
|
||||
@@ -335,6 +336,10 @@ const char *cbm_cli_activation_runtime_parent_for_test(void) {
|
||||
return g_cli_activation_runtime_parent_for_test;
|
||||
}
|
||||
|
||||
void cbm_cli_set_activation_scope_cache_unreadable_for_test(bool unreadable) {
|
||||
g_cli_activation_scope_cache_unreadable_for_test = unreadable;
|
||||
}
|
||||
|
||||
static const char *cli_activation_action_text(cbm_daemon_runtime_activation_action_t action) {
|
||||
switch (action) {
|
||||
case CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL:
|
||||
@@ -541,6 +546,7 @@ typedef enum {
|
||||
CLI_ACTIVATION_SCOPE_ACTIVE = 0,
|
||||
CLI_ACTIVATION_SCOPE_NOTHING_TO_REPLACE,
|
||||
CLI_ACTIVATION_SCOPE_FOREIGN_COHORT,
|
||||
CLI_ACTIVATION_SCOPE_UNCONFIRMED,
|
||||
CLI_ACTIVATION_SCOPE_ERROR,
|
||||
} cli_activation_scope_t;
|
||||
|
||||
@@ -566,9 +572,43 @@ static void cli_activation_log_guard_decision(const cli_activation_production_co
|
||||
* anything at all (a --skip-binary install without an index reset publishes
|
||||
* nothing), and whose cohort is active. Admission with an immediate deadline
|
||||
* is the cheapest authoritative read of the active lifetime record: OK means
|
||||
* the cohort is ours or empty, CONFLICT names the active identity (its cache
|
||||
* fingerprint is filled for every conflict kind), BUSY means another
|
||||
* activation holds maintenance and the barrier waits for it as before. */
|
||||
* the cohort shares this exact identity (cache included) or is empty,
|
||||
* CONFLICT names the active identity (its cache fingerprint is filled for
|
||||
* every conflict kind), BUSY means another activation or a session start held
|
||||
* the group, so the active identity was never read.
|
||||
*
|
||||
* Only a POSITIVE match may drain (user decision 2026-09-28, "unknown =
|
||||
* foreign, keep it"): the daemon's activation-shutdown handler verifies the
|
||||
* requester's build, never its cache, so this decision is the only thing
|
||||
* that keeps another HOME's install/update from stopping the account's live
|
||||
* daemon. A busy group or an unreadable active cache used to fall through to
|
||||
* "ours" and stop every session behind that daemon. */
|
||||
static cli_activation_scope_t cli_activation_scope_unconfirmed(
|
||||
cli_activation_production_context_t *context, const char *scope, const char *reason) {
|
||||
const char *action = cli_activation_action_text(context->action);
|
||||
(void)snprintf(context->scope_detail, sizeof(context->scope_detail),
|
||||
"active cohort ownership unconfirmed (%s); scope=%s; nothing stopped", reason,
|
||||
scope);
|
||||
cli_activation_log_guard_decision(context, "unconfirmed_cohort", NULL);
|
||||
/* Name the daemon being left: a read-only status probe (no cohort, no
|
||||
* HELLO, no admission) that works across build skew. */
|
||||
cbm_daemon_runtime_status_t daemon;
|
||||
if (cbm_daemon_runtime_request_status(context->endpoint, &context->identity,
|
||||
CLI_ACTIVATION_CONTROL_TIMEOUT_MS, &daemon)) {
|
||||
printf("Leaving the running CBM daemon untouched (pid %lu, version %s): this %s could "
|
||||
"not confirm that it serves the cache this %s targets (%s).\n",
|
||||
(unsigned long)daemon.daemon_pid, daemon.semantic_version, action, action, reason);
|
||||
} else {
|
||||
printf("Leaving active CBM sessions untouched: this %s could not confirm that they "
|
||||
"serve the cache it targets (%s).\n",
|
||||
action, reason);
|
||||
}
|
||||
printf("If it is yours, close its CBM sessions and run `codebase-memory-mcp daemon stop` "
|
||||
"to stop it yourself.\n");
|
||||
(void)fflush(stdout);
|
||||
return CLI_ACTIVATION_SCOPE_UNCONFIRMED;
|
||||
}
|
||||
|
||||
static cli_activation_scope_t cli_activation_resolve_scope(
|
||||
cli_activation_production_context_t *context) {
|
||||
const char *runtime_dir = cbm_daemon_ipc_endpoint_runtime_dir(context->endpoint);
|
||||
@@ -594,20 +634,27 @@ static cli_activation_scope_t cli_activation_resolve_scope(
|
||||
if (lease) {
|
||||
return CLI_ACTIVATION_SCOPE_ERROR;
|
||||
}
|
||||
if (g_cli_activation_scope_cache_unreadable_for_test) {
|
||||
conflict.active_cache_fingerprint[0] = '\0';
|
||||
}
|
||||
switch (status) {
|
||||
case CBM_VERSION_COHORT_OK:
|
||||
case CBM_VERSION_COHORT_BUSY:
|
||||
return CLI_ACTIVATION_SCOPE_ACTIVE;
|
||||
case CBM_VERSION_COHORT_BUSY:
|
||||
return cli_activation_scope_unconfirmed(
|
||||
context, scope, "the session group was busy, so its cache identity could not be read");
|
||||
case CBM_VERSION_COHORT_CONFLICT:
|
||||
break;
|
||||
default:
|
||||
return CLI_ACTIVATION_SCOPE_ERROR;
|
||||
}
|
||||
if (!conflict.active_cache_fingerprint[0] ||
|
||||
strcmp(conflict.active_cache_fingerprint, context->cache_fingerprint) == 0) {
|
||||
/* Same cache namespace, another build or version: that IS the daemon
|
||||
* this activation replaces. An unreadable active cache stays in scope
|
||||
* rather than silently exempting a same-namespace daemon. */
|
||||
if (!conflict.active_cache_fingerprint[0]) {
|
||||
return cli_activation_scope_unconfirmed(context, scope,
|
||||
"its cache identity could not be read");
|
||||
}
|
||||
if (strcmp(conflict.active_cache_fingerprint, context->cache_fingerprint) == 0) {
|
||||
/* Positively confirmed: same cache namespace, another build or
|
||||
* version. That IS the daemon this activation replaces. */
|
||||
return CLI_ACTIVATION_SCOPE_ACTIVE;
|
||||
}
|
||||
(void)snprintf(context->scope_detail, sizeof(context->scope_detail),
|
||||
@@ -636,9 +683,10 @@ static int cli_activation_production_reserve(void *opaque, cbm_cli_activation_lo
|
||||
}
|
||||
if (scope != CLI_ACTIVATION_SCOPE_ACTIVE) {
|
||||
/* Nothing in the target namespace is being replaced, or the only
|
||||
* active cohort serves another namespace: hold no maintenance,
|
||||
* admission, lifetime or startup lock (each wakes or blocks the other
|
||||
* namespace's sessions) and send no drain request. */
|
||||
* active cohort serves another namespace, or its ownership could not
|
||||
* be confirmed: hold no maintenance, admission, lifetime or startup
|
||||
* lock (each wakes or blocks the other namespace's sessions) and
|
||||
* send no drain request. */
|
||||
if (!cli_activation_log_event(context, "quiesce_skipped", context->scope_detail)) {
|
||||
return CLI_ERR;
|
||||
}
|
||||
|
||||
@@ -505,6 +505,12 @@ void cbm_cli_set_activation_ops_for_test(const cbm_cli_activation_ops_t *ops);
|
||||
void cbm_cli_set_activation_runtime_parent_for_test(const char *runtime_parent);
|
||||
const char *cbm_cli_activation_runtime_parent_for_test(void);
|
||||
|
||||
/* Internal integration-test seam: the activation scope read reports the active
|
||||
* cohort's cache fingerprint as unreadable (blank), exactly what the scope
|
||||
* decision sees when that field cannot be recovered. false restores the real
|
||||
* read. Not a command-line or environment override. */
|
||||
void cbm_cli_set_activation_scope_cache_unreadable_for_test(bool unreadable);
|
||||
|
||||
/* ── Subcommands (wired from main.c) ─────────────────────────── */
|
||||
|
||||
/* install: copy binary, install skills, install editor MCP configs, ensure PATH.
|
||||
|
||||
@@ -18,11 +18,13 @@
|
||||
#include <cli/cli.h>
|
||||
#include <cli/progress_sink.h>
|
||||
#include <daemon/bootstrap.h>
|
||||
#include <daemon/ipc.h>
|
||||
#include <daemon/runtime.h>
|
||||
#include <daemon/version_cohort.h>
|
||||
#include <foundation/constants.h>
|
||||
#include <foundation/log.h>
|
||||
#include <foundation/platform.h>
|
||||
#include <foundation/private_file_lock.h>
|
||||
#include <foundation/sha256.h>
|
||||
#include <mcp/mcp.h>
|
||||
#include <mcp/index_supervisor.h>
|
||||
@@ -2037,6 +2039,139 @@ TEST(cli_install_skip_binary_unchanged_in_host_namespace_quiesces_nothing) {
|
||||
ASSERT_TRUE(nothing_drained);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* cli_scope_install with the activation's stdout captured into out. */
|
||||
static int cli_scope_install_captured(cli_scope_fixture_t *fixture, const char *home,
|
||||
const char *cache, const char *bin_dir, bool skip_binary,
|
||||
char *out, size_t out_size) {
|
||||
out[0] = '\0';
|
||||
FILE *capture = tmpfile();
|
||||
int saved_stdout = capture ? dup(STDOUT_FILENO) : -1;
|
||||
bool redirected = false;
|
||||
if (capture && saved_stdout >= 0) {
|
||||
fflush(stdout);
|
||||
redirected = dup2(fileno(capture), STDOUT_FILENO) >= 0;
|
||||
}
|
||||
int rc = redirected ? cli_scope_install(fixture, home, cache, bin_dir, skip_binary) : -1;
|
||||
if (redirected) {
|
||||
fflush(stdout);
|
||||
(void)dup2(saved_stdout, STDOUT_FILENO);
|
||||
}
|
||||
if (saved_stdout >= 0) {
|
||||
close(saved_stdout);
|
||||
}
|
||||
if (capture) {
|
||||
rewind(capture);
|
||||
size_t count = fread(out, 1, out_size - 1U, capture);
|
||||
out[count] = '\0';
|
||||
fclose(capture);
|
||||
}
|
||||
return rc;
|
||||
}
|
||||
|
||||
/* The notice an activation prints when it leaves a daemon it could not
|
||||
* confirm as its own: names the daemon by pid and tells the user how to stop
|
||||
* it themselves. Never the "Stopping ..." banner of a drain. */
|
||||
static bool cli_scope_kept_notice_names_host(const char *output, pid_t host) {
|
||||
char pid_text[48];
|
||||
snprintf(pid_text, sizeof(pid_text), "(pid %ld, version %s)", (long)host, CBM_VERSION);
|
||||
return strstr(output, "Leaving the running CBM daemon untouched") != NULL &&
|
||||
strstr(output, pid_text) != NULL &&
|
||||
strstr(output, "codebase-memory-mcp daemon stop") != NULL &&
|
||||
strstr(output, "Stopping active CBM sessions") == NULL;
|
||||
}
|
||||
|
||||
/* (e) USER DECISION 2026-09-28, "unknown = foreign, keep it": the scope read
|
||||
* reaches the active cohort but cannot read its cache identity. Ownership is
|
||||
* unconfirmed, so the sandbox install must leave the host daemon serving,
|
||||
* stop no session, and say which daemon it left and how to stop it. The
|
||||
* install publishes a binary (no --skip-binary): an activation that replaces
|
||||
* nothing never consults the cohort at all (test d). */
|
||||
TEST(cli_install_foreign_home_unreadable_cohort_cache_keeps_host_daemon) {
|
||||
cli_scope_fixture_t fixture;
|
||||
bool ready = cli_scope_fixture_start(&fixture, "unread");
|
||||
char foreign_home[512];
|
||||
char foreign_cache[576];
|
||||
char foreign_bin[640];
|
||||
char activation_log[704];
|
||||
cli_scope_foreign_paths(&fixture, foreign_home, foreign_cache, foreign_bin, activation_log);
|
||||
bool prepared = ready && cbm_mkdir_p(foreign_home, 0700);
|
||||
char output[16384];
|
||||
output[0] = '\0';
|
||||
cbm_cli_set_activation_scope_cache_unreadable_for_test(true);
|
||||
int install_rc = prepared
|
||||
? cli_scope_install_captured(&fixture, foreign_home, foreign_cache,
|
||||
foreign_bin, false, output, sizeof(output))
|
||||
: -1;
|
||||
cbm_cli_set_activation_scope_cache_unreadable_for_test(false);
|
||||
bool host_serving = prepared && cli_scope_host_serving(&fixture);
|
||||
bool notice = cli_scope_kept_notice_names_host(output, fixture.host);
|
||||
const char *events = read_test_file(activation_log);
|
||||
bool completed = events && strstr(events, "\"phase\":\"completed\"") != NULL;
|
||||
bool nothing_drained = events && strstr(events, "cohort drained") == NULL;
|
||||
int host_exit = cli_scope_fixture_finish(&fixture);
|
||||
|
||||
ASSERT_TRUE(ready);
|
||||
ASSERT_TRUE(host_serving);
|
||||
ASSERT_EQ(host_exit, 0);
|
||||
ASSERT_TRUE(nothing_drained);
|
||||
ASSERT_TRUE(notice);
|
||||
ASSERT_EQ(install_rc, 0);
|
||||
ASSERT_TRUE(completed);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* (f) Same decision, busy group: another activation holds the cohort
|
||||
* maintenance gate, so the scope read cannot reach the active identity at
|
||||
* all. Unconfirmed ownership keeps the host daemon; the sandbox install
|
||||
* completes without draining anyone. The gate is held through the product's
|
||||
* own lock-directory handle on the name version_cohort.c uses. */
|
||||
TEST(cli_install_foreign_home_busy_cohort_keeps_host_daemon) {
|
||||
cli_scope_fixture_t fixture;
|
||||
bool ready = cli_scope_fixture_start(&fixture, "busy");
|
||||
char foreign_home[512];
|
||||
char foreign_cache[576];
|
||||
char foreign_bin[640];
|
||||
char activation_log[704];
|
||||
cli_scope_foreign_paths(&fixture, foreign_home, foreign_cache, foreign_bin, activation_log);
|
||||
cbm_private_lock_directory_t *lock_directory = NULL;
|
||||
cbm_private_file_lock_t *maintenance = NULL;
|
||||
bool held = ready &&
|
||||
cbm_daemon_ipc_private_lock_directory_new(fixture.endpoint, &lock_directory) ==
|
||||
CBM_PRIVATE_FILE_LOCK_OK &&
|
||||
cbm_private_file_lock_try_acquire(
|
||||
lock_directory, "cbm-version-cohort-maintenance-v1.lock",
|
||||
CBM_PRIVATE_FILE_LOCK_EX, &maintenance) == CBM_PRIVATE_FILE_LOCK_OK;
|
||||
bool prepared = held && cbm_mkdir_p(foreign_home, 0700);
|
||||
char output[16384];
|
||||
output[0] = '\0';
|
||||
int install_rc = prepared
|
||||
? cli_scope_install_captured(&fixture, foreign_home, foreign_cache,
|
||||
foreign_bin, false, output, sizeof(output))
|
||||
: -1;
|
||||
bool host_serving = prepared && cli_scope_host_serving(&fixture);
|
||||
bool released =
|
||||
maintenance && cbm_private_file_lock_release(&maintenance) == CBM_PRIVATE_FILE_LOCK_OK;
|
||||
if (lock_directory) {
|
||||
cbm_private_lock_directory_close(lock_directory);
|
||||
}
|
||||
bool notice = cli_scope_kept_notice_names_host(output, fixture.host);
|
||||
const char *events = read_test_file(activation_log);
|
||||
bool completed = events && strstr(events, "\"phase\":\"completed\"") != NULL;
|
||||
bool nothing_drained = events && strstr(events, "cohort drained") == NULL;
|
||||
int host_exit = cli_scope_fixture_finish(&fixture);
|
||||
|
||||
ASSERT_TRUE(ready);
|
||||
ASSERT_TRUE(held);
|
||||
ASSERT_TRUE(host_serving);
|
||||
ASSERT_EQ(host_exit, 0);
|
||||
ASSERT_TRUE(nothing_drained);
|
||||
ASSERT_TRUE(notice);
|
||||
ASSERT_EQ(install_rc, 0);
|
||||
ASSERT_TRUE(completed);
|
||||
ASSERT_TRUE(released);
|
||||
PASS();
|
||||
}
|
||||
#endif
|
||||
|
||||
TEST(cli_install_force_quiesces_active_cohort_before_replacing_binary) {
|
||||
@@ -16504,6 +16639,8 @@ SUITE(cli) {
|
||||
RUN_TEST(cli_install_binary_into_foreign_home_never_drains_host_cohort);
|
||||
RUN_TEST(cli_install_into_host_namespace_still_drains_host_cohort);
|
||||
RUN_TEST(cli_install_skip_binary_unchanged_in_host_namespace_quiesces_nothing);
|
||||
RUN_TEST(cli_install_foreign_home_unreadable_cohort_cache_keeps_host_daemon);
|
||||
RUN_TEST(cli_install_foreign_home_busy_cohort_keeps_host_daemon);
|
||||
#endif
|
||||
RUN_TEST(cli_install_force_quiesces_active_cohort_before_replacing_binary);
|
||||
RUN_TEST(cli_install_dir_and_skip_config_stage_first_install_safely);
|
||||
|
||||
Reference in new issue
Block a user