diff --git a/Makefile.cbm b/Makefile.cbm index a8ce45fd..1b4aeda2 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -72,7 +72,7 @@ CXXFLAGS_TSAN = $(CXXFLAGS_COMMON) -g -O1 \ -fsanitize=thread -fno-omit-frame-pointer # Windows needs ws2_32 (Winsock), psapi (GetProcessMemoryInfo), shell32 -# (CommandLineToArgvW — the UTF-8 argv path in main.c, #423/#20), ntdll +# (CommandLineToArgvW — the UTF-8 argv path in main.zig, #423/#20), ntdll # (Zig's Windows runtime), and --allow-multiple-definition (MinGW CRT clashes). # Auto-detected via compiler; no manual override needed. IS_MINGW := $(shell echo | $(CC) -dM -E - 2>/dev/null | grep -q 'define _WIN32 ' && echo yes || echo no) @@ -430,7 +430,7 @@ endif CBM_ZIG_SRC = src/cbm_zig.zig CBM_ZIG_SRCS = $(CBM_ZIG_SRC) src/cbm_zig_c.h src/cli_zig/cbm_cli_zig.zig src/cli/cli_zig.h \ src/cli/archive.zig src/cli/cli.zig src/cli/commands.zig src/cli/store.zig \ - src/cli/tool_args.zig \ + src/cli/tool_args.zig src/main.zig src/main.h \ src/cli/hook_augment.zig src/cli/progress_sink.zig src/cli/progress_sink.h \ src/cli/cli.h src/mcp/mcp.h src/pipeline/pipeline.h \ src/pipeline/worker_pool.h \ diff --git a/src/cbm_zig.zig b/src/cbm_zig.zig index e62f9a26..56ddab4c 100644 --- a/src/cbm_zig.zig +++ b/src/cbm_zig.zig @@ -53,6 +53,7 @@ comptime { checkExports(@import("foundation/yaml.zig")); checkExports(@import("git/git_context.zig")); checkExports(@import("graph_buffer/graph_buffer.zig")); + checkExports(@import("main.zig")); checkExports(@import("mcp/code_tools.zig")); checkExports(@import("mcp/graph_tools.zig")); checkExports(@import("mcp/index_supervisor.zig")); diff --git a/src/cbm_zig_c.h b/src/cbm_zig_c.h index 43b50c20..9fb7c136 100644 --- a/src/cbm_zig_c.h +++ b/src/cbm_zig_c.h @@ -40,6 +40,9 @@ #include #include #include +#include /* CommandLineToArgvW: not in windows.h under WIN32_LEAN_AND_MEAN */ +#include +#include #include "foundation/win_utf8.h" #include #else @@ -181,6 +184,7 @@ static inline FILE *cbm_zig_stderr(void) { #include "discover/userconfig.h" #include "git/git_context.h" #include "graph_buffer/graph_buffer.h" +#include "main.h" #include "mcp/index_supervisor.h" #include "pipeline/laravel_routing.h" #include "pipeline/lsp_resolve.h" diff --git a/src/cli/cli.h b/src/cli/cli.h index 18eaead3..f272f7ef 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -328,7 +328,7 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key); #define CBM_CONFIG_AUTO_INDEX_LIMIT "auto_index_limit" #define CBM_CONFIG_AUTO_WATCH "auto_watch" -/* ── Subcommands (wired from main.c) ─────────────────────────── */ +/* ── Subcommands (wired from main.zig) ─────────────────────────── */ /* install: copy binary, install skills, install editor MCP configs, ensure PATH. * Prompts to delete old indexes if any exist — rejects on "no". */ diff --git a/src/foundation/profile.h b/src/foundation/profile.h index 66b5471e..6505dbb5 100644 --- a/src/foundation/profile.h +++ b/src/foundation/profile.h @@ -2,7 +2,7 @@ * profile.h — Activatable fine-grained performance profiling. * * Enable via environment variable: CBM_PROFILE=1 (or any non-empty non-"0" value) - * Init is called once at program startup (from main.c). + * Init is called once at program startup (from main.zig). * * When disabled (default), the CBM_PROF_* macros cost one load + branch, * effectively zero overhead. diff --git a/src/main.c b/src/main.c index fca0f745..8d79afac 100644 --- a/src/main.c +++ b/src/main.c @@ -1,857 +1,20 @@ /* - * main.c — Entry point for codebase-memory-mcp. + * main.c — Entry point for codebase-memory-mcp. The program is cbm_main in + * main.zig; this file only hands it argv and the version. * - * Modes: - * (default) Run as MCP server on stdin/stdout (JSON-RPC 2.0) - * cli Run a single tool call and print result - * --version Print version and exit - * --help Print usage and exit - * - * Signal handling: SIGTERM/SIGINT trigger graceful shutdown. - * Watcher runs in a background thread, polling for git changes. + * main() stays C for two reasons: + * - The test runners link the same Zig library and define their own main(), + * so the library cannot define one. + * - The release version is a C define: scripts/build.sh passes + * -DCBM_VERSION through CFLAGS_EXTRA, and the Windows release links a Zig + * library that CI prebuilds without it. */ -#include "cbm.h" // cbm_alloc_init — bind 3rd-party allocators to mimalloc before any sqlite/git init -#include "mcp/mcp.h" -#include "mcp/index_supervisor.h" -#include "watcher/watcher.h" -#include "pipeline/pipeline.h" -#include "store/store.h" -#include "cli/cli.h" -#include "cli/progress_sink.h" -#include "zova/cbm_zova.h" -#include "zova/cbm_zova_coordination.h" -#include "zova/cbm_zova_operations.h" -#include "zova/cbm_zova_route.h" -#include "foundation/constants.h" - -enum { - MAIN_MIN_ARGC = 1, - MAIN_CLI_ARGC = 2, - PARENT_WATCHDOG_STACK_SIZE = 64 * CBM_SZ_1K, /* watchdog only polls — tiny stack suffices */ -}; -#define SLEN(s) (sizeof(s) - 1) -#include "foundation/log.h" -#include "foundation/diagnostics.h" -#include "foundation/platform.h" -#include "foundation/compat.h" -#include "foundation/compat_fs.h" -#include "foundation/compat_thread.h" -#include "foundation/mem.h" -#include "foundation/profile.h" -#include "foundation/win_utf8.h" /* cbm_wide_to_utf8 — Windows UTF-8 argv (#423/#20); no-op on POSIX */ -#ifdef _WIN32 -#include /* CommandLineToArgvW — not pulled in by windows.h under WIN32_LEAN_AND_MEAN */ -#include /* _write — async-safe stderr write in the parent watchdog */ -#include /* CreateToolhelp32Snapshot — parent PID discovery for the parent-death watchdog */ -#endif -#include - -#include -#include -#include -#include -#include -#ifndef _WIN32 -#include -#endif +#include "main.h" #ifndef CBM_VERSION #define CBM_VERSION "dev" #endif -/* ── Globals for signal handling ────────────────────────────────── */ - -static cbm_watcher_t *g_watcher = NULL; -static cbm_mcp_server_t *g_server = NULL; -static atomic_int g_shutdown = 0; - -/* Idempotent shutdown: cancels the active pipeline, stops background servers, - * and closes stdin to unblock the MCP read loop. Invoked from the signal - * handler and from the parent-death watchdog, hence the atomic_exchange guard - * so the body runs at most once. Body is async-signal-safe (only atomic stores - * and stop calls that themselves only set atomics). */ -static void request_shutdown(void) { - if (atomic_exchange(&g_shutdown, 1)) { - return; /* already shutting down */ - } - - /* Cancel any in-progress pipeline (async-signal-safe: only does atomic_store) */ - if (g_server) { - cbm_pipeline_t *p = cbm_mcp_server_active_pipeline(g_server); - if (p) { - cbm_pipeline_cancel(p); - } - } - /* Make pending index-lease and barrier waits return at once */ - cbm_zova_coord_set_cancelled(true); - - if (g_watcher) { - cbm_watcher_stop(g_watcher); - } - /* Close stdin to unblock getline in the MCP server loop */ - (void)fclose(stdin); -} - -static void signal_handler(int sig) { - (void)sig; - request_shutdown(); -} - -/* ── Parent-process watchdog ────────────────────────────────────── */ -/* parent-death watchdog — distilled from #407 (fixes #406, thanks @nvt-pankajsharma). - * - * When this stdio MCP server is launched by an agent that later dies without a - * clean SIGTERM (e.g. the editor is force-killed), the orphaned server would - * otherwise linger forever blocked on stdin. POSIX has no portable "notify on - * parent death" primitive (PR_SET_PDEATHSIG is Linux-only), so we poll getppid: - * once the parent dies the process is reparented (ppid changes, typically to 1) - * and we shut down. - * - * Windows (upstream #914, 92725a5e): job objects do not cover this case — the - * KILL_ON_JOB_CLOSE job in subprocess.c only wraps processes CBM spawns itself, - * and Windows never propagates a client's termination to the stdio server it - * spawned. The Windows branch waits on a SYNCHRONIZE handle to the parent - * instead: the kernel signals it exactly once on termination and the held - * handle pins the process object, so PID reuse cannot fool the wait. Workers - * keep the POSIX-only guard — they run inside CBM's own kill-on-close job. */ - -#ifndef _WIN32 -static void *parent_watchdog_thread(void *arg) { - pid_t initial_ppid = *(pid_t *)arg; - const unsigned int poll_interval_us = 500000; /* 500ms */ - - while (!atomic_load(&g_shutdown)) { - cbm_usleep(poll_interval_us); - if (atomic_load(&g_shutdown)) { - break; - } - /* initial_ppid > 1 guards against an already-orphaned start (ppid==1), - * where a changing ppid carries no signal. */ - if (initial_ppid > 1 && getppid() != initial_ppid) { - static const char msg[] = "level=warn msg=parent.exited reason=ppid_changed\n"; - (void)write(STDERR_FILENO, msg, sizeof(msg) - 1); - _exit(0); - } - } - return NULL; -} -#else /* _WIN32 */ -static void *parent_watchdog_thread(void *arg) { - HANDLE parent_process = *(HANDLE *)arg; - - /* The 500 ms timeout only re-checks g_shutdown, mirroring the POSIX poll. */ - while (!atomic_load(&g_shutdown)) { - DWORD wait_status = WaitForSingleObject(parent_process, 500); - if (wait_status == WAIT_OBJECT_0) { - static const char msg[] = "level=warn msg=parent.exited reason=handle_signaled\n"; - (void)_write(_fileno(stderr), msg, sizeof(msg) - 1); - _exit(0); - } - if (wait_status != WAIT_TIMEOUT) { - break; /* handle became unwaitable — stop watching, never spin */ - } - } - return NULL; -} - -/* Toolhelp is the documented way to learn one's own parent PID on Windows. - * Returns 0 when the lookup fails ("no parent signal available"). */ -static DWORD win_parent_pid_from_snapshot(void) { - DWORD parent_pid = 0; - HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); - if (snapshot == INVALID_HANDLE_VALUE) { - return 0; - } - PROCESSENTRY32W entry; - ZeroMemory(&entry, sizeof(entry)); - entry.dwSize = sizeof(entry); - DWORD self_pid = GetCurrentProcessId(); - if (Process32FirstW(snapshot, &entry)) { - do { - if (entry.th32ProcessID == self_pid) { - parent_pid = entry.th32ParentProcessID; - break; - } - } while (Process32NextW(snapshot, &entry)); - } - CloseHandle(snapshot); - return parent_pid; -} -#endif - -/* ── Watcher background thread ──────────────────────────────────── */ - -static void *watcher_thread(void *arg) { - cbm_watcher_t *w = arg; -#define WATCHER_BASE_INTERVAL_MS 5000 - - cbm_watcher_run(w, WATCHER_BASE_INTERVAL_MS); - return NULL; -} - -/* ── Index callback for watcher ─────────────────────────────────── */ - -static int watcher_index_fn(const char *project_name, const char *root_path, void *user_data) { - (void)user_data; - - /* Skip indexing if shutdown is in progress (skipped, not indexed) */ - if (atomic_load(&g_shutdown)) { - return 1; - } - - /* Non-blocking: skip if another process or thread is already indexing this - * workspace, or a database replacement is in progress. The watcher retries - * on its next poll cycle (5-60s). */ - cbm_zova_index_hold_t hold; - cbm_zova_coord_status_t hold_status = cbm_mcp_index_hold_acquire(root_path, 0, &hold, NULL, 0); - if (hold_status != CBM_ZOVA_COORD_OK) { - cbm_log_info("watcher.skip", "project", project_name, "reason", - cbm_mcp_index_hold_refusal_reason(hold_status, &hold)); - return 1; /* skipped: the watcher keeps the change and retries */ - } - - cbm_log_info("watcher.reindex", "project", project_name, "path", root_path); - - /* #832: route the re-index through the supervised worker subprocess so this - * long-lived server process hands its RSS back to the OS on every cycle - * instead of ratcheting (mimalloc v3 does not reclaim pages that worker - * threads abandon at exit). The child writes the DB; the parent only needs the - * return code and holds the index hold for the child's life. - * Degrade to the in-process pipeline when the supervisor is off (kill switch) - * or the spawn fails. */ - if (cbm_index_supervisor_should_wrap()) { - char *resp = cbm_mcp_index_run_supervised_path(root_path); - if (resp) { - free(resp); - cbm_zova_index_hold_release(&hold); - return 0; - } - /* resp == NULL → spawn-failure degrade → fall through to in-process. */ - } - - cbm_pipeline_t *p = cbm_pipeline_new(root_path, NULL, CBM_MODE_FULL); - if (!p) { - cbm_zova_index_hold_release(&hold); - return CBM_NOT_FOUND; - } - - int rc = cbm_pipeline_run(p); - cbm_pipeline_free(p); - cbm_zova_index_hold_release(&hold); - return rc; -} - -/* ── CLI mode ───────────────────────────────────────────────────── */ - -#define CLI_USAGE "Usage: codebase-memory-mcp cli [--progress] [--json] [json_args]\n" - -/* Extract text content from MCP tool result envelope and print it. - * MCP results: {"content":[{"type":"text","text":"..."}],"isError":...} - * Returns 1 if the result was an error, 0 otherwise. */ -static int cli_print_mcp_result(const char *result) { - yyjson_doc *doc = yyjson_read(result, strlen(result), 0); - if (!doc) { - printf("%s\n", result); - return 0; - } - - yyjson_val *root = yyjson_doc_get_root(doc); - yyjson_val *err_val = yyjson_obj_get(root, "isError"); - bool is_error = err_val && yyjson_get_bool(err_val); - - const char *text = NULL; - yyjson_val *content = yyjson_obj_get(root, "content"); - if (yyjson_is_arr(content) && yyjson_arr_size(content) > 0) { - yyjson_val *tv = yyjson_obj_get(yyjson_arr_get_first(content), "text"); - text = tv ? yyjson_get_str(tv) : NULL; - } - - if (text) { - (void)fprintf(is_error ? stderr : stdout, "%s\n", text); - } else { - printf("%s\n", result); - } - - yyjson_doc_free(doc); - return is_error ? SKIP_ONE : 0; -} - -/* Strip a flag from argv, returning true if found. */ -static bool cli_strip_flag(int *argc, char **argv, const char *flag) { - for (int i = 0; i < *argc; i++) { - if (strcmp(argv[i], flag) != 0) { - continue; - } - for (int j = i; j < *argc - SKIP_ONE; j++) { - argv[j] = argv[j + SKIP_ONE]; - } - (*argc)--; - return true; - } - return false; -} - -/* Strip a flag AND its following value from argv, returning the value (a pointer - * into the original argv strings, valid for the process lifetime) or NULL if the - * flag is absent. */ -static const char *cli_strip_flag_value(int *argc, char **argv, const char *flag) { - for (int i = 0; i < *argc; i++) { - if (strcmp(argv[i], flag) != 0) { - continue; - } - const char *value = (i + SKIP_ONE < *argc) ? argv[i + SKIP_ONE] : NULL; - int remove_count = value ? 2 : 1; - for (int j = i; j < *argc - remove_count; j++) { - argv[j] = argv[j + remove_count]; - } - *argc -= remove_count; - return value; - } - return NULL; -} - -/* Portable "is fd a terminal?" — _isatty on Windows, isatty on POSIX. */ -#ifdef _WIN32 -#define cli_isatty(fd) _isatty(fd) -#else -#define cli_isatty(fd) isatty(fd) -#endif - -enum { CLI_SLURP_CHUNK = 4096 }; - -/* Read an open stream fully into a heap, NUL-terminated string. Caller frees. - * Returns NULL on allocation failure. Reads binary-clean (UTF-8 JSON, no shell - * quoting needed). */ -static char *cli_slurp_stream(FILE *f) { - size_t cap = CLI_SLURP_CHUNK; - size_t len = 0; - char *buf = malloc(cap); - if (!buf) { - return NULL; - } - char tmp[CLI_SLURP_CHUNK]; - size_t n; - while ((n = fread(tmp, 1, sizeof(tmp), f)) > 0) { - if (len + n + 1 > cap) { - while (len + n + 1 > cap) { - cap *= 2; - } - char *nb = realloc(buf, cap); - if (!nb) { - free(buf); - return NULL; - } - buf = nb; - } - memcpy(buf + len, tmp, n); - len += n; - } - buf[len] = '\0'; - return buf; -} - -/* Slurp a file path into a heap, NUL-terminated string. Caller frees. */ -static char *cli_slurp_file(const char *path) { - FILE *f = cbm_fopen(path, "rb"); - if (!f) { - return NULL; - } - char *s = cli_slurp_stream(f); - (void)fclose(f); - return s; -} - -/* True if the first non-whitespace byte of s is '{' (raw-JSON detection). */ -static bool cli_first_nonspace_is_brace(const char *s) { - while (*s == ' ' || *s == '\t' || *s == '\n' || *s == '\r') { - s++; - } - return *s == '{'; -} - -static int run_cli(int argc, char **argv) { - if (argc < MAIN_MIN_ARGC) { - (void)fprintf(stderr, CLI_USAGE); - return SKIP_ONE; - } - - bool progress = cli_strip_flag(&argc, argv, "--progress"); - bool raw_json = cli_strip_flag(&argc, argv, "--json"); - - /* Supervisor worker role: when this process was spawned as a supervised index - * worker, run indexing in-process (never re-supervise) and write the result to - * the given file for the parent to read back. Stripped here so the tool - * dispatch below sees only the tool name + its args. */ - bool index_worker = cli_strip_flag(&argc, argv, "--index-worker"); - const char *response_out = cli_strip_flag_value(&argc, argv, "--response-out"); - cbm_index_set_worker_role(index_worker, response_out); - -#ifndef _WIN32 - /* #845: a supervised worker must not outlive its supervisor. If the parent - * dies without reaping us (agent killed, supervisor crashed), an orphaned - * worker would index on unsupervised — observed contributing to memory - * pressure during the 2026-07-04 host panics. Reuse the parent-death - * watchdog (safe outside server mode: on ppid change it only writes to - * stderr and _exit(0)s — no cleanup dependencies). Detached: the worker - * exits by returning from run_cli; exit() tears the thread down. Failure - * to start is non-fatal, same policy as the MCP-server watchdog. */ - if (index_worker) { - static pid_t worker_initial_ppid; /* static: outlives run_cli for the thread */ - worker_initial_ppid = getppid(); - cbm_thread_t worker_watchdog_tid; - if (cbm_thread_create(&worker_watchdog_tid, PARENT_WATCHDOG_STACK_SIZE, - parent_watchdog_thread, &worker_initial_ppid) == 0) { - (void)cbm_thread_detach(&worker_watchdog_tid); - cbm_log_info("worker.watchdog.start"); - } else { - cbm_log_warn("worker.watchdog.unavailable", "reason", "thread_create_failed"); - } - } -#endif - - if (argc < MAIN_MIN_ARGC) { - (void)fprintf(stderr, CLI_USAGE); - return SKIP_ONE; - } - - const char *tool_name = argv[0]; - int rem_argc = argc - SKIP_ONE; /* args following the tool name */ - char **rem_argv = argv + SKIP_ONE; - - /* --help / -h : print per-tool help (from the tool's input_schema) and exit - * before any server work. */ - for (int i = 0; i < rem_argc; i++) { - if (strcmp(rem_argv[i], "--help") == 0 || strcmp(rem_argv[i], "-h") == 0) { - if (cbm_cli_print_tool_help(tool_name) != 0) { - (void)fprintf(stderr, "error: unknown tool '%s'\n", tool_name); - return SKIP_ONE; - } - return 0; - } - } - - /* Resolve the JSON arguments. Precedence: --args-file, then raw JSON - * (back-compat), then --flags, then piped stdin, then empty {}. */ - char *heap_args = NULL; /* freed before return when set */ - const char *args_json = "{}"; - - int args_file_idx = -1; - for (int i = 0; i < rem_argc; i++) { - if (strcmp(rem_argv[i], "--args-file") == 0) { - args_file_idx = i; - break; - } - } - - if (args_file_idx >= 0) { - if (args_file_idx + SKIP_ONE >= rem_argc) { - (void)fprintf(stderr, "error: --args-file requires a path argument\n"); - return SKIP_ONE; - } - const char *path = rem_argv[args_file_idx + SKIP_ONE]; - heap_args = cli_slurp_file(path); - if (!heap_args) { - (void)fprintf(stderr, "error: cannot read args file '%s'\n", path); - return SKIP_ONE; - } - args_json = heap_args; - } else if (rem_argc >= SKIP_ONE && cli_first_nonspace_is_brace(rem_argv[0])) { - /* raw-JSON back-compat: cli '{"k":"v"}' (deprecated path). Warn on - * STDERR only — stdout must stay clean JSON for piping. */ - (void)fprintf(stderr, - "warning: passing raw JSON to 'cli %s' is deprecated and " - "will be removed in a future release; use flags (run 'cli " - "%s --help'), --args-file , or piped stdin.\n", - tool_name, tool_name); - args_json = rem_argv[0]; - } else if (rem_argc >= SKIP_ONE && strncmp(rem_argv[0], "--", 2) == 0) { - /* flag form: cli --flag value --bare-bool ... */ - char *err = NULL; - heap_args = cbm_cli_build_args_json(tool_name, rem_argc, rem_argv, &err); - if (!heap_args) { - (void)fprintf(stderr, "error: %s\n", err ? err : "invalid arguments"); - free(err); - return SKIP_ONE; - } - args_json = heap_args; - } else if (cbm_cli_args_from_stdin_allowed(tool_name, cli_isatty(0) != 0)) { - /* piped stdin (UTF-8 clean, no shell quoting): cli < args.json. - * Skipped for tools without arguments (#1359). */ - heap_args = cli_slurp_stream(stdin); - if (heap_args && heap_args[0]) { - args_json = heap_args; - } else { - free(heap_args); - heap_args = NULL; - args_json = "{}"; - } - } - - if (progress) { - cbm_progress_sink_init(stderr); - } - - cbm_mcp_server_t *srv = cbm_mcp_server_new(); - if (!srv) { - (void)fprintf(stderr, "error: failed to create server\n"); - if (progress) { - cbm_progress_sink_fini(); - } - return SKIP_ONE; - } - - char *result = cbm_mcp_handle_tool(srv, tool_name, args_json); - int exit_code = 0; - - if (result) { - /* Supervised worker: hand the full result string to the parent via the - * response file before printing (parent reads it back on a clean exit). */ - const char *ro = cbm_index_worker_response_out(); - if (ro) { - FILE *rf = cbm_fopen(ro, "wb"); - if (rf) { - (void)fputs(result, rf); - (void)fclose(rf); - } - } - if (raw_json) { - printf("%s\n", result); - } else { - exit_code = cli_print_mcp_result(result); - } - if (cbm_index_worker_active()) { - /* Supervised worker: the response is delivered (file + stdout). - * Skip the multi-GB teardown (server/store frees) — the process - * dies now and the OS reclaims everything wholesale; piecemeal - * free() of a kernel-scale graph costs minutes. _Exit skips - * atexit/LSan by design for this prod worker path. */ - cbm_log_info("index.worker.fast_exit", "action", "_Exit"); - fflush(NULL); - _Exit(exit_code); - } - free(result); - } - - cbm_mcp_server_free(srv); - if (progress) { - cbm_progress_sink_fini(); - } - free(heap_args); - return exit_code; -} - -/* ── Help ───────────────────────────────────────────────────────── */ - -static void print_help(void) { - printf("codebase-memory-mcp %s\n\n", CBM_VERSION); - printf("Usage:\n"); - printf(" codebase-memory-mcp Run MCP server on stdio\n"); - printf(" codebase-memory-mcp cli [json] Run a single tool\n"); - printf(" codebase-memory-mcp install [-y|-n] [--force] [--dry-run] [--plan]\n"); - printf(" codebase-memory-mcp uninstall [-y|-n] [--dry-run]\n"); - printf(" codebase-memory-mcp update [-y|-n]\n"); - printf(" codebase-memory-mcp config \n"); - printf(" codebase-memory-mcp doctor Show install/cache/agent status\n"); - printf(" codebase-memory-mcp where Show storage and config paths\n"); - printf(" codebase-memory-mcp zova-ops --json\n"); - printf(" codebase-memory-mcp --version Print version\n"); - printf(" codebase-memory-mcp --help Print this help\n"); - printf("\nSupported agents (auto-detected):\n"); - printf(" Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode,\n"); - printf(" Antigravity, Aider, KiloCode, Kiro\n"); - char *tools = cbm_cli_tools_help(); - if (tools) { - printf("\n%s", tools); - free(tools); - } -} - -/* ── Main ───────────────────────────────────────────────────────── */ - -/* Try to handle a subcommand (cli/install/uninstall/update/config/--version/--help). - * Returns -1 if no subcommand matched, otherwise the exit code. */ -static int handle_subcommand(int argc, char **argv) { - /* First scan: global flags */ - for (int i = SKIP_ONE; i < argc; i++) { - if (strcmp(argv[i], "--profile") == 0) { - cbm_profile_enable(); - } - } - for (int i = SKIP_ONE; i < argc; i++) { - if (strcmp(argv[i], "--version") == 0) { - printf("codebase-memory-mcp %s\n", CBM_VERSION); - return 0; - } - if (strcmp(argv[i], "--help") == 0 || strcmp(argv[i], "-h") == 0) { - print_help(); - return 0; - } - if (strcmp(argv[i], "cli") == 0) { - cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram)); - return run_cli(argc - i - SKIP_ONE, argv + i + SKIP_ONE); - } - if (strcmp(argv[i], "hook-augment") == 0) { - cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram)); - return cbm_cmd_hook_augment(); - } - if (strcmp(argv[i], "install") == 0) { - return cbm_cmd_install(argc - i - SKIP_ONE, argv + i + SKIP_ONE); - } - if (strcmp(argv[i], "uninstall") == 0) { - return cbm_cmd_uninstall(argc - i - SKIP_ONE, argv + i + SKIP_ONE); - } - if (strcmp(argv[i], "update") == 0) { - return cbm_cmd_update(argc - i - SKIP_ONE, argv + i + SKIP_ONE); - } - if (strcmp(argv[i], "config") == 0) { - return cbm_cmd_config(argc - i - SKIP_ONE, argv + i + SKIP_ONE); - } - if (strcmp(argv[i], "doctor") == 0) { - return cbm_cmd_doctor(argc - i - SKIP_ONE, argv + i + SKIP_ONE); - } - if (strcmp(argv[i], "where") == 0) { - return cbm_cmd_where(argc - i - SKIP_ONE, argv + i + SKIP_ONE); - } - if (strcmp(argv[i], "zova-ops") == 0) { - return cbm_cmd_zova_ops(argc - i - SKIP_ONE, argv + i + SKIP_ONE); - } - } - return CBM_NOT_FOUND; -} - -/* Install platform-specific signal handlers. */ -static void setup_signal_handlers(void) { -#ifdef _WIN32 - signal(SIGTERM, signal_handler); - signal(SIGINT, signal_handler); -#else - struct sigaction sa = {0}; - sa.sa_handler = signal_handler; - sigemptyset(&sa.sa_mask); - sa.sa_flags = 0; - sigaction(SIGTERM, &sa, NULL); - sigaction(SIGINT, &sa, NULL); -#endif -} - -#ifdef _WIN32 -/* On Windows the CRT hands main() an argv encoded in the active ANSI code page, so a - * non-ASCII CLI argument (e.g. a repo path like café_日本語_repo) is mangled before the - * program ever sees it — the documented `cli index_repository ""` then fails with - * "repo_path is required" (#423/#20). Rebuild argv from the wide command line - * (GetCommandLineW → CommandLineToArgvW) and convert each element to UTF-8 so the rest - * of the program receives the same UTF-8 bytes it gets on POSIX. Returns a - * NULL-terminated argv and sets *out_argc, or NULL on any failure (caller then keeps - * the original narrow argv). The returned block lives for the whole process (argv must - * stay valid until exit), so it is intentionally never freed. */ -static char **cbm_win_utf8_argv(int *out_argc) { - int wargc = 0; - LPWSTR *wargv = CommandLineToArgvW(GetCommandLineW(), &wargc); - if (!wargv) { - return NULL; - } - if (wargc <= 0) { - LocalFree(wargv); - return NULL; - } - char **u8argv = (char **)calloc((size_t)wargc + 1, sizeof(char *)); - if (!u8argv) { - LocalFree(wargv); - return NULL; - } - for (int i = 0; i < wargc; i++) { - u8argv[i] = cbm_wide_to_utf8(wargv[i]); - if (!u8argv[i]) { - for (int j = 0; j < i; j++) { - free(u8argv[j]); - } - free(u8argv); - LocalFree(wargv); - return NULL; - } - } - LocalFree(wargv); - *out_argc = wargc; - return u8argv; /* NULL-terminated (calloc'd wargc+1) */ -} -#endif /* _WIN32 */ - -/* A cbm.zova written by an older release uses an older Zova storage format, - * which this binary refuses to open. Convert it once before the server touches - * it; the conversion writes a new verified file and then swaps it in. */ -static void migrate_zova_format_at_startup(void) { - char path[CBM_SZ_1K]; - cbm_zova_operation_report_t report = {0}; - if (cbm_zova_user_database_path(path, sizeof(path)) != 0 || !cbm_file_exists(path) || - cbm_zova_database_format_status(path) != CBM_ZOVA_DATABASE_MIGRATION_REQUIRED) { - return; - } - cbm_log_info("zova.migrate_format", "path", path, "status", "started"); - cbm_zova_operation_code_t code = cbm_zova_database_migrate_format(path, &report); - if (code == CBM_ZOVA_OPERATION_OK || code == CBM_ZOVA_OPERATION_NOOP) { - cbm_log_info("zova.migrate_format", "path", path, "status", "ok"); - } else { - cbm_log_error("zova.migrate_format", "path", path, "status", report.reason, "fix", - "codebase-memory-mcp zova-ops migrate-format --json"); - } -} - int main(int argc, char **argv) { - /* Defense-in-depth: bind tree-sitter and sqlite3 to mimalloc so a - * correct binary does not rely on the fragile MI_OVERRIDE symbol override - * (#424). MUST be the VERY FIRST statement: SQLITE_CONFIG_MALLOC has to run - * before the first sqlite3_open* (cbm_mcp_server_new → cbm_store_open_memory - * below opens sqlite early), else sqlite3_config returns SQLITE_MISUSE and - * the bind is silently ignored. No-op in the test build. */ - cbm_alloc_init(); -#ifdef _WIN32 - /* Replace the ANSI-code-page argv the CRT handed us with a UTF-8 argv rebuilt from - * the wide command line, so non-ASCII CLI arguments survive (#423/#20). Falls back - * to the original argv if the wide rebuild fails. Done after cbm_alloc_init (which - * must stay the very first statement) but before argv is first read below. */ - { - int win_argc = 0; - char **win_argv = cbm_win_utf8_argv(&win_argc); - if (win_argv) { - argc = win_argc; - argv = win_argv; - } - } -#endif - /* #845: mark this process as the REAL binary so the index supervisor may - * wrap index_repository in a worker subprocess. Must run before any - * subcommand dispatch so MCP-server, CLI, and HTTP paths are all covered. - * Embedders of cbm_mcp_handle_tool (test binaries) never mark themselves, - * so they index in-process instead of re-invoking themselves as - * ` cli --index-worker …` (recursive suite re-runs / spawn chains). */ - cbm_index_supervisor_mark_host(); - cbm_cli_set_version(CBM_VERSION); - cbm_profile_init(); /* reads CBM_PROFILE env var, gates all prof macros */ - /* CBM_LOG_LEVEL support — distilled from #414 (closes #413). Apply before - * the first log statement so the configured level governs all output. */ - cbm_log_init_from_env(); - int subcmd = handle_subcommand(argc, argv); - if (subcmd >= 0) { - return subcmd; - } - - /* parent-death watchdog — distilled from #407 (fixes #406). Start it early so - * an orphaned server exits even if it dies before reaching the MCP loop. A - * thread-create failure (or ppid<=1) is non-fatal: the server still runs, it - * just won't auto-exit on parent death — same policy as the watcher/HTTP - * threads below. We deliberately do NOT exit at startup when ppid<=1 (the PR's - * original behaviour): a legitimately-launched server can transiently show - * ppid==1 (early reparent races, double-fork/container launchers), and the - * watchdog already no-ops safely in that case via its initial_ppid>1 guard. */ -#ifndef _WIN32 - /* main() outlives the watchdog (it joins before returning), so a stack - * local is a valid lifetime for the thread's argument. */ - pid_t initial_ppid = getppid(); - cbm_thread_t parent_watchdog_tid; - bool parent_watchdog_started = false; - if (cbm_thread_create(&parent_watchdog_tid, PARENT_WATCHDOG_STACK_SIZE, parent_watchdog_thread, - &initial_ppid) == 0) { - parent_watchdog_started = true; - } else { - cbm_log_warn("parent.watchdog.unavailable", "reason", "thread_create_failed"); - } -#else - /* Same fail-open policy as POSIX ppid<=1: with no trustworthy parent at - * startup (snapshot failed, or the parent already exited) rely on stdin EOF. */ - cbm_thread_t parent_watchdog_tid; - bool parent_watchdog_started = false; - DWORD initial_ppid = win_parent_pid_from_snapshot(); - HANDLE parent_process = initial_ppid ? OpenProcess(SYNCHRONIZE, FALSE, initial_ppid) : NULL; - if (parent_process) { - if (cbm_thread_create(&parent_watchdog_tid, PARENT_WATCHDOG_STACK_SIZE, - parent_watchdog_thread, &parent_process) == 0) { - parent_watchdog_started = true; - } else { - CloseHandle(parent_process); - parent_process = NULL; - cbm_log_warn("parent.watchdog.unavailable", "reason", "thread_create_failed"); - } - } -#endif - - /* Default: MCP server on stdio */ - cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram)); - cbm_log_info("server.start", "version", CBM_VERSION); - migrate_zova_format_at_startup(); - cbm_diag_start(); /* starts if CBM_DIAGNOSTICS=1 */ - - setup_signal_handlers(); - - /* Open config store for runtime settings */ - char config_dir[CBM_SZ_1K]; - const char *cfg_home = cbm_get_home_dir(); - cbm_config_t *runtime_config = NULL; - if (cfg_home) { - snprintf(config_dir, sizeof(config_dir), "%s", cbm_resolve_cache_dir()); - runtime_config = cbm_config_open(config_dir); - } - - /* Create MCP server */ - g_server = cbm_mcp_server_new(); - if (!g_server) { - cbm_log_error("server.err", "msg", "failed to create server"); - cbm_config_close(runtime_config); - if (parent_watchdog_started) { - atomic_store(&g_shutdown, 1); - cbm_thread_join(&parent_watchdog_tid); - } -#ifdef _WIN32 - if (parent_process) { - CloseHandle(parent_process); - } -#endif - return SKIP_ONE; - } - - /* Create and start watcher in background thread */ - cbm_store_t *watch_store = cbm_store_open_memory(); - g_watcher = cbm_watcher_new(watch_store, watcher_index_fn, NULL); - - /* Wire watcher + config into MCP server for session auto-index */ - cbm_mcp_server_set_watcher(g_server, g_watcher); - cbm_mcp_server_set_config(g_server, runtime_config); - cbm_thread_t watcher_tid; - bool watcher_started = false; - - if (g_watcher) { - if (cbm_thread_create(&watcher_tid, 0, watcher_thread, g_watcher) == 0) { - watcher_started = true; - } - } - - /* Run MCP event loop (blocks until EOF or signal) */ - int rc = cbm_mcp_server_run(g_server, stdin, stdout); - atomic_store(&g_shutdown, 1); /* unblock the watchdog poll loop */ - - /* Shutdown */ - cbm_log_info("server.shutdown"); - - if (parent_watchdog_started) { - cbm_thread_join(&parent_watchdog_tid); - } -#ifdef _WIN32 - if (parent_process) { - CloseHandle(parent_process); - } -#endif - - if (watcher_started) { - cbm_watcher_stop(g_watcher); - cbm_thread_join(&watcher_tid); - } - cbm_watcher_free(g_watcher); - cbm_store_close(watch_store); - cbm_mcp_server_free(g_server); - cbm_config_close(runtime_config); - - g_watcher = NULL; - g_server = NULL; - cbm_diag_stop(); - - return rc; + return cbm_main(argc, argv, CBM_VERSION); } diff --git a/src/main.h b/src/main.h new file mode 100644 index 00000000..b889f4ad --- /dev/null +++ b/src/main.h @@ -0,0 +1,12 @@ +/* + * main.h — The codebase-memory-mcp program, implemented in main.zig. + */ +#ifndef CBM_MAIN_H +#define CBM_MAIN_H + +/* Runs the program for argv: a subcommand, a single CLI tool call, or the MCP + * server on stdin/stdout. version is printed by --version and --help and + * logged at server start. Returns the process exit code. */ +int cbm_main(int argc, char **argv, const char *version); + +#endif /* CBM_MAIN_H */ diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 00000000..f8f89e7b --- /dev/null +++ b/src/main.zig @@ -0,0 +1,769 @@ +//! main.zig — The codebase-memory-mcp program. +//! +//! Implements src/main.h. main() itself stays in main.c, which passes the +//! release version in (see that file for why). +//! +//! Modes: +//! (default) Run as MCP server on stdin/stdout (JSON-RPC 2.0) +//! cli [args] Run a single tool call and print the result +//! --version Print version and exit +//! --help Print usage and exit +//! install, update, config, ... The commands in cli/commands.zig +//! +//! Signal handling: SIGTERM/SIGINT trigger graceful shutdown. +//! Watcher runs in a background thread, polling for git changes. + +const std = @import("std"); +const builtin = @import("builtin"); +const c = @import("c"); +const interop = @import("foundation/interop.zig"); + +const is_windows = builtin.os.tag == .windows; + +const CStr = [*c]const u8; +const Argv = [*c][*c]u8; +const logInfo = interop.logInfo; +const logWarn = interop.logWarn; + +const PARENT_WATCHDOG_STACK_SIZE = 64 * c.CBM_SZ_1K; // watchdog only polls — tiny stack suffices +const WATCHER_BASE_INTERVAL_MS = 5000; + +fn strEq(a: CStr, b: CStr) bool { + return c.strcmp(a, b) == 0; +} + +// ── Globals for signal handling ──────────────────────────────────── + +var g_watcher: ?*c.cbm_watcher_t = null; +var g_server: ?*c.cbm_mcp_server_t = null; +var g_shutdown = std.atomic.Value(bool).init(false); + +/// Idempotent shutdown: cancels the active pipeline, stops background servers, +/// and closes stdin to unblock the MCP read loop. Invoked from the signal +/// handler and from the parent-death watchdog, hence the swap guard so the +/// body runs at most once. Body is async-signal-safe (only atomic stores and +/// stop calls that themselves only set atomics). +fn requestShutdown() void { + if (g_shutdown.swap(true, .seq_cst)) return; // already shutting down + + // Cancel any in-progress pipeline (async-signal-safe: only does an atomic store) + if (g_server != null) { + const p = c.cbm_mcp_server_active_pipeline(g_server); + if (p != null) c.cbm_pipeline_cancel(p); + } + // Make pending index-lease and barrier waits return at once + c.cbm_zova_coord_set_cancelled(true); + + if (g_watcher != null) c.cbm_watcher_stop(g_watcher); + // Close stdin to unblock getline in the MCP server loop + _ = c.fclose(interop.stdin()); +} + +fn posixSignalHandler(_: std.posix.SIG) callconv(.c) void { + requestShutdown(); +} + +fn windowsSignalHandler(_: c_int) callconv(.c) void { + requestShutdown(); +} + +/// Install platform-specific signal handlers. +fn setupSignalHandlers() void { + if (is_windows) { + _ = c.signal(c.SIGTERM, &windowsSignalHandler); + _ = c.signal(c.SIGINT, &windowsSignalHandler); + } else { + const sa: std.posix.Sigaction = .{ + .handler = .{ .handler = posixSignalHandler }, + .mask = std.posix.sigemptyset(), + .flags = 0, + }; + std.posix.sigaction(.TERM, &sa, null); + std.posix.sigaction(.INT, &sa, null); + } +} + +// ── Parent-process watchdog ──────────────────────────────────────── +// parent-death watchdog — distilled from #407 (fixes #406, thanks @nvt-pankajsharma). +// +// When this stdio MCP server is launched by an agent that later dies without a +// clean SIGTERM (e.g. the editor is force-killed), the orphaned server would +// otherwise linger forever blocked on stdin. POSIX has no portable "notify on +// parent death" primitive (PR_SET_PDEATHSIG is Linux-only), so we poll getppid: +// once the parent dies the process is reparented (ppid changes, typically to 1) +// and we shut down. +// +// Windows (upstream #914, 92725a5e): job objects do not cover this case — the +// KILL_ON_JOB_CLOSE job in subprocess.zig only wraps processes CBM spawns +// itself, and Windows never propagates a client's termination to the stdio +// server it spawned. The Windows branch waits on a SYNCHRONIZE handle to the +// parent instead: the kernel signals it exactly once on termination and the +// held handle pins the process object, so PID reuse cannot fool the wait. +// Workers keep the POSIX-only guard — they run inside CBM's own kill-on-close +// job. + +/// The watchdog thread's argument: the parent's pid on POSIX, a SYNCHRONIZE +/// handle to the parent on Windows. +const ParentRef = if (is_windows) c.HANDLE else c.pid_t; + +const parentWatchdogThread = if (is_windows) parentWatchdogWindows else parentWatchdogPosix; + +fn parentWatchdogPosix(arg: ?*anyopaque) callconv(.c) ?*anyopaque { + const initial_ppid = @as(*const c.pid_t, @ptrCast(@alignCast(arg))).*; + const poll_interval_us: c_uint = 500000; // 500ms + + while (!g_shutdown.load(.seq_cst)) { + _ = c.cbm_usleep(poll_interval_us); + if (g_shutdown.load(.seq_cst)) break; + // initial_ppid > 1 guards against an already-orphaned start (ppid==1), + // where a changing ppid carries no signal. + if (initial_ppid > 1 and c.getppid() != initial_ppid) { + const msg = "level=warn msg=parent.exited reason=ppid_changed\n"; + _ = c.write(c.STDERR_FILENO, msg, msg.len); + c._exit(0); + } + } + return null; +} + +fn parentWatchdogWindows(arg: ?*anyopaque) callconv(.c) ?*anyopaque { + const parent_process = @as(*const c.HANDLE, @ptrCast(@alignCast(arg))).*; + + // The 500 ms timeout only re-checks g_shutdown, mirroring the POSIX poll. + while (!g_shutdown.load(.seq_cst)) { + const wait_status = c.WaitForSingleObject(parent_process, 500); + if (wait_status == c.WAIT_OBJECT_0) { + const msg = "level=warn msg=parent.exited reason=handle_signaled\n"; + _ = c._write(c._fileno(interop.stderr()), msg, msg.len); + c._exit(0); + } + if (wait_status != c.WAIT_TIMEOUT) break; // handle became unwaitable — stop watching, never spin + } + return null; +} + +/// Toolhelp is the documented way to learn one's own parent PID on Windows. +/// Returns 0 when the lookup fails ("no parent signal available"). +fn windowsParentPid() c.DWORD { + const snapshot = c.CreateToolhelp32Snapshot(c.TH32CS_SNAPPROCESS, 0); + if (snapshot == interop.INVALID_HANDLE_VALUE) return 0; + defer _ = c.CloseHandle(snapshot); + var entry = std.mem.zeroes(c.PROCESSENTRY32W); + entry.dwSize = @sizeOf(c.PROCESSENTRY32W); + const self_pid = c.GetCurrentProcessId(); + if (c.Process32FirstW(snapshot, &entry) == 0) return 0; + while (true) { + if (entry.th32ProcessID == self_pid) return entry.th32ParentProcessID; + if (c.Process32NextW(snapshot, &entry) == 0) return 0; + } +} + +/// The server's parent-death watchdog. It must not move once started: the +/// thread reads `parent` through a pointer. +const ParentWatchdog = struct { + tid: c.cbm_thread_t = undefined, + started: bool = false, + parent: ParentRef = if (is_windows) null else 0, + + /// A thread-create failure (or ppid<=1) is non-fatal: the server still + /// runs, it just won't auto-exit on parent death — same policy as the + /// watcher thread. We deliberately do NOT exit at startup when ppid<=1 + /// (the PR's original behaviour): a legitimately-launched server can + /// transiently show ppid==1 (early reparent races, double-fork/container + /// launchers), and the watchdog already no-ops safely in that case via + /// its initial_ppid>1 guard. + fn start(self: *ParentWatchdog) void { + if (is_windows) { + // Same fail-open policy as POSIX ppid<=1: with no trustworthy + // parent at startup (snapshot failed, or the parent already + // exited) rely on stdin EOF. + const ppid = windowsParentPid(); + self.parent = if (ppid != 0) c.OpenProcess(c.SYNCHRONIZE, c.FALSE, ppid) else null; + if (self.parent == null) return; + } else { + self.parent = c.getppid(); + } + if (c.cbm_thread_create(&self.tid, PARENT_WATCHDOG_STACK_SIZE, &parentWatchdogThread, @ptrCast(&self.parent)) == 0) { + self.started = true; + return; + } + if (is_windows) { + _ = c.CloseHandle(self.parent); + self.parent = null; + } + logWarn("parent.watchdog.unavailable", .{ "reason", "thread_create_failed" }); + } + + /// Ends the watchdog's poll loop and waits for it. + fn stop(self: *ParentWatchdog) void { + g_shutdown.store(true, .seq_cst); + if (self.started) _ = c.cbm_thread_join(&self.tid); + if (is_windows and self.parent != null) _ = c.CloseHandle(self.parent); + } +}; + +/// Static: the detached worker watchdog reads it for the rest of the process. +var worker_initial_ppid: c.pid_t = 0; + +/// #845: a supervised worker must not outlive its supervisor. If the parent +/// dies without reaping us (agent killed, supervisor crashed), an orphaned +/// worker would index on unsupervised — observed contributing to memory +/// pressure during the 2026-07-04 host panics. Reuse the parent-death +/// watchdog (safe outside server mode: on ppid change it only writes to +/// stderr and _exit(0)s — no cleanup dependencies). Detached: the worker +/// exits by returning from runCli; exit() tears the thread down. Failure +/// to start is non-fatal, same policy as the MCP-server watchdog. +fn startWorkerWatchdog() void { + worker_initial_ppid = c.getppid(); + var tid: c.cbm_thread_t = undefined; + if (c.cbm_thread_create(&tid, PARENT_WATCHDOG_STACK_SIZE, &parentWatchdogPosix, &worker_initial_ppid) == 0) { + _ = c.cbm_thread_detach(&tid); + logInfo("worker.watchdog.start", .{}); + } else { + logWarn("worker.watchdog.unavailable", .{ "reason", "thread_create_failed" }); + } +} + +// ── Watcher background thread ────────────────────────────────────── + +fn watcherThread(arg: ?*anyopaque) callconv(.c) ?*anyopaque { + _ = c.cbm_watcher_run(@ptrCast(arg), WATCHER_BASE_INTERVAL_MS); + return null; +} + +// ── Index callback for watcher ───────────────────────────────────── + +fn watcherIndex(project_name: CStr, root_path: CStr, user_data: ?*anyopaque) callconv(.c) c_int { + _ = user_data; + + // Skip indexing if shutdown is in progress (skipped, not indexed) + if (g_shutdown.load(.seq_cst)) return 1; + + // Non-blocking: skip if another process or thread is already indexing this + // workspace, or a database replacement is in progress. The watcher retries + // on its next poll cycle (5-60s). + var hold: c.cbm_zova_index_hold_t = undefined; + const hold_status = c.cbm_mcp_index_hold_acquire(root_path, 0, &hold, null, 0); + if (hold_status != c.CBM_ZOVA_COORD_OK) { + logInfo("watcher.skip", .{ "project", project_name, "reason", c.cbm_mcp_index_hold_refusal_reason(hold_status, &hold) }); + return 1; // skipped: the watcher keeps the change and retries + } + defer c.cbm_zova_index_hold_release(&hold); + + logInfo("watcher.reindex", .{ "project", project_name, "path", root_path }); + + // #832: route the re-index through the supervised worker subprocess so this + // long-lived server process hands its RSS back to the OS on every cycle + // instead of ratcheting (mimalloc v3 does not reclaim pages that worker + // threads abandon at exit). The child writes the DB; the parent only needs the + // return code and holds the index hold for the child's life. + // Degrade to the in-process pipeline when the supervisor is off (kill switch) + // or the spawn fails. + if (c.cbm_index_supervisor_should_wrap()) { + const resp = c.cbm_mcp_index_run_supervised_path(root_path); + if (resp != null) { + c.free(resp); + return 0; + } + // resp == NULL → spawn-failure degrade → fall through to in-process. + } + + const p = c.cbm_pipeline_new(root_path, null, c.CBM_MODE_FULL); + if (p == null) return c.CBM_NOT_FOUND; + defer c.cbm_pipeline_free(p); + return c.cbm_pipeline_run(p); +} + +// ── CLI mode ─────────────────────────────────────────────────────── + +const CLI_USAGE = "Usage: codebase-memory-mcp cli [--progress] [--json] [json_args]\n"; + +/// Extract text content from MCP tool result envelope and print it. +/// MCP results: {"content":[{"type":"text","text":"..."}],"isError":...} +/// Returns 1 if the result was an error, 0 otherwise. +fn cliPrintMcpResult(result: CStr) c_int { + @setEvalBranchQuota(10000); + const doc = c.yyjson_read(result, c.strlen(result), 0); + if (doc == null) { + _ = c.printf("%s\n", result); + return 0; + } + defer c.yyjson_doc_free(doc); + + const root = c.yyjson_doc_get_root(doc); + const err_val = c.yyjson_obj_get(root, "isError"); + const is_error = err_val != null and c.yyjson_get_bool(err_val); + + var text: CStr = null; + const content = c.yyjson_obj_get(root, "content"); + if (c.yyjson_is_arr(content) and c.yyjson_arr_size(content) > 0) { + const tv = c.yyjson_obj_get(c.yyjson_arr_get_first(content), "text"); + if (tv != null) text = c.yyjson_get_str(tv); + } + + if (text != null) { + _ = c.fprintf(if (is_error) interop.stderr() else interop.stdout(), "%s\n", text); + } else { + _ = c.printf("%s\n", result); + } + return if (is_error) c.SKIP_ONE else 0; +} + +/// Strip a flag from argv, returning true if found. +fn cliStripFlag(argc: *c_int, argv: Argv, flag: CStr) bool { + const n: usize = @intCast(argc.*); + for (0..n) |i| { + if (!strEq(argv[i], flag)) continue; + for (i..n - 1) |j| argv[j] = argv[j + 1]; + argc.* -= 1; + return true; + } + return false; +} + +/// Strip a flag AND its following value from argv, returning the value (a +/// pointer into the original argv strings, valid for the process lifetime) or +/// NULL if the flag is absent. +fn cliStripFlagValue(argc: *c_int, argv: Argv, flag: CStr) CStr { + const n: usize = @intCast(argc.*); + for (0..n) |i| { + if (!strEq(argv[i], flag)) continue; + const value: CStr = if (i + 1 < n) argv[i + 1] else null; + const remove_count: usize = if (value != null) 2 else 1; + for (i..n - remove_count) |j| argv[j] = argv[j + remove_count]; + argc.* -= @intCast(remove_count); + return value; + } + return null; +} + +/// Portable "is stdin a terminal?" — _isatty on Windows, isatty on POSIX. +fn cliStdinIsTty() bool { + return if (is_windows) c._isatty(0) != 0 else c.isatty(0) != 0; +} + +const CLI_SLURP_CHUNK = 4096; + +/// Read an open stream fully into a heap, NUL-terminated string. Caller frees. +/// Returns NULL on allocation failure. Reads binary-clean (UTF-8 JSON, no +/// shell quoting needed). +fn cliSlurpStream(f: ?*c.FILE) [*c]u8 { + var cap: usize = CLI_SLURP_CHUNK; + var len: usize = 0; + var buf = interop.mallocN(u8, cap); + if (buf == null) return null; + var tmp: [CLI_SLURP_CHUNK]u8 = undefined; + while (true) { + const n = c.fread(&tmp, 1, tmp.len, f); + if (n == 0) break; + if (len + n + 1 > cap) { + while (len + n + 1 > cap) cap *= 2; + const nb = interop.reallocN(u8, buf, cap); + if (nb == null) { + c.free(buf); + return null; + } + buf = nb; + } + @memcpy((buf + len)[0..n], tmp[0..n]); + len += n; + } + buf[len] = 0; + return buf; +} + +/// Slurp a file path into a heap, NUL-terminated string. Caller frees. +fn cliSlurpFile(path: CStr) [*c]u8 { + const f = c.cbm_fopen(path, "rb"); + if (f == null) return null; + defer _ = c.fclose(f); + return cliSlurpStream(f); +} + +/// True if the first non-whitespace byte of s is '{' (raw-JSON detection). +fn cliFirstNonspaceIsBrace(s: CStr) bool { + var i: usize = 0; + while (s[i] == ' ' or s[i] == '\t' or s[i] == '\n' or s[i] == '\r') i += 1; + return s[i] == '{'; +} + +fn runCli(argc_in: c_int, argv: Argv) c_int { + var argc = argc_in; + if (argc < 1) { + _ = c.fputs(CLI_USAGE, interop.stderr()); + return c.SKIP_ONE; + } + + const progress = cliStripFlag(&argc, argv, "--progress"); + const raw_json = cliStripFlag(&argc, argv, "--json"); + + // Supervisor worker role: when this process was spawned as a supervised index + // worker, run indexing in-process (never re-supervise) and write the result to + // the given file for the parent to read back. Stripped here so the tool + // dispatch below sees only the tool name + its args. + const index_worker = cliStripFlag(&argc, argv, "--index-worker"); + const response_out = cliStripFlagValue(&argc, argv, "--response-out"); + c.cbm_index_set_worker_role(index_worker, response_out); + + if (!is_windows) { + if (index_worker) startWorkerWatchdog(); + } + + if (argc < 1) { + _ = c.fputs(CLI_USAGE, interop.stderr()); + return c.SKIP_ONE; + } + + const tool_name = argv[0]; + const rem_argc = argc - 1; // args following the tool name + const rem_argv = argv + 1; + const rem: usize = @intCast(rem_argc); + + // --help / -h : print per-tool help (from the tool's input_schema) and exit + // before any server work. + for (0..rem) |i| { + if (strEq(rem_argv[i], "--help") or strEq(rem_argv[i], "-h")) { + if (c.cbm_cli_print_tool_help(tool_name) != 0) { + _ = c.fprintf(interop.stderr(), "error: unknown tool '%s'\n", tool_name); + return c.SKIP_ONE; + } + return 0; + } + } + + // Resolve the JSON arguments. Precedence: --args-file, then raw JSON + // (back-compat), then --flags, then piped stdin, then empty {}. + var heap_args: [*c]u8 = null; + defer c.free(heap_args); + var args_json: CStr = "{}"; + + var args_file_idx: ?usize = null; + for (0..rem) |i| { + if (strEq(rem_argv[i], "--args-file")) { + args_file_idx = i; + break; + } + } + + if (args_file_idx) |idx| { + if (idx + 1 >= rem) { + _ = c.fputs("error: --args-file requires a path argument\n", interop.stderr()); + return c.SKIP_ONE; + } + const path = rem_argv[idx + 1]; + heap_args = cliSlurpFile(path); + if (heap_args == null) { + _ = c.fprintf(interop.stderr(), "error: cannot read args file '%s'\n", path); + return c.SKIP_ONE; + } + args_json = heap_args; + } else if (rem >= 1 and cliFirstNonspaceIsBrace(rem_argv[0])) { + // raw-JSON back-compat: cli '{"k":"v"}' (deprecated path). Warn on + // STDERR only — stdout must stay clean JSON for piping. + _ = c.fprintf(interop.stderr(), "warning: passing raw JSON to 'cli %s' is deprecated and " ++ + "will be removed in a future release; use flags (run 'cli " ++ + "%s --help'), --args-file , or piped stdin.\n", tool_name, tool_name); + args_json = rem_argv[0]; + } else if (rem >= 1 and c.strncmp(rem_argv[0], "--", 2) == 0) { + // flag form: cli --flag value --bare-bool ... + var err: [*c]u8 = null; + heap_args = c.cbm_cli_build_args_json(tool_name, rem_argc, rem_argv, &err); + if (heap_args == null) { + const msg: CStr = if (err != null) err else "invalid arguments"; + _ = c.fprintf(interop.stderr(), "error: %s\n", msg); + c.free(err); + return c.SKIP_ONE; + } + args_json = heap_args; + } else if (c.cbm_cli_args_from_stdin_allowed(tool_name, cliStdinIsTty())) { + // piped stdin (UTF-8 clean, no shell quoting): cli < args.json. + // Skipped for tools without arguments (#1359). + heap_args = cliSlurpStream(interop.stdin()); + if (heap_args != null and heap_args[0] != 0) { + args_json = heap_args; + } else { + c.free(heap_args); + heap_args = null; + args_json = "{}"; + } + } + + if (progress) c.cbm_progress_sink_init(interop.stderr()); + defer if (progress) c.cbm_progress_sink_fini(); + + const srv = c.cbm_mcp_server_new(); + if (srv == null) { + _ = c.fputs("error: failed to create server\n", interop.stderr()); + return c.SKIP_ONE; + } + defer c.cbm_mcp_server_free(srv); + + const result = c.cbm_mcp_handle_tool(srv, tool_name, args_json); + var exit_code: c_int = 0; + + if (result != null) { + // Supervised worker: hand the full result string to the parent via the + // response file before printing (parent reads it back on a clean exit). + const ro = c.cbm_index_worker_response_out(); + if (ro != null) { + const rf = c.cbm_fopen(ro, "wb"); + if (rf != null) { + _ = c.fputs(result, rf); + _ = c.fclose(rf); + } + } + if (raw_json) { + _ = c.printf("%s\n", result); + } else { + exit_code = cliPrintMcpResult(result); + } + if (c.cbm_index_worker_active()) { + // Supervised worker: the response is delivered (file + stdout). + // Skip the multi-GB teardown (server/store frees) — the process + // dies now and the OS reclaims everything wholesale; piecemeal + // free() of a kernel-scale graph costs minutes. _Exit skips + // atexit/LSan by design for this prod worker path. + logInfo("index.worker.fast_exit", .{ "action", "_Exit" }); + _ = c.fflush(null); + c._Exit(exit_code); + } + c.free(result); + } + return exit_code; +} + +// ── Help ─────────────────────────────────────────────────────────── + +fn printHelp(version: CStr) void { + _ = c.printf("codebase-memory-mcp %s\n\n", version); + _ = c.fputs("Usage:\n" ++ + " codebase-memory-mcp Run MCP server on stdio\n" ++ + " codebase-memory-mcp cli [json] Run a single tool\n" ++ + " codebase-memory-mcp install [-y|-n] [--force] [--dry-run] [--plan]\n" ++ + " codebase-memory-mcp uninstall [-y|-n] [--dry-run]\n" ++ + " codebase-memory-mcp update [-y|-n]\n" ++ + " codebase-memory-mcp config \n" ++ + " codebase-memory-mcp doctor Show install/cache/agent status\n" ++ + " codebase-memory-mcp where Show storage and config paths\n" ++ + " codebase-memory-mcp zova-ops --json\n" ++ + " codebase-memory-mcp --version Print version\n" ++ + " codebase-memory-mcp --help Print this help\n" ++ + "\nSupported agents (auto-detected):\n" ++ + " Claude Code, Codex CLI, Gemini CLI, Zed, OpenCode,\n" ++ + " Antigravity, Aider, KiloCode, Kiro\n", interop.stdout()); + const tools = c.cbm_cli_tools_help(); + if (tools != null) { + _ = c.printf("\n%s", tools); + c.free(tools); + } +} + +// ── Main ─────────────────────────────────────────────────────────── + +fn initMemoryBudget() void { + c.cbm_mem_init(c.cbm_mem_ram_fraction_for_total(c.cbm_system_info().total_ram)); +} + +/// Try to handle a subcommand (cli/install/uninstall/update/config/--version/--help). +/// Returns -1 if no subcommand matched, otherwise the exit code. +fn handleSubcommand(argc: c_int, argv: Argv, version: CStr) c_int { + const n: usize = @intCast(argc); + // First scan: global flags + var i: usize = 1; + while (i < n) : (i += 1) { + if (strEq(argv[i], "--profile")) c.cbm_profile_enable(); + } + i = 1; + while (i < n) : (i += 1) { + const arg = argv[i]; + const sub_argc = argc - @as(c_int, @intCast(i)) - 1; + const sub_argv = argv + (i + 1); + if (strEq(arg, "--version")) { + _ = c.printf("codebase-memory-mcp %s\n", version); + return 0; + } + if (strEq(arg, "--help") or strEq(arg, "-h")) { + printHelp(version); + return 0; + } + if (strEq(arg, "cli")) { + initMemoryBudget(); + return runCli(sub_argc, sub_argv); + } + if (strEq(arg, "hook-augment")) { + initMemoryBudget(); + return c.cbm_cmd_hook_augment(); + } + if (strEq(arg, "install")) return c.cbm_cmd_install(sub_argc, sub_argv); + if (strEq(arg, "uninstall")) return c.cbm_cmd_uninstall(sub_argc, sub_argv); + if (strEq(arg, "update")) return c.cbm_cmd_update(sub_argc, sub_argv); + if (strEq(arg, "config")) return c.cbm_cmd_config(sub_argc, sub_argv); + if (strEq(arg, "doctor")) return c.cbm_cmd_doctor(sub_argc, sub_argv); + if (strEq(arg, "where")) return c.cbm_cmd_where(sub_argc, sub_argv); + if (strEq(arg, "zova-ops")) return c.cbm_cmd_zova_ops(sub_argc, sub_argv); + } + return c.CBM_NOT_FOUND; +} + +/// On Windows the CRT hands main() an argv encoded in the active ANSI code +/// page, so a non-ASCII CLI argument (e.g. a repo path like café_日本語_repo) is +/// mangled before the program ever sees it — the documented +/// `cli index_repository ""` then fails with "repo_path is required" +/// (#423/#20). Rebuild argv from the wide command line (GetCommandLineW → +/// CommandLineToArgvW) and convert each element to UTF-8 so the rest of the +/// program receives the same UTF-8 bytes it gets on POSIX. Returns a +/// NULL-terminated argv and sets out_argc, or NULL on any failure (caller then +/// keeps the original narrow argv). The returned block lives for the whole +/// process (argv must stay valid until exit), so it is intentionally never +/// freed. +fn windowsUtf8Argv(out_argc: *c_int) Argv { + var wargc: c_int = 0; + const wargv = c.CommandLineToArgvW(c.GetCommandLineW(), &wargc); + if (wargv == null) return null; + defer _ = c.LocalFree(@ptrCast(wargv)); + if (wargc <= 0) return null; + const n: usize = @intCast(wargc); + const u8argv = interop.callocN([*c]u8, n + 1); // NULL-terminated + if (u8argv == null) return null; + for (0..n) |i| { + u8argv[i] = c.cbm_wide_to_utf8(wargv[i]); + if (u8argv[i] == null) { + for (0..i) |j| c.free(u8argv[j]); + c.free(@ptrCast(u8argv)); + return null; + } + } + out_argc.* = wargc; + return u8argv; +} + +/// A cbm.zova written by an older release uses an older Zova storage format, +/// which this binary refuses to open. Convert it once before the server touches +/// it; the conversion writes a new verified file and then swaps it in. +fn migrateZovaFormatAtStartup() void { + var path_buf: [c.CBM_SZ_1K]u8 = undefined; + const path: CStr = &path_buf; + var report = std.mem.zeroes(c.cbm_zova_operation_report_t); + if (c.cbm_zova_user_database_path(&path_buf, path_buf.len) != 0 or !c.cbm_file_exists(path) or + c.cbm_zova_database_format_status(path) != c.CBM_ZOVA_DATABASE_MIGRATION_REQUIRED) + { + return; + } + logInfo("zova.migrate_format", .{ "path", path, "status", "started" }); + const code = c.cbm_zova_database_migrate_format(path, &report); + if (code == c.CBM_ZOVA_OPERATION_OK or code == c.CBM_ZOVA_OPERATION_NOOP) { + logInfo("zova.migrate_format", .{ "path", path, "status", "ok" }); + } else { + const reason: CStr = &report.reason; + const fix = "codebase-memory-mcp zova-ops migrate-format --json"; + interop.log(c.CBM_LOG_ERROR, "zova.migrate_format", .{ "path", path, "status", reason, "fix", fix }); + } +} + +/// Default mode: the MCP server on stdio. +fn runServer(version: CStr) c_int { + // parent-death watchdog — distilled from #407 (fixes #406). Start it early so + // an orphaned server exits even if it dies before reaching the MCP loop. + var watchdog: ParentWatchdog = .{}; + watchdog.start(); + + initMemoryBudget(); + logInfo("server.start", .{ "version", version }); + migrateZovaFormatAtStartup(); + _ = c.cbm_diag_start(); // starts if CBM_DIAGNOSTICS=1 + + setupSignalHandlers(); + + // Open config store for runtime settings + var config_dir: [c.CBM_SZ_1K]u8 = undefined; + var runtime_config: ?*c.cbm_config_t = null; + if (c.cbm_get_home_dir() != null) { + _ = interop.bprint(&config_dir, "{s}", .{interop.sp(c.cbm_resolve_cache_dir())}); + runtime_config = c.cbm_config_open(&config_dir); + } + + // Create MCP server + g_server = c.cbm_mcp_server_new(); + if (g_server == null) { + interop.log(c.CBM_LOG_ERROR, "server.err", .{ "msg", "failed to create server" }); + c.cbm_config_close(runtime_config); + watchdog.stop(); + return c.SKIP_ONE; + } + + // Create and start watcher in background thread + const watch_store = c.cbm_store_open_memory(); + g_watcher = c.cbm_watcher_new(watch_store, &watcherIndex, null); + + // Wire watcher + config into MCP server for session auto-index + c.cbm_mcp_server_set_watcher(g_server, g_watcher); + c.cbm_mcp_server_set_config(g_server, runtime_config); + var watcher_tid: c.cbm_thread_t = undefined; + var watcher_started = false; + if (g_watcher != null) { + watcher_started = c.cbm_thread_create(&watcher_tid, 0, &watcherThread, g_watcher) == 0; + } + + // Run MCP event loop (blocks until EOF or signal) + const rc = c.cbm_mcp_server_run(g_server, interop.stdin(), interop.stdout()); + + // Shutdown + logInfo("server.shutdown", .{}); + watchdog.stop(); // also unblocks its poll loop + + if (watcher_started) { + c.cbm_watcher_stop(g_watcher); + _ = c.cbm_thread_join(&watcher_tid); + } + c.cbm_watcher_free(g_watcher); + c.cbm_store_close(watch_store); + c.cbm_mcp_server_free(g_server); + c.cbm_config_close(runtime_config); + + g_watcher = null; + g_server = null; + c.cbm_diag_stop(); + + return rc; +} + +pub export fn cbm_main(argc_in: c_int, argv_in: Argv, version: CStr) c_int { + // Defense-in-depth: bind tree-sitter and sqlite3 to mimalloc so a + // correct binary does not rely on the fragile MI_OVERRIDE symbol override + // (#424). MUST be the VERY FIRST statement: SQLITE_CONFIG_MALLOC has to run + // before the first sqlite3_open* (cbm_mcp_server_new → cbm_store_open_memory + // below opens sqlite early), else sqlite3_config returns SQLITE_MISUSE and + // the bind is silently ignored. No-op in the test build. + c.cbm_alloc_init(); + var argc = argc_in; + var argv = argv_in; + if (is_windows) { + // Replace the ANSI-code-page argv the CRT handed us with a UTF-8 argv + // rebuilt from the wide command line, so non-ASCII CLI arguments survive + // (#423/#20). Falls back to the original argv if the wide rebuild fails. + // Done after cbm_alloc_init (which must stay the very first statement) + // but before argv is first read below. + var win_argc: c_int = 0; + const win_argv = windowsUtf8Argv(&win_argc); + if (win_argv != null) { + argc = win_argc; + argv = win_argv; + } + } + // #845: mark this process as the REAL binary so the index supervisor may + // wrap index_repository in a worker subprocess. Must run before any + // subcommand dispatch so MCP-server, CLI, and HTTP paths are all covered. + // Embedders of cbm_mcp_handle_tool (test binaries) never mark themselves, + // so they index in-process instead of re-invoking themselves as + // ` cli --index-worker …` (recursive suite re-runs / spawn chains). + c.cbm_index_supervisor_mark_host(); + c.cbm_cli_set_version(version); + c.cbm_profile_init(); // reads CBM_PROFILE env var, gates all prof macros + // CBM_LOG_LEVEL support — distilled from #414 (closes #413). Apply before + // the first log statement so the configured level governs all output. + c.cbm_log_init_from_env(); + const subcmd = handleSubcommand(argc, argv, version); + if (subcmd >= 0) return subcmd; + + return runServer(version); +} diff --git a/src/mcp/index_supervisor.h b/src/mcp/index_supervisor.h index 383c9530..31f2d11a 100644 --- a/src/mcp/index_supervisor.h +++ b/src/mcp/index_supervisor.h @@ -24,7 +24,7 @@ #include "foundation/subprocess.h" /* cbm_proc_outcome_t */ -/* Worker-role state, set once from the CLI arg parser (main.c) when this process +/* Worker-role state, set once from the CLI arg parser (main.zig) when this process * was spawned as a supervised worker. When active, indexing must run in-process * (the gate must NOT re-supervise). response_out (may be NULL) is the file the * worker writes its final result string to, for the parent to read back. */ diff --git a/src/mcp/index_tools.zig b/src/mcp/index_tools.zig index 9638e32b..12025755 100644 --- a/src/mcp/index_tools.zig +++ b/src/mcp/index_tools.zig @@ -221,7 +221,7 @@ fn buildWorkerFailureResponse(args: CStr, outcome: c.cbm_proc_outcome_t) HeapStr } /// Drop the cached store so the next query reopens whatever the worker -/// published to cbm.zova. NULL-safe: the background watcher path (main.c) has +/// published to cbm.zova. NULL-safe: the background watcher path (main.zig) has /// no MCP server, so there is nothing to invalidate. fn supervisorInvalidateStore(srv: ?*Server) void { const s = srv orelse return; @@ -527,7 +527,7 @@ pub fn indexRunSupervisedPath(srv: ?*Server, root_path: CStr) HeapStr { return indexRunSupervised(srv, args); } -/// The watcher re-index in main.c has no MCP server, so it reaches the +/// The watcher re-index in main.zig has no MCP server, so it reaches the /// supervised runner through this srv-less wrapper. pub export fn cbm_mcp_index_run_supervised_path(root_path: [*c]const u8) [*c]u8 { return indexRunSupervisedPath(null, root_path); @@ -749,7 +749,7 @@ fn indexRepositoryRun(srv: *Server, args: CStr) HeapStr { c.yyjson_mut_doc_free(doc); // Free the pipeline only after the response doc copied the excluded list. // Supervised worker: skip the deep free — the process exits right after - // handing over the response (main.c fast-exits), and piecemeal-freeing a + // handing over the response (main.zig fast-exits), and piecemeal-freeing a // multi-GB graph before process death costs minutes on kernel-scale // repos; the OS reclaims it wholesale at exit. if (c.cbm_index_worker_active()) { diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index d6fb40d8..2a13d370 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -156,7 +156,7 @@ int cbm_mcp_check_index_root(const char *repo_path, char *canonical_out, size_t * response string (caller frees) on success, or NULL to signal the caller must * degrade to the in-process path (kill switch set, spawn failure, or the process * is not a supervisor host). This is the shared entry the watcher re-index - * (main.c) and the session auto-index (mcp.c) route through. */ + * (main.zig) and the session auto-index (mcp.c) route through. */ char *cbm_mcp_index_run_supervised_path(const char *root_path); /* Index hold (ADR-0001) for root_path, canonicalized the way index_repository