mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
fix(cli): a refused UI configuration is not a failed daemon start
`daemon start` exited nonzero when the daemon came up but did not answer the UI configuration handshake. By that point the daemon is running -- the control connection above it already satisfied the startup window -- so the command reported failure for something it had already achieved. The two requests in that handshake are bounded at MAIN_CONNECT_TIMEOUT_MS, one second. On a loaded machine, especially right after an abrupt shutdown, that is thin. Windows CI hit it repeatedly: the crash-recovery section of `tests/windows/test_daemon_stability.py` hard-kills a daemon and immediately starts a fresh one, and the report that came back was that no fresh daemon had started -- when one had, and was serving. Whether the refusal is fatal now depends on what was asked for. `--port` and `--open` make the UI the point of the command and keep the existing nonzero exit. A bare `daemon start` asks for a daemon and got one, so it now warns and succeeds; it also skips the UI-warming notice, which would otherwise announce a port that nothing is serving. A seam forces the refusal, because reproducing it for real needs a machine loaded enough to miss a bounded handshake -- not a state a test can ask for. The lifecycle guard covers both halves of the contract, and with the decision reverted it reports the bare-start case as red. Signed-off-by: 刘冲 <mail@liuchong.dev>
This commit is contained in:
2 files changed
+104
-9
No files matched your search
+47
-8
@@ -2175,6 +2175,26 @@ static void main_daemon_ctl_open_browser(int port) {
|
||||
}
|
||||
}
|
||||
|
||||
/* Ask the daemon to enable the UI on `port`; true when it accepted.
|
||||
*
|
||||
* The seam below forces the refusal that the caller must survive. Reproducing it
|
||||
* for real needs a machine loaded enough to miss a bounded handshake, which is
|
||||
* not a state a test can ask for; it is COMPILED OUT of ordinary builds (see
|
||||
* TEST_SEAMS in Makefile.cbm). */
|
||||
static bool main_daemon_ctl_apply_ui_config(cbm_daemon_runtime_client_t *client,
|
||||
uint8_t update_mask, int port) {
|
||||
#ifdef CBM_ENABLE_TEST_SEAMS
|
||||
char forced[8];
|
||||
if (cbm_safe_getenv("CBM_TEST_DAEMON_UI_CONFIG_REFUSED", forced, sizeof(forced), NULL) &&
|
||||
forced[0] == '1') {
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
return cbm_daemon_application_client_set_ui_config(client, update_mask, true, port,
|
||||
MAIN_CONNECT_TIMEOUT_MS) ==
|
||||
CBM_DAEMON_RUNTIME_APPLICATION_OK;
|
||||
}
|
||||
|
||||
static int main_daemon_ctl_finish_ui_open(cbm_daemon_runtime_client_t **client_io, int port,
|
||||
bool open_browser) {
|
||||
if (!open_browser) {
|
||||
@@ -2380,6 +2400,7 @@ static int main_run_daemon_ctl(int argc, char **argv, const cbm_daemon_ipc_endpo
|
||||
/* The committed control connection satisfied the daemon's no-client
|
||||
* startup window; configure the UI before departing. */
|
||||
int ui_port = 0;
|
||||
bool ui_configured = false;
|
||||
if ((CBM_EMBEDDED_FILE_COUNT > 0)) {
|
||||
cbm_ui_config_t ui_config;
|
||||
cbm_ui_config_load(&ui_config);
|
||||
@@ -2388,14 +2409,30 @@ static int main_run_daemon_ctl(int argc, char **argv, const cbm_daemon_ipc_endpo
|
||||
bool context_set =
|
||||
main_set_client_context(start_result.client, ".", CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL,
|
||||
MAIN_CONNECT_TIMEOUT_MS);
|
||||
if (!context_set || cbm_daemon_application_client_set_ui_config(
|
||||
start_result.client, update_mask, true, ui_port,
|
||||
MAIN_CONNECT_TIMEOUT_MS) != CBM_DAEMON_RUNTIME_APPLICATION_OK) {
|
||||
ui_configured = context_set &&
|
||||
main_daemon_ctl_apply_ui_config(start_result.client, update_mask, ui_port);
|
||||
if (!ui_configured) {
|
||||
/* Reaching here means the daemon is up: the control connection above
|
||||
* already satisfied its startup window. Only the UI handshake — two
|
||||
* requests bounded by MAIN_CONNECT_TIMEOUT_MS — came back short.
|
||||
*
|
||||
* Whether that is fatal depends on what was asked for. `--port`/`--open`
|
||||
* make the UI the point of the command, so failing to configure it is a
|
||||
* failed command. A bare `daemon start` asks for a daemon, and it got
|
||||
* one; reporting failure there sent operators hunting for a daemon that
|
||||
* was in fact running and healthy. It also made a loaded machine look
|
||||
* like a broken one, because a second of scheduling delay after an
|
||||
* abrupt shutdown is enough to miss this handshake. */
|
||||
if (requested_port > 0 || open_browser) {
|
||||
(void)fprintf(stderr,
|
||||
"error: the daemon did not accept the UI configuration; browser was "
|
||||
"not opened\n");
|
||||
(void)cbm_daemon_runtime_client_close(start_result.client, MAIN_CLOSE_TIMEOUT_MS);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
(void)fprintf(stderr,
|
||||
"error: the daemon did not accept the UI configuration; browser was "
|
||||
"not opened\n");
|
||||
(void)cbm_daemon_runtime_client_close(start_result.client, MAIN_CLOSE_TIMEOUT_MS);
|
||||
return EXIT_FAILURE;
|
||||
"warning: the daemon started but did not accept the UI configuration; "
|
||||
"the UI is unavailable until the next `daemon start`\n");
|
||||
}
|
||||
} else if (requested_port > 0 || open_browser) {
|
||||
(void)fprintf(stderr, "warning: this binary was built without UI support; "
|
||||
@@ -2412,8 +2449,10 @@ static int main_run_daemon_ctl(int argc, char **argv, const cbm_daemon_ipc_endpo
|
||||
}
|
||||
printf("It survives idle periods and session ends; `codebase-memory-mcp daemon stop` "
|
||||
"retires it.\n");
|
||||
/* Skipped when the handshake was refused: that path announces the port as
|
||||
* warming, which would be a promise nothing is keeping. */
|
||||
int ui_result =
|
||||
(CBM_EMBEDDED_FILE_COUNT > 0)
|
||||
(CBM_EMBEDDED_FILE_COUNT > 0 && ui_configured)
|
||||
? main_daemon_ctl_finish_ui_open(&start_result.client, ui_port, open_browser)
|
||||
: EXIT_SUCCESS;
|
||||
if (start_result.client) {
|
||||
|
||||
@@ -24,9 +24,11 @@ import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def run_cli(binary, cache, args, timeout=60):
|
||||
def run_cli(binary, cache, args, timeout=60, extra_env=None):
|
||||
env = dict(os.environ)
|
||||
env["CBM_CACHE_DIR"] = cache
|
||||
if extra_env:
|
||||
env.update(extra_env)
|
||||
return subprocess.run([binary] + args, capture_output=True, timeout=timeout, env=env)
|
||||
|
||||
|
||||
@@ -81,6 +83,8 @@ def main():
|
||||
% start_text[:400])
|
||||
return 1
|
||||
print("PASS: daemon start reported permanent pid %d" % daemon_pid)
|
||||
# Only a UI build configures a UI, and only it can refuse to.
|
||||
ui_build = "ui:" in start_text
|
||||
|
||||
warm = run_cli(binary, cache, ["cli", "list_projects", "{}"])
|
||||
warm_text = output_text(warm)
|
||||
@@ -107,6 +111,58 @@ def main():
|
||||
% output_text(stop_again)[:300])
|
||||
return 1
|
||||
print("PASS: stop retired the idle daemon; second stop was idempotent")
|
||||
daemon_pid = 0
|
||||
|
||||
if not ui_build:
|
||||
print("SKIP: non-UI binary cannot exercise the UI-configuration refusal")
|
||||
else:
|
||||
refused_env = {"CBM_TEST_DAEMON_UI_CONFIG_REFUSED": "1"}
|
||||
# A refused UI handshake must not be reported as a failed start. The
|
||||
# daemon is already up by then, so a bare `daemon start` got exactly
|
||||
# what it asked for; only `--port`/`--open` make the UI the point of
|
||||
# the command. Before the fix a busy machine — one second of delay
|
||||
# after an abrupt shutdown was enough — turned a healthy start into a
|
||||
# nonzero exit, which is how this reached CI as a phantom failure.
|
||||
refused = run_cli(binary, cache, ["daemon", "start"], extra_env=refused_env)
|
||||
refused_text = output_text(refused)
|
||||
pid_match = re.search(r"pid (\d+)", refused_text)
|
||||
daemon_pid = int(pid_match.group(1)) if pid_match else 0
|
||||
if refused.returncode != 0 or "permanent" not in refused_text:
|
||||
print("RED: a refused UI configuration must not fail `daemon start`; "
|
||||
"the daemon is already running by then:\n%s" % refused_text[:400])
|
||||
return 1
|
||||
if "warning" not in refused_text:
|
||||
print("RED: a refused UI configuration must still be reported, as a "
|
||||
"warning:\n%s" % refused_text[:400])
|
||||
return 1
|
||||
alive = run_cli(binary, cache, ["daemon", "status"])
|
||||
if alive.returncode != 0 or "permanent" not in output_text(alive):
|
||||
print("RED: the daemon that survived a refused UI configuration should "
|
||||
"be reachable:\n%s" % output_text(alive)[:400])
|
||||
return 1
|
||||
print("PASS: a refused UI configuration warns and leaves the daemon running")
|
||||
|
||||
stopped = run_cli(binary, cache, ["daemon", "stop"])
|
||||
if stopped.returncode != 0:
|
||||
print("RED: could not retire the daemon started without a UI:\n%s"
|
||||
% output_text(stopped)[:300])
|
||||
return 1
|
||||
daemon_pid = 0
|
||||
|
||||
# The other half of the contract: when the UI *was* asked for, failing
|
||||
# to configure it is still a failed command.
|
||||
demanded = run_cli(binary, cache, ["daemon", "start", "--port=45871"],
|
||||
extra_env=refused_env)
|
||||
demanded_text = output_text(demanded)
|
||||
pid_match = re.search(r"pid (\d+)", demanded_text)
|
||||
daemon_pid = int(pid_match.group(1)) if pid_match else 0
|
||||
if demanded.returncode == 0:
|
||||
print("RED: `daemon start --port=N` must fail when the UI it asked for "
|
||||
"could not be configured:\n%s" % demanded_text[:400])
|
||||
return 1
|
||||
print("PASS: an explicitly requested UI still fails the command when refused")
|
||||
run_cli(binary, cache, ["daemon", "stop"])
|
||||
daemon_pid = 0
|
||||
|
||||
print("\nGREEN: daemon lifecycle (status/start/recycle/stop) behaves.")
|
||||
return 0
|
||||
|
||||
Reference in new issue
Block a user