From 0e00ef5702d4a67b6fed667cf66800a5a0fc75f1 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Thu, 16 Jul 2026 18:21:55 +0200 Subject: [PATCH 01/23] feat: coordinate concurrent CBM sessions Signed-off-by: Martin Vogel --- Makefile.cbm | 60 +- README.md | 38 +- graph-ui/src/components/ControlTab.tsx | 25 +- graph-ui/src/lib/i18n.ts | 4 - install.ps1 | 167 +- install.sh | 179 +- pkg/go/cmd/codebase-memory-mcp/main.go | 155 +- pkg/npm/install.js | 206 +- pkg/pypi/src/codebase_memory_mcp/_cli.py | 211 +- scripts/security-allowlist.txt | 15 +- scripts/security-audit.sh | 79 +- scripts/vendored-checksums.txt | 2 +- src/cli/activation_transaction.c | 2103 +++++++ src/cli/activation_transaction.h | 95 + src/cli/cli.c | 2342 ++++++- src/cli/cli.h | 78 +- src/cli/hook_augment.c | 227 +- src/cli/progress_sink.c | 164 +- src/cli/progress_sink.h | 13 +- src/cypher/cypher.c | 9 +- src/daemon/application.c | 2666 ++++++++ src/daemon/application.h | 145 + src/daemon/application_internal.h | 16 + src/daemon/bootstrap.c | 811 +++ src/daemon/bootstrap.h | 153 + src/daemon/daemon.c | 845 +++ src/daemon/daemon.h | 137 + src/daemon/frontend.c | 624 ++ src/daemon/frontend.h | 60 + src/daemon/host.c | 671 ++ src/daemon/host.h | 24 + src/daemon/host_internal.h | 23 + src/daemon/ipc.c | 6179 +++++++++++++++++++ src/daemon/ipc.h | 248 + src/daemon/ipc_internal.h | 103 + src/daemon/project_lock.c | 202 + src/daemon/project_lock.h | 42 + src/daemon/runtime.c | 3001 +++++++++ src/daemon/runtime.h | 370 ++ src/daemon/service.c | 978 +++ src/daemon/service.h | 73 + src/daemon/service_internal.h | 36 + src/daemon/version_cohort.c | 975 +++ src/daemon/version_cohort.h | 155 + src/foundation/compat.c | 17 +- src/foundation/compat_fs.c | 112 +- src/foundation/compat_fs.h | 16 +- src/foundation/lock_registry.c | 1098 ++++ src/foundation/lock_registry.h | 67 + src/foundation/lock_registry_internal.h | 63 + src/foundation/macos_acl.c | 60 + src/foundation/macos_acl.h | 15 + src/foundation/mem.c | 27 +- src/foundation/mem.h | 11 + src/foundation/platform.c | 74 +- src/foundation/platform_internal.h | 12 + src/foundation/private_file_lock.c | 1624 +++++ src/foundation/private_file_lock.h | 45 + src/foundation/private_file_lock_internal.h | 94 + src/foundation/subprocess.c | 883 ++- src/foundation/subprocess.h | 67 +- src/main.c | 1536 ++++- src/mcp/index_supervisor.c | 806 ++- src/mcp/index_supervisor.h | 160 +- src/mcp/mcp.c | 1308 +++- src/mcp/mcp.h | 86 +- src/mcp/mcp_internal.h | 19 + src/pipeline/pass_cross_repo.c | 909 ++- src/pipeline/pass_cross_repo.h | 13 + src/pipeline/pipeline.c | 387 +- src/pipeline/pipeline.h | 7 + src/pipeline/pipeline_incremental.c | 137 +- src/pipeline/pipeline_internal.h | 7 + src/semantic/rotsq.c | 28 +- src/store/store.c | 111 +- src/store/store.h | 17 + src/ui/config.c | 353 +- src/ui/config.h | 5 +- src/ui/http_server.c | 410 +- src/ui/http_server.h | 19 + src/watcher/watcher.c | 195 +- src/watcher/watcher.h | 20 +- tests/repro/repro_issue557.c | 107 +- tests/test_activation_transaction.c | 1028 +++ tests/test_cli.c | 1686 ++++- tests/test_cross_repo.c | 548 ++ tests/test_cypher.c | 114 + tests/test_daemon.c | 487 ++ tests/test_daemon_application.c | 3944 ++++++++++++ tests/test_daemon_bootstrap.c | 688 +++ tests/test_daemon_frontend.c | 489 ++ tests/test_daemon_ipc.c | 4870 +++++++++++++++ tests/test_daemon_runtime.c | 4011 ++++++++++++ tests/test_daemon_smoke.py | 2288 +++++++ tests/test_daemon_version.c | 641 ++ tests/test_httpd.c | 337 + tests/test_index_supervisor.c | 700 +++ tests/test_lock_registry.c | 1670 +++++ tests/test_main.c | 433 +- tests/test_mcp.c | 2128 ++++++- tests/test_mem.c | 18 + tests/test_pipeline.c | 424 ++ tests/test_platform.c | 163 + tests/test_private_file_lock.c | 888 +++ tests/test_project_lock.c | 74 + tests/test_semantic.c | 61 + tests/test_store_checkpoint.c | 13 + tests/test_subprocess.c | 483 +- tests/test_ui.c | 84 +- tests/test_version_cohort.c | 960 +++ tests/test_watcher.c | 398 ++ tests/test_worker_watchdog.sh | 93 +- 112 files changed, 63114 insertions(+), 2241 deletions(-) create mode 100644 src/cli/activation_transaction.c create mode 100644 src/cli/activation_transaction.h create mode 100644 src/daemon/application.c create mode 100644 src/daemon/application.h create mode 100644 src/daemon/application_internal.h create mode 100644 src/daemon/bootstrap.c create mode 100644 src/daemon/bootstrap.h create mode 100644 src/daemon/daemon.c create mode 100644 src/daemon/daemon.h create mode 100644 src/daemon/frontend.c create mode 100644 src/daemon/frontend.h create mode 100644 src/daemon/host.c create mode 100644 src/daemon/host.h create mode 100644 src/daemon/host_internal.h create mode 100644 src/daemon/ipc.c create mode 100644 src/daemon/ipc.h create mode 100644 src/daemon/ipc_internal.h create mode 100644 src/daemon/project_lock.c create mode 100644 src/daemon/project_lock.h create mode 100644 src/daemon/runtime.c create mode 100644 src/daemon/runtime.h create mode 100644 src/daemon/service.c create mode 100644 src/daemon/service.h create mode 100644 src/daemon/service_internal.h create mode 100644 src/daemon/version_cohort.c create mode 100644 src/daemon/version_cohort.h create mode 100644 src/foundation/lock_registry.c create mode 100644 src/foundation/lock_registry.h create mode 100644 src/foundation/lock_registry_internal.h create mode 100644 src/foundation/macos_acl.c create mode 100644 src/foundation/macos_acl.h create mode 100644 src/foundation/platform_internal.h create mode 100644 src/foundation/private_file_lock.c create mode 100644 src/foundation/private_file_lock.h create mode 100644 src/foundation/private_file_lock_internal.h create mode 100644 src/mcp/mcp_internal.h create mode 100644 tests/test_activation_transaction.c create mode 100644 tests/test_cross_repo.c create mode 100644 tests/test_daemon.c create mode 100644 tests/test_daemon_application.c create mode 100644 tests/test_daemon_bootstrap.c create mode 100644 tests/test_daemon_frontend.c create mode 100644 tests/test_daemon_ipc.c create mode 100644 tests/test_daemon_runtime.c create mode 100644 tests/test_daemon_smoke.py create mode 100644 tests/test_daemon_version.c create mode 100644 tests/test_index_supervisor.c create mode 100644 tests/test_lock_registry.c create mode 100644 tests/test_private_file_lock.c create mode 100644 tests/test_project_lock.c create mode 100644 tests/test_version_cohort.c diff --git a/Makefile.cbm b/Makefile.cbm index b78018c11..cfece1721 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -78,12 +78,13 @@ CXXFLAGS_TSAN = $(CXXFLAGS_COMMON) -g -O1 \ # Windows needs ws2_32 (Winsock), psapi (GetProcessMemoryInfo), shell32 # (CommandLineToArgvW — the UTF-8 argv path in main.c, #423/#20), and +# advapi32 (owner-only activation-candidate ACLs), plus # --allow-multiple-definition (MinGW CRT symbol 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) WIN32_LIBS := ifeq ($(IS_MINGW),yes) -WIN32_LIBS := -lws2_32 -lpsapi -lshell32 -Wl,--allow-multiple-definition -Wl,--stack,8388608 -static +WIN32_LIBS := -lws2_32 -lpsapi -lshell32 -ladvapi32 -Wl,--allow-multiple-definition -Wl,--stack,8388608 -static endif # STATIC=1 produces a fully static binary (for Alpine/musl portable builds) @@ -117,7 +118,10 @@ FOUNDATION_SRCS = \ src/foundation/dump_verify.c \ src/foundation/limits.c \ src/foundation/subprocess.c \ - src/foundation/sha256.c + src/foundation/sha256.c \ + src/foundation/macos_acl.c \ + src/foundation/private_file_lock.c \ + src/foundation/lock_registry.c # Existing extraction C code (compiled from current location) EXTRACTION_SRCS = \ @@ -181,6 +185,19 @@ CYPHER_SRCS = src/cypher/cypher.c # MCP server module (new) MCP_SRCS = src/mcp/mcp.c src/mcp/index_supervisor.c src/mcp/compact_out.c +# Shared MCP daemon coordination and local transport +DAEMON_SRCS = \ + src/daemon/daemon.c \ + src/daemon/project_lock.c \ + src/daemon/version_cohort.c \ + src/daemon/service.c \ + src/daemon/runtime.c \ + src/daemon/application.c \ + src/daemon/frontend.c \ + src/daemon/host.c \ + src/daemon/bootstrap.c \ + src/daemon/ipc.c + # Discover module (new) DISCOVER_SRCS = \ src/discover/language.c \ @@ -245,7 +262,7 @@ GIT_SRCS = src/git/git_context.c CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c \ src/cli/agent_clients.c src/cli/agent_profiles.c \ src/cli/config_json_like.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c \ - src/cli/config_text_edit.c + src/cli/config_text_edit.c src/cli/activation_transaction.c # UI module (graph visualization) UI_SRCS = \ @@ -304,7 +321,7 @@ TRE_CFLAGS = -std=c11 -g -O1 -w -Ivendored/tre YYJSON_SRC = vendored/yyjson/yyjson.c # All production sources -PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC) +PROD_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) $(TRACES_SRCS) $(WATCHER_SRCS) $(GIT_SRCS) $(CLI_SRCS) $(UI_SRCS) $(YYJSON_SRC) EXISTING_C_SRCS = $(EXTRACTION_SRCS) $(LSP_SRCS) $(TS_RUNTIME_SRC) \ $(GRAMMAR_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) @@ -320,7 +337,9 @@ TEST_FOUNDATION_SRCS = \ tests/test_str_util.c \ tests/test_platform.c \ tests/test_dump_verify.c \ - tests/test_subprocess.c + tests/test_subprocess.c \ + tests/test_private_file_lock.c \ + tests/test_lock_registry.c TEST_EXTRACTION_SRCS = \ tests/test_extraction.c \ @@ -346,7 +365,19 @@ TEST_CYPHER_SRCS = \ tests/test_cypher.c TEST_MCP_SRCS = \ - tests/test_mcp.c + tests/test_mcp.c \ + tests/test_index_supervisor.c + +TEST_DAEMON_SRCS = \ + tests/test_daemon.c \ + tests/test_project_lock.c \ + tests/test_version_cohort.c \ + tests/test_daemon_version.c \ + tests/test_daemon_runtime.c \ + tests/test_daemon_application.c \ + tests/test_daemon_frontend.c \ + tests/test_daemon_bootstrap.c \ + tests/test_daemon_ipc.c TEST_DISCOVER_SRCS = \ tests/test_language.c \ @@ -357,7 +388,7 @@ TEST_DISCOVER_SRCS = \ TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c -TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c +TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c TEST_WATCHER_SRCS = tests/test_watcher.c @@ -403,7 +434,8 @@ TEST_TRACES_SRCS = tests/test_traces.c TEST_CLI_SRCS = tests/test_cli.c tests/test_agent_clients.c tests/test_agent_profiles.c \ tests/test_config_json_like.c \ - tests/test_config_toml_edit.c tests/test_config_yaml_edit.c tests/test_config_text_edit.c + tests/test_config_toml_edit.c tests/test_config_yaml_edit.c tests/test_config_text_edit.c \ + tests/test_activation_transaction.c TEST_MEM_SRCS = tests/test_mem.c @@ -485,7 +517,7 @@ TEST_REPRO_SRCS = \ tests/repro/repro_lsp_java_cs.c \ tests/repro/repro_lsp_kt_php_rust.c -ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS) +ALL_TEST_SRCS =$(TEST_FOUNDATION_SRCS) $(TEST_EXTRACTION_SRCS) $(TEST_STORE_SRCS) $(TEST_CYPHER_SRCS) $(TEST_MCP_SRCS) $(TEST_DAEMON_SRCS) $(TEST_DISCOVER_SRCS) $(TEST_GRAPH_BUFFER_SRCS) $(TEST_PIPELINE_SRCS) $(TEST_WATCHER_SRCS) $(TEST_LZ4_SRCS) $(TEST_ZSTD_SRCS) $(TEST_ARTIFACT_SRCS) $(TEST_SQLITE_WRITER_SRCS) $(TEST_GO_LSP_SRCS) $(TEST_C_LSP_SRCS) $(TEST_PHP_LSP_SRCS) $(TEST_CS_LSP_SRCS) $(TEST_CS_LSP_BENCH_SRCS) $(TEST_PERL_LSP_SRCS) $(TEST_SCOPE_SRCS) $(TEST_TYPE_REP_SRCS) $(TEST_PY_LSP_SRCS) $(TEST_PY_LSP_BENCH_SRCS) $(TEST_PY_LSP_STRESS_SRCS) $(TEST_PY_LSP_SCALE_SRCS) $(TEST_TS_LSP_SRCS) $(TEST_JAVA_LSP_SRCS) $(TEST_KOTLIN_LSP_SRCS) $(TEST_RUST_LSP_SRCS) $(TEST_TRACES_SRCS) $(TEST_CLI_SRCS) $(TEST_MEM_SRCS) $(TEST_UI_SRCS) $(TEST_HTTPD_SRCS) $(TEST_SECURITY_SRCS) $(TEST_YAML_SRCS) $(TEST_SEMANTIC_SRCS) $(TEST_AST_PROFILE_SRCS) $(TEST_SLAB_ALLOC_SRCS) $(TEST_SIMHASH_SRCS) $(TEST_STACK_OVERFLOW_SRCS) $(TEST_INTEGRATION_SRCS) # ── Build directories ──────────────────────────────────────────── @@ -513,7 +545,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o # ── Targets ────────────────────────────────────────────────────── -.PHONY: test test-repro test-foundation test-tsan cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security +.PHONY: test test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security $(BUILD_DIR): mkdir -p $(BUILD_DIR) @@ -676,6 +708,12 @@ $(BUILD_DIR)/test-runner-tsan: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) test-tsan: $(BUILD_DIR)/test-runner-tsan cd $(CURDIR) && TSAN_OPTIONS="$(TSAN_OPTIONS)" $(BUILD_DIR)/test-runner-tsan $(TEST_TSAN_SUITES) +# Real-binary POSIX lifecycle smoke. The endpoint is deliberately account-wide; +# an explicit Make invocation requires a clean rendezvous and fails rather than +# silently skipping an occupied one. Deterministic C tests cover Windows IPC. +test-daemon-smoke: $(BUILD_DIR)/codebase-memory-mcp + cd $(CURDIR) && CBM_DAEMON_SMOKE_REQUIRE_RUN=1 python3 tests/test_daemon_smoke.py $(BUILD_DIR)/codebase-memory-mcp + # ── Production binary ──────────────────────────────────────────── # Grammar/TS/LSP objects for production (compiled with relaxed warnings, -O2) @@ -763,7 +801,7 @@ SYSROOT = $(shell xcrun --show-sdk-path 2>/dev/null) SYSROOT_FLAG = $(if $(SYSROOT),-isysroot $(SYSROOT),) # Our source files (excluding vendored, grammars, tree-sitter runtime) -LINT_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) \ +LINT_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) \ $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) \ $(TRACES_SRCS) $(WATCHER_SRCS) $(CLI_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) \ $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(MAIN_SRC) diff --git a/README.md b/README.md index bb4919ed5..c446076a5 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,18 @@ The `install` command automatically strips macOS quarantine attributes and ad-ho The `install` command auto-detects installed coding agents and configures their documented MCP entries plus durable instructions, skills, and lifecycle hooks where supported. +### Session Coordination Daemon + +CBM automatically shares one per-account coordination daemon across Claude Code, Codex, OpenCode, and every other configured client. There is no opt-in setting for MCP servers or hook clients: the first daemon-backed CBM session starts it, each session registers its own work, and the final session shuts it down. The daemon owns long-lived background services such as watchers, shared indexing jobs, and the optional UI. Closing one session cancels work owned only by that session, while work still needed by another session continues. + +All active CBM processes must run the exact same version, executable build, and coordination ABI. MCP servers, hooks, one-shot CLI commands, temporary index workers, and the daemon share a crash-safe OS admission barrier; starting an ordinary newer or older CBM process while another build is active fails before doing work and records an explicit conflict in `daemon-conflicts.ndjson` in the private log directory. + +The native `install`, `update`, and `uninstall` commands are the deliberate exception to that conflict rule. Download, verification, and private same-filesystem staging happen first so a bad candidate never disrupts active work. Activation then publishes account-wide maintenance intent, asks the daemon and every temporary local operation to cancel, and waits to a finite deadline for all coordinated CBM processes to exit. It holds the admission and lifetime barriers exclusively while changing the active binary, configuration, PATH, or indexes. New CBM work cannot enter during this window. Activation progress and results are recorded in the private `activation-events.ndjson` log, and a successful command tells you to restart open coding-agent sessions so they launch the activated build. + +Package-manager setup (npm, PyPI, or Go) only verifies and atomically publishes that package's private cached binary; it does not replace the active native installation and therefore does not stop running CBM sessions. When that cached binary is executed, it still enters the same exact-build admission barrier. The shell and PowerShell installers invoke the verified candidate's native `install` command, so they do receive the full account-wide activation guarantee. + +The ordinary `cli` mode is intentionally separate: it runs one command locally and never starts or connects to the coordination daemon, registers a daemon session, or starts watchers/UI. Its only shared state is the OS admission barrier plus per-project locks for graph mutations. While the command is running, a temporary monitor lets activation cancel that operation and its supervised worker safely; the monitor exits with the command and never becomes a standing daemon. See [CLI Mode](#cli-mode) for details. + ### Graph Visualization UI The UI ships as a separate `ui` build (it embeds the frontend). The default install on every channel is the lean, headless server; opt into the UI build with: @@ -117,7 +129,7 @@ Then run it: codebase-memory-mcp --ui=true --port=9749 ``` -Open `http://localhost:9749` in your browser. The UI runs as a background thread alongside the MCP server — it's available whenever your agent is connected. +Open `http://localhost:9749` in your browser. The UI is owned by the shared coordination daemon, so concurrent agent sessions do not start duplicate HTTP servers. ### Auto-Index @@ -521,19 +533,30 @@ no longer a suitable automatic global target. ## CLI Mode -Every MCP tool can be invoked from the command line: +Every MCP tool can be invoked as a local, one-shot command. CLI tools neither start nor connect to the coordination daemon and leave no standing process behind. They hold a crash-safe exact-build admission lease only for the command lifetime. `index_repository` is the only exception internally: it starts a temporary, exact-build supervised worker for the index, then stops that worker before the CLI command exits; the worker holds its own lease until exit. + +Commands that mutate graph data use shared OS-backed, per-project locks. This serializes conflicting work from CLI and MCP sessions on the same project while allowing unrelated projects to proceed independently. + +When stderr is an interactive terminal, the CLI automatically shows lifecycle and indexing progress. Pass `--progress` to force the same feedback when stderr is redirected or the command is run non-interactively. Progress is written only to stderr; stdout remains reserved for the command result, so pipes and scripts stay machine-safe. Pass `--json` when the full MCP result envelope is needed. + +Use `cli --help` to see the flags generated from that tool's input schema: ```bash -codebase-memory-mcp cli index_repository '{"repo_path": "/path/to/repo"}' +codebase-memory-mcp cli index_repository --repo-path /path/to/repo codebase-memory-mcp cli list_projects # Use the "name" returned by list_projects as the project value. -codebase-memory-mcp cli search_graph '{"project": "my-project", "name_pattern": ".*Handler.*", "label": "Function"}' -codebase-memory-mcp cli trace_path '{"project": "my-project", "function_name": "Search", "direction": "both"}' -codebase-memory-mcp cli query_graph '{"project": "my-project", "query": "MATCH (f:Function) RETURN f.name LIMIT 5"}' -codebase-memory-mcp cli --raw search_graph '{"project": "my-project", "label": "Function"}' | jq '.results[].name' +codebase-memory-mcp cli search_graph --project my-project --name-pattern '.*Handler.*' --label Function +codebase-memory-mcp cli trace_path --project my-project --function-name Search --direction both +codebase-memory-mcp cli query_graph --project my-project --query 'MATCH (f:Function) RETURN f.name LIMIT 5' + +# Force human-readable progress without contaminating stdout. +codebase-memory-mcp cli --progress index_repository --repo-path /path/to/repo +codebase-memory-mcp cli search_graph --project my-project --label Function | jq '.results[].name' ``` +JSON arguments can also be piped on stdin. Inline JSON remains accepted for backward compatibility but is deprecated in favor of flags, `--args-file`, or stdin. + ## MCP Tools ### Indexing @@ -706,6 +729,7 @@ Also supported (not yet benchmarked): Ada, Agda, Apex, Assembly (NASM), Astro, A ``` src/ main.c Entry point (MCP stdio server + CLI + install/update/config) + daemon/ Per-account session coordination, IPC, lifecycle, shared jobs/watchers mcp/ MCP server (15 tools, JSON-RPC 2.0, session detection, auto-index) cli/ Install/uninstall/update/config (43 client surfaces, hooks, instructions) store/ SQLite graph storage (nodes, edges, traversal, search, Louvain) diff --git a/graph-ui/src/components/ControlTab.tsx b/graph-ui/src/components/ControlTab.tsx index 2d2ce103b..6ea05bc29 100644 --- a/graph-ui/src/components/ControlTab.tsx +++ b/graph-ui/src/components/ControlTab.tsx @@ -27,9 +27,9 @@ function Gauge({ label, value, max, unit, color }: { /* ── Process card ───────────────────────────────────────── */ -function ProcessCard({ proc, selected, onSelect, onKill }: { +function ProcessCard({ proc, selected, onSelect }: { proc: ProcessInfo; selected: boolean; - onSelect: () => void; onKill: () => void; + onSelect: () => void; }) { const t = useUiMessages(); return ( @@ -51,14 +51,6 @@ function ProcessCard({ proc, selected, onSelect, onKill }: { {t.control.thisProcess} )} - {!proc.is_self && ( - - )}
@@ -159,18 +151,6 @@ export function ControlTab() { return () => clearInterval(interval); }, [fetchProcesses]); - const killProcess = useCallback(async (pid: number) => { - if (!confirm(t.control.killConfirm(pid))) return; - try { - await fetch("/api/process-kill", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ pid }), - }); - setTimeout(fetchProcesses, 1000); - } catch { /* ignore */ } - }, [fetchProcesses, t.control]); - /* Aggregates */ const totalCpu = processes.reduce((s, p) => s + p.cpu, 0); const totalRam = processes.reduce((s, p) => s + p.rss_mb, 0); @@ -212,7 +192,6 @@ export function ControlTab() { proc={p} selected={selectedPid === p.pid} onSelect={() => setSelectedPid(selectedPid === p.pid ? null : p.pid)} - onKill={() => killProcess(p.pid)} /> ))}
diff --git a/graph-ui/src/lib/i18n.ts b/graph-ui/src/lib/i18n.ts index b0a4bfdf3..f2473fe9b 100644 --- a/graph-ui/src/lib/i18n.ts +++ b/graph-ui/src/lib/i18n.ts @@ -70,10 +70,8 @@ export const messages = { processLogs: "Process Logs", noProcesses: "No processes found", noLogs: "No logs yet", - kill: "Kill", thisProcess: "THIS", uptime: "Uptime", - killConfirm: (pid: number) => `Kill process ${pid}?`, }, }, zh: { @@ -143,10 +141,8 @@ export const messages = { processLogs: "进程日志", noProcesses: "未找到进程", noLogs: "暂无日志", - kill: "结束", thisProcess: "本进程", uptime: "运行时间", - killConfirm: (pid: number) => `结束进程 ${pid}?`, }, }, } as const; diff --git a/install.ps1 b/install.ps1 index 9eebc7659..63ac408f8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -9,18 +9,78 @@ $ErrorActionPreference = "Stop" # Enforce TLS 1.2+ (older PowerShell defaults to TLS 1.0 which GitHub rejects) [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 +Add-Type -AssemblyName System.Net.Http $Repo = "DeusData/codebase-memory-mcp" $InstallDir = "$env:LOCALAPPDATA\Programs\codebase-memory-mcp" $BinName = "codebase-memory-mcp.exe" $BaseUrl = if ($env:CBM_DOWNLOAD_URL) { $env:CBM_DOWNLOAD_URL } else { "https://github.com/$Repo/releases/latest/download" } -# Security: reject non-HTTPS download URLs (defense-in-depth) -if (-not $BaseUrl.StartsWith("https://") -and -not $BaseUrl.StartsWith("http://localhost") -and -not $BaseUrl.StartsWith("http://127.0.0.1")) { +try { $BaseUri = [Uri]$BaseUrl } catch { $BaseUri = $null } +$AllowLoopbackHttp = ( + $BaseUri -and $BaseUri.IsAbsoluteUri -and + $BaseUri.Scheme -eq "http" -and $BaseUri.IsLoopback -and + [string]::IsNullOrEmpty($BaseUri.UserInfo) +) +if (-not $BaseUri -or -not $BaseUri.IsAbsoluteUri -or + ($BaseUri.Scheme -ne "https" -and -not $AllowLoopbackHttp) -or + -not [string]::IsNullOrEmpty($BaseUri.UserInfo)) { Write-Host "error: refusing non-HTTPS download URL: $BaseUrl" -ForegroundColor Red exit 1 } +function Invoke-CbmDownload { + param([Parameter(Mandatory=$true)][string]$Url, + [Parameter(Mandatory=$true)][string]$OutFile) + + $current = [Uri]$Url + $handler = New-Object System.Net.Http.HttpClientHandler + $handler.AllowAutoRedirect = $false + $client = New-Object -TypeName System.Net.Http.HttpClient -ArgumentList $handler + $client.Timeout = [TimeSpan]::FromMinutes(10) + try { + for ($redirects = 0; $redirects -le 5; $redirects++) { + $allowed = $current.IsAbsoluteUri -and + [string]::IsNullOrEmpty($current.UserInfo) -and + ($current.Scheme -eq "https" -or + ($AllowLoopbackHttp -and $current.Scheme -eq "http" -and + $current.IsLoopback)) + if (-not $allowed) { + throw "download redirect escaped the allowed transport: $current" + } + $response = $client.GetAsync( + $current, [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead + ).GetAwaiter().GetResult() + try { + $status = [int]$response.StatusCode + if ($status -in @(301, 302, 303, 307, 308)) { + if ($redirects -eq 5 -or -not $response.Headers.Location) { + throw "invalid or excessive download redirect from $current" + } + $current = [Uri]::new($current, $response.Headers.Location) + continue + } + if (-not $response.IsSuccessStatusCode) { + throw "HTTP $status for $current" + } + $input = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + try { + $output = [System.IO.File]::Open( + $OutFile, [System.IO.FileMode]::Create, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None) + try { $input.CopyTo($output) } finally { $output.Dispose() } + } finally { $input.Dispose() } + return + } finally { $response.Dispose() } + } + throw "too many download redirects" + } finally { + $client.Dispose() + $handler.Dispose() + } +} + # Detect variant from args (--ui or --standard) $Variant = "standard" $SkipConfig = $false @@ -73,7 +133,7 @@ New-Item -ItemType Directory -Path $TmpDir -Force | Out-Null Write-Host "Downloading $Archive..." try { - Invoke-WebRequest -Uri $Url -OutFile "$TmpDir\$Archive" -UseBasicParsing + Invoke-CbmDownload -Url $Url -OutFile "$TmpDir\$Archive" } catch { Write-Host "error: download failed: $_" -ForegroundColor Red Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue @@ -81,25 +141,42 @@ try { } -# Checksum verification +# Checksum verification is mandatory. Do not request coordinated shutdown for +# a candidate that was not positively matched to the published release digest. $ChecksumUrl = "$BaseUrl/checksums.txt" try { - Invoke-WebRequest -Uri $ChecksumUrl -OutFile "$TmpDir\checksums.txt" -UseBasicParsing - $checksumLine = Get-Content "$TmpDir\checksums.txt" | Where-Object { $_ -like "*$Archive*" } - if ($checksumLine) { - $expected = ($checksumLine -split '\s+')[0] - $actual = (Get-FileHash -Path "$TmpDir\$Archive" -Algorithm SHA256).Hash.ToLower() - if ($expected -ne $actual) { - Write-Host "error: CHECKSUM MISMATCH!" -ForegroundColor Red - Write-Host " expected: $expected" - Write-Host " actual: $actual" - Remove-Item -Recurse -Force $TmpDir - exit 1 + Invoke-CbmDownload -Url $ChecksumUrl -OutFile "$TmpDir\checksums.txt" + $checksumPath = "$TmpDir\checksums.txt" + if ((Get-Item -LiteralPath $checksumPath).Length -gt 1048576) { + throw "checksums.txt exceeds the 1 MiB safety limit" + } + $checksumLines = @(Get-Content -LiteralPath $checksumPath | Where-Object { + $parts = $_ -split '\s+' + $parts.Count -ge 2 -and $parts[1].TrimStart('*') -eq $Archive + }) + if ($checksumLines.Count -eq 0) { + throw "no digest for $Archive in checksums.txt" + } + $expected = $null + foreach ($checksumLine in $checksumLines) { + $digest = (($checksumLine -split '\s+')[0]).ToLower() + if ($digest -notmatch '^[0-9a-f]{64}$') { + throw "invalid SHA-256 digest for $Archive" } - Write-Host "Checksum verified." + if ($null -ne $expected -and $expected -ne $digest) { + throw "conflicting SHA-256 digests for $Archive" + } + $expected = $digest + } + $actual = (Get-FileHash -Path "$TmpDir\$Archive" -Algorithm SHA256).Hash.ToLower() + if ($expected -ne $actual) { + throw "CHECKSUM MISMATCH (expected $expected, actual $actual)" } + Write-Host "Checksum verified." } catch { - Write-Host "warning: could not verify checksum (non-fatal)" + Write-Host "error: checksum verification failed: $_" -ForegroundColor Red + Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue + exit 1 } # Extract @@ -120,26 +197,31 @@ if (-not (Test-Path $DlBin)) { } } -# Install -New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null -$Dest = Join-Path $InstallDir $BinName - -# Handle replace-if-running (rename-aside) -if (Test-Path $Dest) { - $OldDest = "$Dest.old" - Remove-Item $OldDest -Force -ErrorAction SilentlyContinue - try { - Rename-Item $Dest $OldDest -ErrorAction Stop - } catch { - Write-Host "warning: could not rename existing binary (may be in use)" - } +# Verify the candidate before it asks all coordinated CBM processes to stop. +try { + $candidateVersion = & $DlBin --version 2>&1 + if ($LASTEXITCODE -ne 0) { throw "candidate exited with $LASTEXITCODE" } + Write-Host "Verified candidate: $candidateVersion" +} catch { + Write-Host "error: downloaded binary failed to run: $_" -ForegroundColor Red + Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue + exit 1 } -Copy-Item $DlBin $Dest -Force +$Dest = Join-Path $InstallDir $BinName +$InstallArgs = @("install", "-y", "--force", "--dir=$InstallDir") +if ($SkipConfig) { $InstallArgs += "--skip-config" } +& $DlBin @InstallArgs +if ($LASTEXITCODE -ne 0) { + Write-Host "error: coordinated activation failed (exit code $LASTEXITCODE)" -ForegroundColor Red + Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue + exit 1 +} # Verify try { $ver = & $Dest --version 2>&1 + if ($LASTEXITCODE -ne 0) { throw "installed binary exited with $LASTEXITCODE" } Write-Host "Installed: $ver" } catch { Write-Host "error: installed binary failed to run" -ForegroundColor Red @@ -147,30 +229,15 @@ try { exit 1 } -# Configure agents +# Agent configuration was included in the candidate-owned activation window. if ($SkipConfig) { Write-Host "" Write-Host "Skipping agent configuration (--skip-config)" -} else { - Write-Host "" - Write-Host "Configuring coding agents..." - & $Dest install -y 2>&1 | Write-Host - if ($LASTEXITCODE -ne 0) { - Write-Host "" - Write-Host "error: agent configuration failed (exit code $LASTEXITCODE)" -ForegroundColor Red - Write-Host "The binary was installed, but no coding agents were configured." - Write-Host "Run manually to configure: `"$Dest`" install" - exit 1 - } } -# Add to PATH (user scope, no admin needed) -$UserPath = [Environment]::GetEnvironmentVariable("PATH", "User") -if ($UserPath -notlike "*$InstallDir*") { - [Environment]::SetEnvironmentVariable("PATH", "$UserPath;$InstallDir", "User") - $env:PATH = "$env:PATH;$InstallDir" - Write-Host "Added $InstallDir to user PATH" -} +# The verified candidate persisted the current-user PATH while holding the +# coordinated activation lease. Do not perform a second registry mutation here +# after running sessions have been allowed to restart. # Cleanup Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue diff --git a/install.sh b/install.sh index d0678011d..423464159 100755 --- a/install.sh +++ b/install.sh @@ -23,11 +23,63 @@ VARIANT="standard" SKIP_CONFIG=false CBM_DOWNLOAD_URL="${CBM_DOWNLOAD_URL:-https://github.com/${REPO}/releases/latest/download}" -# Security: reject non-HTTPS download URLs (defense-in-depth) -case "$CBM_DOWNLOAD_URL" in - https://*|http://localhost*|http://127.0.0.1*) ;; - *) echo "error: refusing non-HTTPS download URL: $CBM_DOWNLOAD_URL" >&2; exit 1 ;; -esac +# Security: every remote hop must remain HTTPS. Plain HTTP is accepted only +# for an exact loopback authority used by local smoke tests, with redirects +# disabled so a local fixture cannot bounce the installer to the network. +is_loopback_http_url() { + [[ "$1" =~ ^http://(localhost|127\.0\.0\.1|\[::1\])(:[0-9]+)?([/?\#].*)?$ ]] +} + +if [[ "$CBM_DOWNLOAD_URL" == https://* ]]; then + CBM_DOWNLOAD_LOOPBACK=false +elif is_loopback_http_url "$CBM_DOWNLOAD_URL"; then + CBM_DOWNLOAD_LOOPBACK=true +else + echo "error: refusing non-HTTPS download URL: $CBM_DOWNLOAD_URL" >&2 + exit 1 +fi + +download_file() { + local url="$1" + local destination="$2" + local progress="$3" + if [ "$CBM_DOWNLOAD_LOOPBACK" = true ]; then + is_loopback_http_url "$url" || { + echo "error: loopback download escaped its authority: $url" >&2 + return 1 + } + if command -v curl &>/dev/null; then + local curl_args=(-fS --noproxy '*' --proto '=http') + [ "$progress" = true ] && curl_args+=(--progress-bar) || curl_args+=(-s) + curl "${curl_args[@]}" -o "$destination" "$url" + elif command -v wget &>/dev/null; then + local wget_args=(--no-proxy --max-redirect=0) + [ "$progress" = true ] && wget_args+=(--show-progress) || wget_args+=(-q) + wget "${wget_args[@]}" -O "$destination" "$url" + else + echo "error: curl or wget required" >&2 + return 1 + fi + return + fi + + [[ "$url" == https://* ]] || { + echo "error: HTTPS download downgraded: $url" >&2 + return 1 + } + if command -v curl &>/dev/null; then + local curl_args=(-fSL --max-redirs 5 --proto '=https' --proto-redir '=https') + [ "$progress" = true ] && curl_args+=(--progress-bar) || curl_args+=(-sS) + curl "${curl_args[@]}" -o "$destination" "$url" + elif command -v wget &>/dev/null; then + local wget_args=(--https-only --max-redirect=5) + [ "$progress" = true ] && wget_args+=(--show-progress) || wget_args+=(-q) + wget "${wget_args[@]}" -O "$destination" "$url" + else + echo "error: curl or wget required" >&2 + return 1 + fi +} for arg in "$@"; do case "$arg" in @@ -116,45 +168,75 @@ DLDIR=$(mktemp -d) trap 'rm -rf "$DLDIR"' EXIT echo "Downloading ${ARCHIVE}..." -if command -v curl &>/dev/null; then - curl -fSL --progress-bar -o "$DLDIR/$ARCHIVE" "$URL" -elif command -v wget &>/dev/null; then - wget -q --show-progress -O "$DLDIR/$ARCHIVE" "$URL" -else - echo "error: curl or wget required" >&2 - exit 1 -fi +download_file "$URL" "$DLDIR/$ARCHIVE" true -# Checksum verification +# Checksum verification is mandatory. Activation must never stop running CBM +# sessions for a candidate whose published digest was not positively verified. CHECKSUM_URL="${CBM_DOWNLOAD_URL}/checksums.txt" -if curl -fsSL -o "$DLDIR/checksums.txt" "$CHECKSUM_URL" 2>/dev/null; then - EXPECTED=$(grep "$ARCHIVE" "$DLDIR/checksums.txt" | awk '{print $1}') - if [ -n "$EXPECTED" ]; then - if command -v sha256sum &>/dev/null; then - ACTUAL=$(sha256sum "$DLDIR/$ARCHIVE" | awk '{print $1}') - elif command -v shasum &>/dev/null; then - ACTUAL=$(shasum -a 256 "$DLDIR/$ARCHIVE" | awk '{print $1}') - else - ACTUAL="" - fi - if [ -n "$ACTUAL" ] && [ "$EXPECTED" != "$ACTUAL" ]; then - echo "error: CHECKSUM MISMATCH — download may be corrupted!" >&2 - echo " expected: $EXPECTED" >&2 - echo " actual: $ACTUAL" >&2 +download_file "$CHECKSUM_URL" "$DLDIR/checksums.txt" false || { + echo "error: could not download checksums.txt" >&2 + exit 1 +} +CHECKSUM_BYTES=$(wc -c < "$DLDIR/checksums.txt" | tr -d '[:space:]') +case "$CHECKSUM_BYTES" in + ''|*[!0-9]*) + echo "error: could not determine checksums.txt size" >&2 + exit 1 + ;; +esac +if [ "$CHECKSUM_BYTES" -gt 1048576 ]; then + echo "error: checksums.txt exceeds the 1 MiB safety limit" >&2 + exit 1 +fi +awk -v archive="$ARCHIVE" \ + '$2 == archive || $2 == "*" archive { print $1 }' \ + "$DLDIR/checksums.txt" > "$DLDIR/matching-checksums.txt" +EXPECTED="" +while IFS= read -r digest; do + case "$digest" in + ''|*[!0-9A-Fa-f]*) + echo "error: invalid SHA-256 digest for $ARCHIVE" >&2 exit 1 - elif [ -n "$ACTUAL" ]; then - echo "Checksum verified." - fi + ;; + esac + if [ "${#digest}" -ne 64 ]; then + echo "error: invalid SHA-256 digest length for $ARCHIVE" >&2 + exit 1 + fi + digest=$(printf '%s' "$digest" | tr 'A-F' 'a-f') + if [ -n "$EXPECTED" ] && [ "$EXPECTED" != "$digest" ]; then + echo "error: conflicting SHA-256 digests for $ARCHIVE" >&2 + exit 1 fi + EXPECTED="$digest" +done < "$DLDIR/matching-checksums.txt" +if [ -z "$EXPECTED" ]; then + echo "error: no SHA-256 digest for $ARCHIVE in checksums.txt" >&2 + exit 1 +fi +if command -v sha256sum &>/dev/null; then + ACTUAL=$(sha256sum "$DLDIR/$ARCHIVE" | awk '{print $1}') +elif command -v shasum &>/dev/null; then + ACTUAL=$(shasum -a 256 "$DLDIR/$ARCHIVE" | awk '{print $1}') +else + echo "error: sha256sum or shasum is required to verify the download" >&2 + exit 1 +fi +ACTUAL=$(printf '%s' "$ACTUAL" | tr 'A-F' 'a-f') +if [ "$EXPECTED" != "$ACTUAL" ]; then + echo "error: CHECKSUM MISMATCH — download may be corrupted!" >&2 + echo " expected: $EXPECTED" >&2 + echo " actual: $ACTUAL" >&2 + exit 1 fi +echo "Checksum verified." # Extract echo "Extracting..." -cd "$DLDIR" if [ "$EXT" = "zip" ]; then - unzip -q "$ARCHIVE" + unzip -q "$DLDIR/$ARCHIVE" -d "$DLDIR" else - tar -xzf "$ARCHIVE" + tar -xzf "$DLDIR/$ARCHIVE" -C "$DLDIR" fi DLBIN="$DLDIR/codebase-memory-mcp" @@ -170,14 +252,21 @@ if [ "$OS" = "darwin" ]; then codesign --sign - --force "$DLBIN" 2>/dev/null || true fi -# Install -mkdir -p "$INSTALL_DIR" +# Verify the candidate before it requests account-wide maintenance. The +# candidate itself owns process draining and the transactional target swap. +chmod 755 "$DLBIN" +if ! CANDIDATE_VERSION=$("$DLBIN" --version 2>&1); then + echo "error: downloaded binary failed to run" >&2 + exit 1 +fi +echo "Verified candidate: $CANDIDATE_VERSION" + DEST="$INSTALL_DIR/codebase-memory-mcp" -if [ -f "$DEST" ]; then - rm -f "$DEST" +INSTALL_ARGS=(-y --force "--dir=$INSTALL_DIR") +if [ "$SKIP_CONFIG" = true ]; then + INSTALL_ARGS+=(--skip-config) fi -cp "$DLBIN" "$DEST" -chmod 755 "$DEST" +"$DLBIN" install "${INSTALL_ARGS[@]}" # Verify VERSION=$("$DEST" --version 2>&1) || { @@ -189,18 +278,10 @@ VERSION=$("$DEST" --version 2>&1) || { } echo "Installed: $VERSION" -# Configure agents +# Agent configuration is part of the candidate-owned activation window. if [ "$SKIP_CONFIG" = true ]; then echo "" echo "Skipping agent configuration (--skip-config)" -else - echo "" - echo "Configuring coding agents..." - "$DEST" install -y 2>&1 || { - echo "" - echo "Agent configuration failed (non-fatal)." - echo "Run manually: codebase-memory-mcp install" - } fi # PATH check diff --git a/pkg/go/cmd/codebase-memory-mcp/main.go b/pkg/go/cmd/codebase-memory-mcp/main.go index a3e524531..8f89f7150 100644 --- a/pkg/go/cmd/codebase-memory-mcp/main.go +++ b/pkg/go/cmd/codebase-memory-mcp/main.go @@ -13,22 +13,33 @@ import ( "archive/tar" "archive/zip" "compress/gzip" + "context" "crypto/sha256" "encoding/hex" "fmt" "io" + "net" "net/http" + "net/url" "os" "os/exec" "path/filepath" "runtime" "strings" "syscall" + "time" ) const ( repo = "DeusData/codebase-memory-mcp" version = "0.8.1" + + maxRedirects = 5 + requestTimeout = 2 * time.Minute + connectTimeout = 15 * time.Second + responseHeaderTimeout = 30 * time.Second + candidateTimeout = 15 * time.Second + maxChecksumManifestSize = 1024 * 1024 ) func main() { @@ -117,7 +128,11 @@ func download(dest string) error { ext = "zip" } - archive := fmt.Sprintf("codebase-memory-mcp-%s-%s.%s", platform, arch, ext) + portable := "" + if platform == "linux" { + portable = "-portable" + } + archive := fmt.Sprintf("codebase-memory-mcp-%s-%s%s.%s", platform, arch, portable, ext) url := fmt.Sprintf("https://github.com/%s/releases/download/v%s/%s", repo, version, archive) checksumURL := fmt.Sprintf("https://github.com/%s/releases/download/v%s/checksums.txt", repo, version) @@ -134,13 +149,18 @@ func download(dest string) error { return fmt.Errorf("download failed: %w", err) } - // Verify checksum if available (non-fatal if checksums.txt unreachable) - if checksums, err := fetchChecksums(checksumURL); err == nil { - if expected, ok := checksums[archive]; ok { - if err := verifyChecksum(archivePath, expected); err != nil { - return err - } - } + // A release binary is executable input, so checksum verification is a + // mandatory precondition rather than a best-effort warning. + checksums, err := fetchChecksums(checksumURL) + if err != nil { + return fmt.Errorf("checksum manifest unavailable: %w", err) + } + expected, ok := checksums[archive] + if !ok { + return fmt.Errorf("checksum manifest has no entry for %s", archive) + } + if err := verifyChecksum(archivePath, expected); err != nil { + return err } binName := "codebase-memory-mcp" @@ -157,25 +177,29 @@ func download(dest string) error { return fmt.Errorf("extraction failed: %w", err) } } + extracted := filepath.Join(tmp, binName) + if err := os.Chmod(extracted, 0755); err != nil { + return fmt.Errorf("could not set candidate permissions: %w", err) + } + if err := verifyCandidate(extracted); err != nil { + return err + } if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { return fmt.Errorf("could not create cache dir: %w", err) } - if err := copyFile(filepath.Join(tmp, binName), dest); err != nil { + if err := installCandidateAtomically(extracted, dest); err != nil { return fmt.Errorf("could not install binary: %w", err) } - if err := os.Chmod(dest, 0755); err != nil { - return fmt.Errorf("could not set permissions: %w", err) - } - return nil } // validateURLScheme rejects non-https URLs before any fetch (defense-in-depth). func validateURLScheme(rawURL string) error { - if !strings.HasPrefix(rawURL, "https://") { + parsed, err := url.Parse(rawURL) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil { return fmt.Errorf("refusing non-https URL: %s", rawURL) } return nil @@ -183,11 +207,20 @@ func validateURLScheme(rawURL string) error { // httpsOnlyClient returns an HTTP client that rejects non-HTTPS redirects. var httpsOnlyClient = &http.Client{ + Timeout: requestTimeout, + Transport: &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{Timeout: connectTimeout}).DialContext, + ForceAttemptHTTP2: true, + TLSHandshakeTimeout: connectTimeout, + ResponseHeaderTimeout: responseHeaderTimeout, + ExpectContinueTimeout: time.Second, + }, CheckRedirect: func(req *http.Request, via []*http.Request) error { - if req.URL.Scheme != "https" { - return fmt.Errorf("refusing non-https redirect to %s", req.URL) + if err := validateURLScheme(req.URL.String()); err != nil { + return fmt.Errorf("refusing unsafe redirect: %w", err) } - if len(via) >= 10 { + if len(via) > maxRedirects { return fmt.Errorf("too many redirects") } return nil @@ -210,9 +243,12 @@ func httpGet(rawURL, dest string) error { if err != nil { return err } - defer f.Close() - _, err = io.Copy(f, resp.Body) - return err + _, copyErr := io.Copy(f, resp.Body) + closeErr := f.Close() + if copyErr != nil { + return copyErr + } + return closeErr } func fetchChecksums(url string) (map[string]string, error) { @@ -227,21 +263,45 @@ func fetchChecksums(url string) (map[string]string, error) { if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP %d", resp.StatusCode) } - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxChecksumManifestSize+1)) if err != nil { return nil, err } + if len(body) > maxChecksumManifestSize { + return nil, fmt.Errorf("checksums.txt exceeds the 1 MiB safety limit") + } result := make(map[string]string) for _, line := range strings.Split(string(body), "\n") { parts := strings.Fields(line) if len(parts) == 2 { - result[parts[1]] = parts[0] + name := parts[1] + if strings.HasPrefix(name, "*") { + name = name[1:] + } + digest := strings.ToLower(parts[0]) + if len(digest) != sha256.Size*2 { + return nil, fmt.Errorf("invalid SHA-256 checksum for %s", name) + } + if _, err := hex.DecodeString(digest); err != nil { + return nil, fmt.Errorf("invalid SHA-256 checksum for %s: %w", name, err) + } + if prior, exists := result[name]; exists && prior != digest { + return nil, fmt.Errorf("conflicting SHA-256 checksums for %s", name) + } + result[name] = digest } } return result, nil } func verifyChecksum(path, expected string) error { + expected = strings.ToLower(expected) + if len(expected) != sha256.Size*2 { + return fmt.Errorf("invalid SHA-256 checksum length") + } + if _, err := hex.DecodeString(expected); err != nil { + return fmt.Errorf("invalid SHA-256 checksum: %w", err) + } f, err := os.Open(path) if err != nil { return err @@ -316,19 +376,60 @@ func extractZip(archivePath, destDir, targetFile string) error { return fmt.Errorf("%s not found in archive", targetFile) } -func copyFile(src, dst string) error { +func verifyCandidate(path string) error { + ctx, cancel := context.WithTimeout(context.Background(), candidateTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, path, "--version") + cmd.Stdin = nil + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + if err := cmd.Run(); err != nil { + if ctx.Err() != nil { + return fmt.Errorf("downloaded binary verification timed out: %w", ctx.Err()) + } + return fmt.Errorf("downloaded binary failed to run: %w", err) + } + return nil +} + +func installCandidateAtomically(src, dst string) error { in, err := os.Open(src) if err != nil { return err } defer in.Close() - out, err := os.Create(dst) + pattern := "." + filepath.Base(dst) + ".*.tmp" + if runtime.GOOS == "windows" { + pattern += ".exe" + } + out, err := os.CreateTemp(filepath.Dir(dst), pattern) if err != nil { return err } - defer out.Close() - _, err = io.Copy(out, in) - return err + staged := out.Name() + defer os.Remove(staged) + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + if err := out.Chmod(0755); err != nil { + out.Close() + return err + } + if err := out.Sync(); err != nil { + out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + if err := verifyCandidate(staged); err != nil { + return err + } + if err := os.Rename(staged, dst); err != nil { + return err + } + return nil } func execBinary(bin string, args []string) error { diff --git a/pkg/npm/install.js b/pkg/npm/install.js index 25bb6a80f..cb20f3a7d 100644 --- a/pkg/npm/install.js +++ b/pkg/npm/install.js @@ -9,10 +9,15 @@ const fs = require('fs'); const path = require('path'); const os = require('os'); const { execFileSync } = require('child_process'); +const { pipeline } = require('stream'); const REPO = 'DeusData/codebase-memory-mcp'; const VERSION = require('./package.json').version; const BIN_DIR = path.join(__dirname, 'bin'); +const MAX_REDIRECTS = 5; +const DOWNLOAD_HOP_TIMEOUT_MS = 120_000; +const CANDIDATE_TIMEOUT_MS = 15_000; +const MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024; function getPlatform() { switch (process.platform) { @@ -31,36 +36,145 @@ function getArch() { } } -// Security: only follow HTTPS URLs (defense-in-depth). -function validateUrl(url) { - if (!url.startsWith('https://')) { - throw new Error(`Refusing non-HTTPS URL: ${url}`); +// Security: only follow HTTPS URLs (defense-in-depth). Parse the URL instead +// of relying on a string prefix so every redirect is checked unambiguously. +function validateUrl(rawUrl) { + let parsed; + try { + parsed = new URL(rawUrl); + } catch (err) { + throw new Error(`Invalid download URL ${rawUrl}: ${err.message}`); } + if (parsed.protocol !== 'https:' || !parsed.hostname || parsed.username || parsed.password) { + throw new Error(`Refusing non-HTTPS or credentialed URL: ${rawUrl}`); + } + return parsed.href; } -function download(url, dest) { - validateUrl(url); +function downloadHop(url, dest, maxBytes) { return new Promise((resolve, reject) => { - function follow(u, depth) { - if (depth > 5) return reject(new Error('Too many redirects')); - validateUrl(u); - https.get(u, (res) => { - if (res.statusCode === 301 || res.statusCode === 302) { - const loc = res.headers.location; - if (!loc) return reject(new Error('Redirect with no location')); - const next = loc.startsWith('/') ? new URL(loc, u).href : loc; - return follow(next, depth + 1); + let settled = false; + let response = null; + let timer = null; + + function finish(err, result) { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + if (err) reject(err); + else resolve(result); + } + + const req = https.get(url, (res) => { + response = res; + res.on('error', (err) => finish(err)); + const redirectCodes = new Set([301, 302, 303, 307, 308]); + if (redirectCodes.has(res.statusCode)) { + const location = res.headers.location; + if (!location) { + res.destroy(); + finish(new Error(`Redirect with no location for ${url}`)); + return; + } + let next; + try { + next = validateUrl(new URL(location, url).href); + } catch (err) { + res.destroy(); + finish(err); + return; } - if (res.statusCode !== 200) { - return reject(new Error(`HTTP ${res.statusCode} for ${u}`)); + res.destroy(); + finish(null, { redirect: next }); + return; + } + if (res.statusCode !== 200) { + res.destroy(); + finish(new Error(`HTTP ${res.statusCode} for ${url}`)); + return; + } + + let received = 0; + res.on('data', (chunk) => { + received += chunk.length; + if (maxBytes && received > maxBytes) { + res.destroy(new Error(`Download exceeds the ${maxBytes}-byte safety limit`)); } - const file = fs.createWriteStream(dest); - res.pipe(file); - file.on('finish', () => file.close(resolve)); - file.on('error', reject); - }).on('error', reject); + }); + const file = fs.createWriteStream(dest, { flags: 'w' }); + pipeline(res, file, (err) => { + finish(err, { redirect: null }); + }); + }); + req.on('error', (err) => finish(err)); + timer = setTimeout(() => { + const err = new Error(`Download hop timed out after ${DOWNLOAD_HOP_TIMEOUT_MS} ms: ${url}`); + req.destroy(err); + if (response) response.destroy(err); + }, DOWNLOAD_HOP_TIMEOUT_MS); + }); +} + +async function download(rawUrl, dest, maxBytes = 0) { + let url = validateUrl(rawUrl); + for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) { + const result = await downloadHop(url, dest, maxBytes); + if (!result.redirect) return; + if (redirects === MAX_REDIRECTS) throw new Error('Too many redirects'); + url = result.redirect; + } +} + +function parseExpectedChecksum(manifest, archiveName) { + let expected = null; + for (const line of manifest.split('\n')) { + const fields = line.trim().split(/\s+/); + if (fields.length < 2) continue; + if (fields[1] !== archiveName && fields[1] !== `*${archiveName}`) continue; + + const digest = fields[0].toLowerCase(); + if (!/^[0-9a-f]{64}$/.test(digest)) { + throw new Error(`Invalid SHA-256 checksum for ${archiveName}`); + } + if (expected !== null && expected !== digest) { + throw new Error(`Conflicting SHA-256 checksums for ${archiveName}`); } - follow(url, 0); + expected = digest; + } + if (expected === null) { + throw new Error(`No checksum for ${archiveName} in checksums.txt`); + } + return expected; +} + +function verifyCandidate(candidatePath) { + execFileSync(candidatePath, ['--version'], { + stdio: ['ignore', 'pipe', 'pipe'], + timeout: CANDIDATE_TIMEOUT_MS, + windowsHide: true, + }); +} + +function extractZipOnWindows(archivePath, destPath) { + // -EncodedCommand is a constant program. Paths travel only through the child + // environment and are consumed with -LiteralPath, so PowerShell never parses + // user/TEMP path bytes as source code or wildcard syntax. + const script = [ + "$ErrorActionPreference = 'Stop'", + 'Expand-Archive -LiteralPath $env:CBM_NPM_ARCHIVE_PATH ' + + '-DestinationPath $env:CBM_NPM_DEST_PATH -Force', + ].join('; '); + const encoded = Buffer.from(script, 'utf16le').toString('base64'); + execFileSync('powershell', [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded, + ], { + env: { + ...process.env, + CBM_NPM_ARCHIVE_PATH: archivePath, + CBM_NPM_DEST_PATH: destPath, + }, + stdio: 'inherit', + windowsHide: true, }); } @@ -69,11 +183,14 @@ async function verifyChecksum(archivePath, archiveName) { const url = `https://github.com/${REPO}/releases/download/v${VERSION}/checksums.txt`; const tmpChecksums = archivePath + '.checksums'; try { - await download(url, tmpChecksums); - const lines = fs.readFileSync(tmpChecksums, 'utf-8').split('\n'); - const match = lines.find((l) => l.includes(archiveName)); - if (!match) return; // checksum line not found — non-fatal - const expected = match.split(/\s+/)[0]; + await download(url, tmpChecksums, MAX_CHECKSUM_MANIFEST_BYTES); + const manifestSize = fs.statSync(tmpChecksums).size; + if (manifestSize > MAX_CHECKSUM_MANIFEST_BYTES) { + throw new Error('checksums.txt exceeds the 1 MiB safety limit'); + } + const expected = parseExpectedChecksum( + fs.readFileSync(tmpChecksums, 'utf-8'), archiveName, + ); const actual = crypto .createHash('sha256') .update(fs.readFileSync(archivePath)) @@ -84,9 +201,6 @@ async function verifyChecksum(archivePath, archiveName) { ); } process.stdout.write('codebase-memory-mcp: checksum verified.\n'); - } catch (err) { - if (err.message.startsWith('Checksum mismatch')) throw err; - // Non-fatal: checksum unavailable (network issue, pre-release, etc.) } finally { try { fs.unlinkSync(tmpChecksums); } catch (_) { /* ignore */ } } @@ -100,7 +214,12 @@ async function main() { const binPath = path.join(BIN_DIR, binName); if (fs.existsSync(binPath)) { - return; // already installed, nothing to do + try { + verifyCandidate(binPath); + return; // already installed and runnable, nothing to do + } catch (_) { + fs.rmSync(binPath, { force: true }); + } } fs.mkdirSync(BIN_DIR, { recursive: true }); @@ -129,10 +248,7 @@ async function main() { if (ext === 'tar.gz') { execFileSync('tar', ['-xzf', tmpArchive, '-C', tmpDir, '--no-same-owner']); } else { - execFileSync('powershell', [ - '-NoProfile', '-Command', - `Expand-Archive -Path '${tmpArchive}' -DestinationPath '${tmpDir}' -Force`, - ]); + extractZipOnWindows(tmpArchive, tmpDir); } // Validate extracted path doesn't escape tmpDir (tar-slip defense). @@ -146,8 +262,22 @@ async function main() { throw new Error(`Binary not found after extraction at ${extracted}`); } - fs.copyFileSync(extracted, binPath); - fs.chmodSync(binPath, 0o755); + fs.chmodSync(extracted, 0o755); + verifyCandidate(extracted); + + const stagedSuffix = platform === 'windows' ? '.tmp.exe' : '.tmp'; + const staged = path.join( + BIN_DIR, + `.${binName}.${process.pid}.${crypto.randomBytes(8).toString('hex')}${stagedSuffix}`, + ); + try { + fs.copyFileSync(extracted, staged, fs.constants.COPYFILE_EXCL); + fs.chmodSync(staged, 0o755); + verifyCandidate(staged); + fs.renameSync(staged, binPath); + } finally { + try { fs.unlinkSync(staged); } catch (_) { /* renamed or never created */ } + } process.stdout.write('codebase-memory-mcp: ready.\n'); } finally { diff --git a/pkg/pypi/src/codebase_memory_mcp/_cli.py b/pkg/pypi/src/codebase_memory_mcp/_cli.py index d192f1352..49ae3fc9c 100644 --- a/pkg/pypi/src/codebase_memory_mcp/_cli.py +++ b/pkg/pypi/src/codebase_memory_mcp/_cli.py @@ -2,14 +2,16 @@ import hashlib import os -import sys import platform -import stat import shutil +import stat +import subprocess +import sys import tempfile -import urllib.request +import time import urllib.error import urllib.parse +import urllib.request from pathlib import Path REPO = "DeusData/codebase-memory-mcp" @@ -18,16 +20,99 @@ # file://, ftp://, and custom schemes — a redirect or tainted URL source # could otherwise turn a download into an arbitrary-local-file read. _ALLOWED_SCHEMES = frozenset({"https"}) +_MAX_REDIRECTS = 5 +_NETWORK_TIMEOUT_SECONDS = 120 +_CANDIDATE_TIMEOUT_SECONDS = 15 +_MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024 +_REDIRECT_CODES = frozenset({301, 302, 303, 307, 308}) + + +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """Expose redirects to _download_https for explicit per-hop validation.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +_HTTPS_OPENER = urllib.request.build_opener(_NoRedirectHandler()) def _validate_url_scheme(url: str) -> None: """Reject non-https URLs before any network fetch.""" - scheme = urllib.parse.urlparse(url).scheme - if scheme not in _ALLOWED_SCHEMES: + parsed = urllib.parse.urlparse(url) + if ( + parsed.scheme not in _ALLOWED_SCHEMES + or not parsed.hostname + or parsed.username is not None + or parsed.password is not None + ): sys.exit( - f"codebase-memory-mcp: refusing to fetch non-https URL " - f"(scheme={scheme!r}): {url}" + f"codebase-memory-mcp: refusing to fetch invalid, credentialed, or " + f"non-https URL: {url}" + ) + + +def _download_https(url: str, dest: str, max_bytes: int = 0) -> None: + """Download through a bounded HTTPS-only opener into dest.""" + current_url = url + for redirect_count in range(_MAX_REDIRECTS + 1): + _validate_url_scheme(current_url) + request = urllib.request.Request( + current_url, headers={"User-Agent": "codebase-memory-mcp-installer"} + ) + try: + response = _HTTPS_OPENER.open( + request, timeout=_NETWORK_TIMEOUT_SECONDS + ) + except urllib.error.HTTPError as exc: + if exc.code not in _REDIRECT_CODES: + raise + location = exc.headers.get("Location") + exc.close() + if not location: + raise RuntimeError(f"redirect has no Location: {current_url}") + if redirect_count == _MAX_REDIRECTS: + raise RuntimeError("too many redirects") + current_url = urllib.parse.urljoin(current_url, location) + _validate_url_scheme(current_url) + continue + + with response: + _validate_url_scheme(response.geturl()) + deadline = time.monotonic() + _NETWORK_TIMEOUT_SECONDS + total = 0 + with open(dest, "wb") as out: + while True: + if time.monotonic() >= deadline: + raise TimeoutError( + f"download hop timed out: {current_url}" + ) + chunk = response.read(65536) + if not chunk: + return + total += len(chunk) + if max_bytes and total > max_bytes: + raise RuntimeError( + f"download exceeds the {max_bytes}-byte safety limit" + ) + out.write(chunk) + + raise RuntimeError("too many redirects") + + +def _verify_candidate(path: Path) -> None: + """Require a staged native candidate to execute successfully.""" + try: + subprocess.run( + [str(path), "--version"], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True, + timeout=_CANDIDATE_TIMEOUT_SECONDS, ) + except (OSError, subprocess.SubprocessError) as exc: + raise RuntimeError(f"downloaded binary failed to run: {exc}") from exc def _safe_extract_tar(tf, dest: str) -> None: @@ -73,37 +158,61 @@ def _safe_extract_zip(zf, dest: str) -> None: def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None: """Verify SHA256 checksum against checksums.txt from the release.""" url = f"https://github.com/{REPO}/releases/download/v{version}/checksums.txt" + tmp_path = None try: _validate_url_scheme(url) with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp: tmp_path = tmp.name - urllib.request.urlretrieve(url, tmp_path) # noqa: S310 — scheme validated above - with open(tmp_path) as f: + _download_https(url, tmp_path, _MAX_CHECKSUM_MANIFEST_BYTES) + expected = None + with open(tmp_path, encoding="utf-8") as f: for line in f: - if archive_name in line: - expected = line.split()[0] - h = hashlib.sha256() - with open(archive_path, "rb") as af: - for chunk in iter(lambda: af.read(65536), b""): - h.update(chunk) - actual = h.hexdigest() - if expected != actual: - sys.exit( - f"codebase-memory-mcp: CHECKSUM MISMATCH for {archive_name}\n" - f" expected: {expected}\n" - f" actual: {actual}" - ) - print("codebase-memory-mcp: checksum verified.", file=sys.stderr) - break + fields = line.split() + if len(fields) < 2 or fields[1] not in ( + archive_name, + f"*{archive_name}", + ): + continue + digest = fields[0].lower() + if len(digest) != 64 or any( + ch not in "0123456789abcdef" for ch in digest + ): + sys.exit( + f"codebase-memory-mcp: invalid SHA256 checksum for " + f"{archive_name}" + ) + if expected is not None and expected != digest: + sys.exit( + f"codebase-memory-mcp: conflicting SHA256 checksums for " + f"{archive_name}" + ) + expected = digest + if expected is None: + sys.exit( + f"codebase-memory-mcp: no checksum for {archive_name} in checksums.txt" + ) + h = hashlib.sha256() + with open(archive_path, "rb") as af: + for chunk in iter(lambda: af.read(65536), b""): + h.update(chunk) + actual = h.hexdigest() + if expected != actual: + sys.exit( + f"codebase-memory-mcp: CHECKSUM MISMATCH for {archive_name}\n" + f" expected: {expected}\n" + f" actual: {actual}" + ) + print("codebase-memory-mcp: checksum verified.", file=sys.stderr) except SystemExit: raise - except Exception: - pass # Non-fatal: checksum unavailable + except Exception as exc: + sys.exit(f"codebase-memory-mcp: checksum verification failed: {exc}") finally: - try: - os.unlink(tmp_path) - except Exception: - pass + if tmp_path is not None: + try: + os.unlink(tmp_path) + except Exception: + pass def _version() -> str: @@ -175,8 +284,8 @@ def _download(version: str) -> Path: with tempfile.TemporaryDirectory() as tmp: tmp_archive = os.path.join(tmp, f"cbm.{ext}") try: - urllib.request.urlretrieve(url, tmp_archive) # noqa: S310 — scheme validated above - except urllib.error.HTTPError as e: + _download_https(url, tmp_archive) + except (OSError, RuntimeError, urllib.error.URLError) as e: sys.exit( f"codebase-memory-mcp: download failed ({e})\n" f"URL: {url}\n" @@ -199,9 +308,40 @@ def _download(version: str) -> Path: if not os.path.exists(extracted): sys.exit("codebase-memory-mcp: binary not found after extraction") - shutil.copy2(extracted, dest) - current = dest.stat().st_mode - dest.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + extracted_path = Path(extracted) + current = extracted_path.stat().st_mode + extracted_path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + try: + _verify_candidate(extracted_path) + except RuntimeError as exc: + sys.exit(f"codebase-memory-mcp: {exc}") + + staged_path = None + try: + staged_suffix = ".tmp.exe" if sys.platform == "win32" else ".tmp" + with tempfile.NamedTemporaryFile( + dir=str(dest.parent), + prefix=f".{dest.name}.", + suffix=staged_suffix, + delete=False, + ) as staged: + staged_path = Path(staged.name) + shutil.copy2(extracted_path, staged_path) + staged_mode = staged_path.stat().st_mode + staged_path.chmod( + staged_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) + _verify_candidate(staged_path) + os.replace(staged_path, dest) + staged_path = None + except RuntimeError as exc: + sys.exit(f"codebase-memory-mcp: {exc}") + finally: + if staged_path is not None: + try: + staged_path.unlink() + except OSError: + pass return dest @@ -222,6 +362,5 @@ def main() -> None: if sys.platform != "win32": os.execv(str(bin_path), args) # noqa: S606 — list form, no shell else: - import subprocess result = subprocess.run(args) # noqa: S603 — list form, no shell=True sys.exit(result.returncode) diff --git a/scripts/security-allowlist.txt b/scripts/security-allowlist.txt index ed329619b..efe9b37f8 100644 --- a/scripts/security-allowlist.txt +++ b/scripts/security-allowlist.txt @@ -11,9 +11,22 @@ src/foundation/compat_fs.c:fork:cbm_exec_no_shell — fork+execvp for shell-free src/foundation/subprocess.c:fork:cbm_run_posix — fork+execv for the crash/hang-isolating index worker (supervisor primitive; child execs immediately, no code runs in the forked image) src/foundation/compat_fs.c:execvp:cbm_exec_no_shell — direct exec without shell interpretation +# ── Coordination daemon: detached bootstrap and test-only watchdog probe ──── +src/daemon/bootstrap.c:fork:validated shell-free double-fork; grandchild resets signals/umask, closes inherited descriptors, and execv's the exact current CBM image +src/main.c:fork:test-only parent-death containment descendant; inert unless the private CBM_TEST_WORKER_DESCENDANT_PID_FILE probe is explicitly set + +# ── Coordination daemon: count-bounded local IPC (never Internet sockets) ── +# Format: NETWORK:file:function:expected-count:justification +NETWORK:src/daemon/ipc.c:socket:1:single owner-only AF_UNIX stream constructor +NETWORK:src/daemon/ipc.c:connect:1:single sockaddr_un-typed connect wrapper used only with the stable owner-only daemon endpoint + +# ── Coordination daemon: count-bounded private internal file writes ──────── +# Format: WRITE:file:expected-count:justification +WRITE:src/main.c:1:supervised worker writes its MCP result only to the private unique response path created and retained by its parent +WRITE:src/daemon/application.c:1:recovery truncates only its private uniquely-created per-job marker file + # ── CLI: update command (user-initiated, interactive) ────────────────────── src/cli/cli.c:cbm_popen:sha256 checksum verification (update cmd) -src/cli/cli.c:cbm_popen:pgrep for kill_other_instances (hardcoded process name) src/cli/cli.c:popen:sha256 checksum computation via shasum # ── Watcher: git status polling (repo paths validated via cbm_validate_shell_arg) ── diff --git a/scripts/security-audit.sh b/scripts/security-audit.sh index f46d512f2..ea211a40d 100755 --- a/scripts/security-audit.sh +++ b/scripts/security-audit.sh @@ -58,22 +58,51 @@ while IFS= read -r file; do done done < <(find "$ROOT/src" -name '*.c' -type f | sort) -# ── 1b. Raw network calls (must not exist) ────────────────────── +# ── 1b. Raw network calls (count-bounded explicit exceptions) ─── # # The graph-UI HTTP server (src/ui/httpd.c) is the one component that owns a # listening socket. It is first-party, binds 127.0.0.1 only, and is audited # separately by scripts/security-ui.sh (binding + CORS checks), so it is -# exempt from this scan. No other source file may make raw network calls. +# exempt from this scan. The coordination transport may use only explicitly +# count-bounded AF_UNIX calls recorded in the security allow-list. echo "" -echo "--- Scanning for raw network calls (must not exist) ---" - -NETWORK_FUNCS='[^a-z_]connect\(|[^a-z_]socket\(|[^a-z_]sendto\(' -if grep -rn -E "$NETWORK_FUNCS" "$ROOT/src/" --include='*.c' 2>/dev/null | grep -v '^\s*//' | grep -v '^\s*\*' | grep -v 'test' | grep -v 'src/ui/httpd.c'; then - echo "BLOCKED: Raw network calls found in src/." - fail -else - echo "OK: No raw network calls outside the graph-UI server." +echo "--- Scanning for raw network calls ---" + +NETWORK_OK=true +while IFS= read -r file; do + relfile="${file#"$ROOT/"}" + [[ "$relfile" == "src/ui/httpd.c" ]] && continue + for func in socket connect sendto; do + matches=$(grep -n -E "[^a-z_]${func}\\(" "$file" 2>/dev/null \ + | grep -v '^\s*//' | grep -v '^\s*\*' || true) + [[ -z "$matches" ]] && continue + actual_count=$(printf '%s\n' "$matches" | wc -l | tr -d ' ') + allowance=$(grep -E "^NETWORK:${relfile}:${func}:[0-9]+:" \ + "$ALLOWLIST" 2>/dev/null || true) + allowance_count=$(printf '%s\n' "$allowance" | grep -c . || true) + expected_count=$(printf '%s\n' "$allowance" | cut -d: -f4) + semantic_ok=true + if [[ "$func" == "socket" ]] && + printf '%s\n' "$matches" | grep -v 'socket(AF_UNIX' >/dev/null; then + semantic_ok=false + fi + if [[ "$allowance_count" != "1" || + "$actual_count" != "$expected_count" || + "$semantic_ok" != "true" ]]; then + echo "BLOCKED: ${relfile}: unexpected raw ${func}() surface" + echo " expected=${expected_count:-none} actual=${actual_count} semantic_ok=${semantic_ok}" + printf '%s\n' "$matches" | sed 's/^/ /' + fail + NETWORK_OK=false + else + echo "REVIEWED: ${relfile}: ${actual_count} count-bounded ${func}() call(s)" + fi + done +done < <(find "$ROOT/src" -name '*.c' -type f | sort) + +if $NETWORK_OK; then + echo "OK: Raw network calls are absent or explicitly count-bounded." fi # ── 2. Hardcoded URLs in string literals ───────────────────────── @@ -170,20 +199,32 @@ echo "" echo "--- Scanning for unexpected file writes in src/ ---" FOPEN_FOUND=false -while IFS= read -r match; do - [[ -z "$match" ]] && continue - file=$(echo "$match" | cut -d: -f1) +while IFS= read -r file; do relfile="${file#"$ROOT/"}" + matches=$(grep -n 'fopen.*"w' "$file" 2>/dev/null \ + | grep -v '^\s*//' || true) + [[ -z "$matches" ]] && continue case "$relfile" in src/cli/cli.c|src/store/store.c|src/pipeline/*.c|src/foundation/log.c|src/foundation/diagnostics.c|src/ui/http_server.c|src/ui/config.c|src/mcp/mcp.c) ;; # Known safe (diagnostics.c: atomic .tmp+rename metrics dump to configured path) *) - echo "REVIEW: ${match}" - echo " -> Unexpected fopen(\"w\") in ${relfile}" - FOPEN_FOUND=true + actual_count=$(printf '%s\n' "$matches" | wc -l | tr -d ' ') + allowance=$(grep -E "^WRITE:${relfile}:[0-9]+:" \ + "$ALLOWLIST" 2>/dev/null || true) + allowance_count=$(printf '%s\n' "$allowance" | grep -c . || true) + expected_count=$(printf '%s\n' "$allowance" | cut -d: -f3) + if [[ "$allowance_count" != "1" || + "$actual_count" != "$expected_count" ]]; then + echo "REVIEW: ${relfile}: unexpected fopen(\"w\") surface" + echo " expected=${expected_count:-none} actual=${actual_count}" + printf '%s\n' "$matches" | sed 's/^/ /' + FOPEN_FOUND=true + else + echo "REVIEWED: ${relfile}: ${actual_count} count-bounded private write(s)" + fi ;; esac -done < <(grep -rn 'fopen.*"w' "$ROOT/src/" --include='*.c' 2>/dev/null | grep -v '/test' | grep -v '^\s*//' || true) +done < <(find "$ROOT/src" -name '*.c' -type f | sort) if ! $FOPEN_FOUND; then echo "OK: All file writes are in expected locations." @@ -247,10 +288,10 @@ if [ -f "$MCP_FILE" ]; then # - HTTP transport: reads the incoming request body (content-length bound) # Count fopen/fread calls and compare against expected FOPEN_COUNT=$(grep -c 'fopen\|fread\|read_file' "$MCP_FILE" 2>/dev/null || echo "0") - # Update this when legitimate reads are added. 13 reads audited as of the + # Update this when legitimate reads are added. 15 reads audited as of the # search/ADR/Windows-support commits — all path-contained or transport # reads, no new exfiltration surface. - EXPECTED_MAX=13 + EXPECTED_MAX=15 if [ "$FOPEN_COUNT" -gt "$EXPECTED_MAX" ]; then echo "REVIEW: src/mcp/mcp.c has $FOPEN_COUNT file read operations (expected max $EXPECTED_MAX)" echo " New file reads in MCP tool handlers must be reviewed for data exfiltration risk." diff --git a/scripts/vendored-checksums.txt b/scripts/vendored-checksums.txt index 5009399d3..df2ad490b 100644 --- a/scripts/vendored-checksums.txt +++ b/scripts/vendored-checksums.txt @@ -47,7 +47,6 @@ ea81fb7bd05882e0e0b92c4d60f677b205f7f1fbf085f218b12f0b5b3f0b9e48 vendored/sqlit eab23b8e79ee90f78e8495de64519afb61b627e062804fd4a622784e052a85fa vendored/tre/regerror.c 26b0f550d491335cdaa3fecfe49213d68466befdf648ed281ccdaa631ea6d4f9 vendored/tre/regex.h fd6fe2789439d3d28140c27edfe6bcdde1d1c737cab4bd27b1287d3e759fa82d vendored/tre/regexec.c -90f76dce41eade7e28c3477d8b45acb8d2ccbc6d4aaba0bb93f0d5ec5b160820 vendored/tre/tre_all.c e98c7732fdbb35ec182edfe043743d7e6b4ad7bcf57b815ec9f37f0d1065a062 vendored/tre/tre-ast.c f5d0374597a42f4bf0e7a80001a68bae9ea2622b80f760d8005200fd20acaf0f vendored/tre/tre-ast.h 45407a83ef0151a977cb7f8a5275b2a9831ae570d6f27a43723c8da1e76c0261 vendored/tre/tre-compile.c @@ -67,6 +66,7 @@ a04e1bea47aff5d858460c1d08aac6ed3a3c8ee285500281dd3147ff0621095e vendored/tre/t 4c9af903178f5f7030962b5708d4e656f8b060e795852e1eee883696b682849d vendored/tre/tre-stack.c aabe11f1b7c6c627dc9cfb62cfb9565ac9ebdf2c51c2d55c5320af5db76c5e3b vendored/tre/tre-stack.h 1c2d81474d2b59b7a39f5b1592473adfb4109fad99156588088f2ccc56c654ab vendored/tre/tre.h +90f76dce41eade7e28c3477d8b45acb8d2ccbc6d4aaba0bb93f0d5ec5b160820 vendored/tre/tre_all.c 9632e5eeb20e3d328f8def0efb2e8230f5b5cb7d9f2e5680ad89caf065f8b3a6 vendored/tre/xmalloc.c 22aee25e6892e97719ec4a5fad91345cd2145722ebbd3ec31397aff68108e3e6 vendored/tre/xmalloc.h 5c3591fe6e6c86a619eb26760e9520e37a6fd5152882ab5ad93f912e2a855966 vendored/xxhash/xxhash.c diff --git a/src/cli/activation_transaction.c b/src/cli/activation_transaction.c new file mode 100644 index 000000000..109f80eaa --- /dev/null +++ b/src/cli/activation_transaction.c @@ -0,0 +1,2103 @@ +/* Transactional binary activation. See activation_transaction.h. */ +#include "cli/activation_transaction.h" +#include "foundation/macos_acl.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#else +#include +#ifdef __APPLE__ +#include +#include +#elif defined(__linux__) +#include +#endif +#include +#endif + +typedef enum { + ACTIVATION_REPLACE = 0, + ACTIVATION_REMOVE = 1, +} activation_action_t; + +typedef enum { + ACTIVATION_STAGED = 0, + ACTIVATION_COMMITTED = 1, + ACTIVATION_ROLLED_BACK = 2, + ACTIVATION_FINALIZED = 3, + ACTIVATION_FINALIZED_DEFERRED = 4, + ACTIVATION_RECOVERY_NEEDED = 5, +} activation_state_t; + +typedef struct { +#ifdef _WIN32 + DWORD volume_serial; + DWORD index_high; + DWORD index_low; +#else + dev_t device; + ino_t inode; +#endif +} activation_file_identity_t; + +struct cbm_activation_transaction { + activation_action_t action; + activation_state_t state; + char *target_path; + char *directory_path; + char *target_name; + char *staged_path; + char *staged_name; + char *backup_path; + char *backup_name; + bool target_existed; + bool staged_exists; + bool backup_exists; + bool backup_contains_target; + bool deferred_cleanup; + activation_file_identity_t target_identity; + activation_file_identity_t directory_identity; + activation_file_identity_t staged_identity; + activation_file_identity_t backup_identity; +#ifndef _WIN32 + int directory_fd; +#endif +}; + +#ifdef _WIN32 +typedef HANDLE activation_native_file_t; +#define ACTIVATION_INVALID_FILE INVALID_HANDLE_VALUE +#else +typedef int activation_native_file_t; +#define ACTIVATION_INVALID_FILE (-1) +#endif + +static atomic_uint_fast64_t activation_unique_sequence = ATOMIC_VAR_INIT(0); +static cbm_activation_transaction_before_absent_publish_for_test_fn + activation_before_absent_publish_for_test; +static void *activation_before_absent_publish_context_for_test; + +void cbm_activation_transaction_set_before_absent_publish_for_test( + cbm_activation_transaction_before_absent_publish_for_test_fn hook, + void *context) { + activation_before_absent_publish_context_for_test = context; + activation_before_absent_publish_for_test = hook; +} + +static char *activation_string_copy(const char *value) { + if (!value) { + return NULL; + } + size_t length = strlen(value); + char *copy = malloc(length + 1U); + if (copy) { + memcpy(copy, value, length + 1U); + } + return copy; +} + +static char *activation_string_span(const char *value, size_t length) { + char *copy = malloc(length + 1U); + if (copy) { + memcpy(copy, value, length); + copy[length] = '\0'; + } + return copy; +} + +static bool activation_target_parts(const char *target_path, + char **directory_out, char **name_out) { + *directory_out = NULL; + *name_out = NULL; + if (!target_path || !target_path[0]) { + return false; + } + size_t length = strlen(target_path); + if (target_path[length - 1U] == '/' || target_path[length - 1U] == '\\') { + return false; + } + const char *separator = strrchr(target_path, '/'); +#ifdef _WIN32 + const char *backslash = strrchr(target_path, '\\'); + if (backslash && (!separator || backslash > separator)) { + separator = backslash; + } +#endif + const char *base = separator ? separator + 1 : target_path; + if (!base[0] || strcmp(base, ".") == 0 || strcmp(base, "..") == 0) { + return false; + } + if (!separator) { + *directory_out = activation_string_copy("."); + } else if (separator == target_path) { + *directory_out = activation_string_span(target_path, 1U); +#ifdef _WIN32 + } else if (separator == target_path + 2 && target_path[1] == ':') { + *directory_out = activation_string_span(target_path, 3U); +#endif + } else { + *directory_out = activation_string_span( + target_path, (size_t)(separator - target_path)); + } + if (*directory_out) { + *name_out = activation_string_copy(base); + } + if (!*directory_out || !*name_out) { + free(*directory_out); + free(*name_out); + *directory_out = NULL; + *name_out = NULL; + return false; + } + return true; +} + +static char *activation_path_name_copy(const char *path) { + const char *slash = strrchr(path, '/'); +#ifdef _WIN32 + const char *backslash = strrchr(path, '\\'); + if (backslash && (!slash || backslash > slash)) { + slash = backslash; + } +#endif + return activation_string_copy(slash ? slash + 1 : path); +} + +static char *activation_unique_path(const char *directory, const char *tag) { + uint64_t sequence = atomic_fetch_add_explicit( + &activation_unique_sequence, 1U, + memory_order_relaxed) + + 1U; +#ifdef _WIN32 + unsigned long process_id = (unsigned long)GetCurrentProcessId(); +#else + unsigned long process_id = (unsigned long)getpid(); +#endif + size_t directory_length = strlen(directory); + size_t needed = directory_length + strlen(tag) + 80U; + char *path = malloc(needed); + if (!path) { + return NULL; + } + int written = snprintf( + path, needed, "%s%s.cbm-%s-%lu-%" PRIu64, directory, + directory[directory_length - 1U] == '/' || + directory[directory_length - 1U] == '\\' + ? "" + : "/", + tag, process_id, sequence); + if (written <= 0 || (size_t)written >= needed) { + free(path); + return NULL; + } + return path; +} + +static bool activation_identity_equal(const activation_file_identity_t *left, + const activation_file_identity_t *right) { +#ifdef _WIN32 + return left->volume_serial == right->volume_serial && + left->index_high == right->index_high && + left->index_low == right->index_low; +#else + return left->device == right->device && left->inode == right->inode; +#endif +} + +#ifdef _WIN32 + +typedef struct { + void *token_information; + PSID user_sid; + PACL acl; + SECURITY_DESCRIPTOR descriptor; + SECURITY_ATTRIBUTES attributes; +} activation_windows_security_t; + +static wchar_t *activation_utf8_to_wide(const char *value) { + if (!value) { + return NULL; + } + int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, + NULL, 0); + if (needed <= 0) { + return NULL; + } + wchar_t *wide = malloc((size_t)needed * sizeof(*wide)); + if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, + wide, needed) <= 0) { + free(wide); + return NULL; + } + return wide; +} + +static bool activation_windows_user(void **information_out, PSID *sid_out) { + *information_out = NULL; + *sid_out = NULL; + HANDLE token = NULL; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { + return false; + } + DWORD needed = 0; + (void)GetTokenInformation(token, TokenUser, NULL, 0, &needed); + if (needed == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + (void)CloseHandle(token); + return false; + } + void *information = calloc(1, needed); + bool ok = information && + GetTokenInformation(token, TokenUser, information, needed, + &needed) != 0; + (void)CloseHandle(token); + if (!ok) { + free(information); + return false; + } + PSID sid = ((TOKEN_USER *)information)->User.Sid; + if (!sid || !IsValidSid(sid)) { + free(information); + return false; + } + *information_out = information; + *sid_out = sid; + return true; +} + +static bool activation_windows_security_init( + activation_windows_security_t *security) { + memset(security, 0, sizeof(*security)); + if (!activation_windows_user(&security->token_information, + &security->user_sid)) { + return false; + } + EXPLICIT_ACCESSW access; + memset(&access, 0, sizeof(access)); + access.grfAccessPermissions = GENERIC_ALL; + access.grfAccessMode = SET_ACCESS; + access.grfInheritance = NO_INHERITANCE; + access.Trustee.TrusteeForm = TRUSTEE_IS_SID; + access.Trustee.TrusteeType = TRUSTEE_IS_USER; + access.Trustee.ptstrName = (LPWSTR)security->user_sid; + bool ok = SetEntriesInAclW(1, &access, NULL, &security->acl) == ERROR_SUCCESS && + InitializeSecurityDescriptor(&security->descriptor, + SECURITY_DESCRIPTOR_REVISION) && + SetSecurityDescriptorDacl(&security->descriptor, TRUE, + security->acl, FALSE) && + SetSecurityDescriptorControl(&security->descriptor, + SE_DACL_PROTECTED, + SE_DACL_PROTECTED); + if (!ok) { + if (security->acl) { + (void)LocalFree(security->acl); + } + free(security->token_information); + memset(security, 0, sizeof(*security)); + return false; + } + security->attributes.nLength = sizeof(security->attributes); + security->attributes.lpSecurityDescriptor = &security->descriptor; + security->attributes.bInheritHandle = FALSE; + return true; +} + +static void activation_windows_security_destroy( + activation_windows_security_t *security) { + if (security->acl) { + (void)LocalFree(security->acl); + } + free(security->token_information); + memset(security, 0, sizeof(*security)); +} + +static bool activation_windows_owner_is_current(HANDLE handle) { + void *information = NULL; + PSID user_sid = NULL; + if (!activation_windows_user(&information, &user_sid)) { + return false; + } + PSID owner = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + DWORD result = GetSecurityInfo(handle, SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, &owner, NULL, + NULL, NULL, &descriptor); + bool same = result == ERROR_SUCCESS && owner && IsValidSid(owner) && + EqualSid(owner, user_sid); + if (descriptor) { + (void)LocalFree(descriptor); + } + free(information); + return same; +} + +static bool activation_windows_acl_secure(HANDLE handle) { + void *information = NULL; + PSID user_sid = NULL; + if (!activation_windows_user(&information, &user_sid)) { + return false; + } + PACL dacl = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + DWORD result = GetSecurityInfo(handle, SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, NULL, NULL, + &dacl, NULL, &descriptor); + ACL_SIZE_INFORMATION acl_information; + memset(&acl_information, 0, sizeof(acl_information)); + bool secure = result == ERROR_SUCCESS && descriptor && dacl && + IsValidAcl(dacl) != 0 && + GetAclInformation(dacl, &acl_information, + sizeof(acl_information), + AclSizeInformation) != 0; + const DWORD mutation_rights = + GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | + FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | + FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | + WRITE_OWNER; + enum { + ACTIVATION_WINDOWS_ACE_ALLOW = 0x00, + ACTIVATION_WINDOWS_ACE_DENY = 0x01, + ACTIVATION_WINDOWS_ACE_DENY_OBJECT = 0x06, + ACTIVATION_WINDOWS_ACE_DENY_CALLBACK = 0x0a, + ACTIVATION_WINDOWS_ACE_DENY_CALLBACK_OBJECT = 0x0c, + }; + for (DWORD index = 0; secure && index < acl_information.AceCount; index++) { + void *opaque_ace = NULL; + if (!GetAce(dacl, index, &opaque_ace) || !opaque_ace) { + secure = false; + break; + } + ACE_HEADER *header = opaque_ace; + if (header->AceType == ACTIVATION_WINDOWS_ACE_DENY || + header->AceType == ACTIVATION_WINDOWS_ACE_DENY_OBJECT || + header->AceType == ACTIVATION_WINDOWS_ACE_DENY_CALLBACK || + header->AceType == + ACTIVATION_WINDOWS_ACE_DENY_CALLBACK_OBJECT) { + continue; + } + if (header->AceType != ACTIVATION_WINDOWS_ACE_ALLOW) { + /* Only the fixed-layout ACCESS_ALLOWED_ACE is parsed below. Every + * callback/object/compound allow form and every unknown future + * form is rejected so payloads cannot hide mutation grants. */ + secure = false; + break; + } + const size_t sid_offset = offsetof(ACCESS_ALLOWED_ACE, SidStart); + const size_t sid_header_size = offsetof(SID, SubAuthority); + if ((size_t)header->AceSize < sid_offset + sid_header_size) { + secure = false; + break; + } + ACCESS_ALLOWED_ACE *ace = opaque_ace; + if ((ace->Mask & mutation_rights) == 0) { + continue; + } + PSID sid = (PSID)&ace->SidStart; + size_t sid_capacity = (size_t)header->AceSize - sid_offset; + DWORD sid_length = GetSidLengthRequired( + ((SID *)sid)->SubAuthorityCount); + bool trusted = sid_length <= sid_capacity && IsValidSid(sid) && + GetLengthSid(sid) == sid_length && + (EqualSid(sid, user_sid) || + IsWellKnownSid(sid, WinLocalSystemSid) || + IsWellKnownSid(sid, WinBuiltinAdministratorsSid)); + if (!trusted) { + secure = false; + } + } + if (descriptor) { + (void)LocalFree(descriptor); + } + free(information); + return secure; +} + +static bool activation_windows_identity( + HANDLE handle, activation_file_identity_t *identity_out, + bool require_regular) { + BY_HANDLE_FILE_INFORMATION information; + if (handle == INVALID_HANDLE_VALUE || GetFileType(handle) != FILE_TYPE_DISK || + !GetFileInformationByHandle(handle, &information)) { + return false; + } + if (require_regular && + ((information.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + information.nNumberOfLinks != 1)) { + return false; + } + identity_out->volume_serial = information.dwVolumeSerialNumber; + identity_out->index_high = information.nFileIndexHigh; + identity_out->index_low = information.nFileIndexLow; + return true; +} + +static HANDLE activation_windows_directory_open_no_reparse( + const char *directory) { + wchar_t *input = activation_utf8_to_wide(directory); + if (!input) { + return INVALID_HANDLE_VALUE; + } + DWORD needed = GetFullPathNameW(input, 0, NULL, NULL); + wchar_t *path = needed > 0 ? calloc((size_t)needed + 1U, sizeof(*path)) + : NULL; + DWORD length = path ? GetFullPathNameW(input, needed + 1U, path, NULL) : 0; + free(input); + if (!path || length < 3 || length > needed || path[1] != L':' || + (path[2] != L'\\' && path[2] != L'/')) { + free(path); + return INVALID_HANDLE_VALUE; + } + for (DWORD index = 0; index < length; index++) { + if (path[index] == L'/') { + path[index] = L'\\'; + } + } + HANDLE final_handle = INVALID_HANDLE_VALUE; + for (DWORD boundary = 3; boundary <= length; boundary++) { + if (boundary < length && path[boundary] != L'\\') { + continue; + } + if (boundary < length && boundary > 0 && + path[boundary - 1] == L'\\') { + free(path); + return INVALID_HANDLE_VALUE; + } + wchar_t saved = path[boundary]; + path[boundary] = L'\0'; + HANDLE handle = CreateFileW( + path, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + path[boundary] = saved; + BY_HANDLE_FILE_INFORMATION information; + bool valid = handle != INVALID_HANDLE_VALUE && + GetFileType(handle) == FILE_TYPE_DISK && + GetFileInformationByHandle(handle, &information) && + (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; + if (!valid) { + if (handle != INVALID_HANDLE_VALUE) { + (void)CloseHandle(handle); + } + free(path); + return INVALID_HANDLE_VALUE; + } + if (boundary == length) { + final_handle = handle; + } else { + (void)CloseHandle(handle); + } + } + free(path); + return final_handle; +} + +static bool activation_directory_secure( + const char *directory, int *unused, + activation_file_identity_t *identity_out) { + (void)unused; + HANDLE handle = activation_windows_directory_open_no_reparse(directory); + BY_HANDLE_FILE_INFORMATION information; + bool ok = handle != INVALID_HANDLE_VALUE && + GetFileInformationByHandle(handle, &information) && + (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && + activation_windows_identity(handle, identity_out, false) && + activation_windows_owner_is_current(handle) && + activation_windows_acl_secure(handle); + if (handle != INVALID_HANDLE_VALUE) { + (void)CloseHandle(handle); + } + return ok; +} + +#else + +static bool activation_posix_acl_empty(int descriptor) { + return cbm_macos_extended_acl_fd_is_empty(descriptor); +} + +static char *activation_posix_walk_path(const char *directory) { +#ifdef __APPLE__ + static const char *const aliases[] = {"/tmp", "/var"}; + for (size_t index = 0; index < sizeof(aliases) / sizeof(aliases[0]); + index++) { + const char *alias = aliases[index]; + size_t alias_length = strlen(alias); + if (strncmp(directory, alias, alias_length) != 0 || + (directory[alias_length] != '\0' && + directory[alias_length] != '/')) { + continue; + } + struct stat alias_status; + char resolved[4096]; + if (lstat(alias, &alias_status) != 0 || + !S_ISLNK(alias_status.st_mode) || alias_status.st_uid != 0 || + !realpath(alias, resolved)) { + return NULL; + } + struct stat resolved_status; + if (lstat(resolved, &resolved_status) != 0 || + !S_ISDIR(resolved_status.st_mode) || resolved_status.st_uid != 0) { + return NULL; + } + size_t needed = strlen(resolved) + strlen(directory + alias_length) + 1U; + char *mapped = malloc(needed); + if (!mapped) { + return NULL; + } + int written = snprintf(mapped, needed, "%s%s", resolved, + directory + alias_length); + if (written <= 0 || (size_t)written >= needed) { + free(mapped); + return NULL; + } + return mapped; + } +#endif + return activation_string_copy(directory); +} + +static bool activation_posix_intermediate_secure(const struct stat *status) { + bool trusted_owner = status->st_uid == 0 || status->st_uid == geteuid(); + bool private_permissions = (status->st_mode & 0022) == 0; + bool root_sticky = status->st_uid == 0 && + (status->st_mode & S_ISVTX) != 0; + return S_ISDIR(status->st_mode) && trusted_owner && + (private_permissions || root_sticky); +} + +static bool activation_directory_secure( + const char *directory, int *directory_fd_out, + activation_file_identity_t *identity_out) { + int flags = O_RDONLY | O_DIRECTORY | O_CLOEXEC; +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif + char *walk_path = activation_posix_walk_path(directory); + if (!walk_path) { + return false; + } + bool absolute = walk_path[0] == '/'; + int descriptor = open(absolute ? "/" : ".", flags); + bool ok = descriptor >= 0; + char *cursor = walk_path; + while (*cursor == '/') { + cursor++; + } + if (ok && *cursor) { + struct stat initial_status; + ok = fstat(descriptor, &initial_status) == 0 && + activation_posix_intermediate_secure(&initial_status); + } + while (ok && *cursor) { + char *component = cursor; + while (*cursor && *cursor != '/') { + cursor++; + } + char saved = *cursor; + *cursor = '\0'; + if (strcmp(component, ".") == 0) { + /* Harmless explicit current-directory component. */ + } else if (strcmp(component, "..") == 0 || !component[0]) { + ok = false; + } else { + int next = openat(descriptor, component, flags); + struct stat next_status; + bool next_ok = next >= 0 && fstat(next, &next_status) == 0; + char *remaining = cursor + (saved ? 1 : 0); + while (*remaining == '/') { + remaining++; + } + if (next_ok && *remaining && + !activation_posix_intermediate_secure(&next_status)) { + next_ok = false; + } + if (next_ok) { + (void)close(descriptor); + descriptor = next; + } else { + if (next >= 0) { + (void)close(next); + } + ok = false; + } + } + *cursor = saved; + while (*cursor == '/') { + cursor++; + } + } + struct stat status; + ok = ok && fstat(descriptor, &status) == 0 && + S_ISDIR(status.st_mode) && status.st_uid == geteuid() && + (status.st_mode & 0022) == 0 && + activation_posix_acl_empty(descriptor); + free(walk_path); + if (!ok) { + if (descriptor >= 0) { + (void)close(descriptor); + } + return false; + } + identity_out->device = status.st_dev; + identity_out->inode = status.st_ino; + *directory_fd_out = descriptor; + return true; +} + +#endif + +static bool activation_directory_still_valid( + const cbm_activation_transaction_t *transaction) { +#ifdef _WIN32 + int ignored = 0; + activation_file_identity_t current; + return activation_directory_secure(transaction->directory_path, &ignored, + ¤t) && + activation_identity_equal(¤t, + &transaction->directory_identity); +#else + struct stat status; + if (transaction->directory_fd < 0 || + fstat(transaction->directory_fd, &status) != 0 || + !S_ISDIR(status.st_mode) || status.st_uid != geteuid() || + (status.st_mode & 0022) != 0 || + !activation_posix_acl_empty(transaction->directory_fd)) { + return false; + } + activation_file_identity_t current = { + .device = status.st_dev, + .inode = status.st_ino, + }; + if (!activation_identity_equal(¤t, + &transaction->directory_identity)) { + return false; + } + int path_fd = -1; + activation_file_identity_t path_identity; + bool path_same = activation_directory_secure( + transaction->directory_path, &path_fd, + &path_identity) && + activation_identity_equal(&path_identity, ¤t); + if (path_fd >= 0) { + (void)close(path_fd); + } + return path_same; +#endif +} + +static bool activation_native_close(activation_native_file_t file) { +#ifdef _WIN32 + return file != INVALID_HANDLE_VALUE && CloseHandle(file) != 0; +#else + return file >= 0 && close(file) == 0; +#endif +} + +static bool activation_native_sync(activation_native_file_t file) { +#ifdef _WIN32 + return FlushFileBuffers(file) != 0; +#else + int result; + do { + result = fsync(file); + } while (result != 0 && errno == EINTR); + return result == 0 || errno == EINVAL || errno == ENOTSUP || errno == EROFS; +#endif +} + +static bool activation_native_write_all(activation_native_file_t file, + const void *data, size_t length) { + const unsigned char *cursor = data; + while (length > 0) { +#ifdef _WIN32 + DWORD chunk = length > (size_t)UINT32_MAX ? UINT32_MAX : (DWORD)length; + DWORD written = 0; + if (!WriteFile(file, cursor, chunk, &written, NULL) || written == 0) { + return false; + } + size_t count = (size_t)written; +#else + ssize_t result; + do { + result = write(file, cursor, length); + } while (result < 0 && errno == EINTR); + if (result <= 0) { + return false; + } + size_t count = (size_t)result; +#endif + cursor += count; + length -= count; + } + return true; +} + +typedef enum { + ACTIVATION_CREATE_OK = 0, + ACTIVATION_CREATE_EXISTS = 1, + ACTIVATION_CREATE_ERROR = 2, +} activation_create_status_t; + +static activation_create_status_t activation_private_file_create( + const cbm_activation_transaction_t *transaction, const char *path, + const char *name, activation_native_file_t *file_out, + activation_file_identity_t *identity_out) { + *file_out = ACTIVATION_INVALID_FILE; + if (!activation_directory_still_valid(transaction)) { + return ACTIVATION_CREATE_ERROR; + } +#ifdef _WIN32 + wchar_t *wide = activation_utf8_to_wide(path); + activation_windows_security_t security; + if (!wide || !activation_windows_security_init(&security)) { + free(wide); + return ACTIVATION_CREATE_ERROR; + } + SetLastError(ERROR_SUCCESS); + HANDLE file = CreateFileW( + wide, GENERIC_READ | GENERIC_WRITE | READ_CONTROL | DELETE, + FILE_SHARE_READ, &security.attributes, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + DWORD error = file == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS; + free(wide); + bool valid = file != INVALID_HANDLE_VALUE && + SetHandleInformation(file, HANDLE_FLAG_INHERIT, 0) != 0 && + activation_windows_identity(file, identity_out, true) && + activation_windows_owner_is_current(file) && + activation_windows_acl_secure(file); + activation_windows_security_destroy(&security); + if (!valid) { + if (file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(file); + } + return error == ERROR_FILE_EXISTS || error == ERROR_ALREADY_EXISTS + ? ACTIVATION_CREATE_EXISTS + : ACTIVATION_CREATE_ERROR; + } + *file_out = file; + return ACTIVATION_CREATE_OK; +#else + int flags = O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC; +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif + int file = openat(transaction->directory_fd, name, flags, 0700); + int open_error = errno; + struct stat status; + bool valid = file >= 0 && fchmod(file, 0700) == 0 && + fstat(file, &status) == 0 && S_ISREG(status.st_mode) && + status.st_uid == geteuid() && status.st_nlink == 1 && + (status.st_mode & 0777) == 0700 && + activation_posix_acl_empty(file); + if (!valid) { + if (file >= 0) { + (void)close(file); + } + return file < 0 && open_error == EEXIST ? ACTIVATION_CREATE_EXISTS + : ACTIVATION_CREATE_ERROR; + } + identity_out->device = status.st_dev; + identity_out->inode = status.st_ino; + *file_out = file; + return ACTIVATION_CREATE_OK; +#endif +} + +static cbm_activation_transaction_status_t activation_create_unique( + const cbm_activation_transaction_t *transaction, const char *tag, + char **path_out, char **name_out, activation_native_file_t *file_out, + activation_file_identity_t *identity_out) { + *path_out = NULL; + *name_out = NULL; + *file_out = ACTIVATION_INVALID_FILE; + for (unsigned int attempt = 0; attempt < 1024U; attempt++) { + char *candidate = activation_unique_path(transaction->directory_path, tag); + if (!candidate) { + return CBM_ACTIVATION_TRANSACTION_NO_MEMORY; + } + char *name = activation_path_name_copy(candidate); + if (!name) { + free(candidate); + return CBM_ACTIVATION_TRANSACTION_NO_MEMORY; + } + activation_create_status_t created = activation_private_file_create( + transaction, candidate, name, file_out, identity_out); + if (created == ACTIVATION_CREATE_OK) { + *path_out = candidate; + *name_out = name; + return CBM_ACTIVATION_TRANSACTION_OK; + } + free(candidate); + free(name); + if (created != ACTIVATION_CREATE_EXISTS) { + return CBM_ACTIVATION_TRANSACTION_IO; + } + } + return CBM_ACTIVATION_TRANSACTION_IO; +} + +#ifdef _WIN32 +static bool activation_external_snapshot( + const char *path, bool *exists_out, + activation_file_identity_t *identity_out) { + *exists_out = false; + wchar_t *wide = activation_utf8_to_wide(path); + if (!wide) { + return false; + } + DWORD attributes = GetFileAttributesW(wide); + if (attributes == INVALID_FILE_ATTRIBUTES) { + DWORD error = GetLastError(); + free(wide); + return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND; + } + if ((attributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + free(wide); + return false; + } + HANDLE handle = CreateFileW( + wide, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL); + free(wide); + bool valid = handle != INVALID_HANDLE_VALUE && + activation_windows_identity(handle, identity_out, true) && + activation_windows_owner_is_current(handle) && + activation_windows_acl_secure(handle); + if (handle != INVALID_HANDLE_VALUE) { + (void)CloseHandle(handle); + } + if (!valid) { + return false; + } + *exists_out = true; + return true; +} +#endif + +#ifndef _WIN32 +static bool activation_posix_entry_snapshot_with_links( + const cbm_activation_transaction_t *transaction, const char *name, + nlink_t required_links, bool *exists_out, + activation_file_identity_t *identity_out) { + *exists_out = false; + if (!activation_directory_still_valid(transaction)) { + return false; + } + struct stat before; + if (fstatat(transaction->directory_fd, name, &before, + AT_SYMLINK_NOFOLLOW) != 0) { + return errno == ENOENT; + } + if (!S_ISREG(before.st_mode) || before.st_uid != geteuid() || + before.st_nlink != required_links || (before.st_mode & 0022) != 0) { + return false; + } + int flags = O_RDONLY | O_CLOEXEC; +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif + int file = openat(transaction->directory_fd, name, flags); + struct stat opened; + struct stat after; + bool valid = file >= 0 && fstat(file, &opened) == 0 && + S_ISREG(opened.st_mode) && opened.st_uid == geteuid() && + opened.st_nlink == required_links && + (opened.st_mode & 0022) == 0 && + opened.st_dev == before.st_dev && + opened.st_ino == before.st_ino && + activation_posix_acl_empty(file) && + fstatat(transaction->directory_fd, name, &after, + AT_SYMLINK_NOFOLLOW) == 0 && + S_ISREG(after.st_mode) && after.st_uid == geteuid() && + after.st_nlink == required_links && + (after.st_mode & 0022) == 0 && + after.st_dev == opened.st_dev && + after.st_ino == opened.st_ino; + bool closed = file >= 0 && close(file) == 0; + if (!valid || !closed) { + return false; + } + identity_out->device = opened.st_dev; + identity_out->inode = opened.st_ino; + *exists_out = true; + return true; +} +#endif + +static bool activation_entry_snapshot( + const cbm_activation_transaction_t *transaction, const char *path, + const char *name, bool *exists_out, + activation_file_identity_t *identity_out) { +#ifdef _WIN32 + return activation_directory_still_valid(transaction) && + activation_external_snapshot(path, exists_out, identity_out); +#else + (void)path; + return activation_posix_entry_snapshot_with_links( + transaction, name, (nlink_t)1, exists_out, identity_out); +#endif +} + +static bool activation_path_matches( + const cbm_activation_transaction_t *transaction, const char *path, + const char *name, const activation_file_identity_t *expected, + bool *exists_out) { + activation_file_identity_t actual; + bool exists = false; + if (!activation_entry_snapshot(transaction, path, name, &exists, &actual)) { + return false; + } + *exists_out = exists; + return !exists || activation_identity_equal(&actual, expected); +} + +static bool activation_sync_directory( + const cbm_activation_transaction_t *transaction) { +#ifdef _WIN32 + /* MoveFileExW(MOVEFILE_WRITE_THROUGH) is the strongest portable + * directory-entry durability primitive available here. */ + (void)transaction; + return true; +#else + int result; + do { + result = fsync(transaction->directory_fd); + } while (result != 0 && errno == EINTR); + return result == 0 || errno == EINVAL || errno == ENOTSUP || errno == EROFS; +#endif +} + +static bool activation_rename( + const cbm_activation_transaction_t *transaction, const char *source, + const char *source_name, const char *destination, + const char *destination_name, bool replace_destination) { + if (!activation_directory_still_valid(transaction)) { + return false; + } +#ifdef _WIN32 + wchar_t *wide_source = activation_utf8_to_wide(source); + wchar_t *wide_destination = activation_utf8_to_wide(destination); + DWORD flags = MOVEFILE_WRITE_THROUGH | + (replace_destination ? MOVEFILE_REPLACE_EXISTING : 0); + bool ok = wide_source && wide_destination && + MoveFileExW(wide_source, wide_destination, flags) != 0 && + activation_directory_still_valid(transaction); + free(wide_source); + free(wide_destination); + return ok; +#else + (void)source; + (void)destination; + (void)replace_destination; + int result; + do { + result = renameat(transaction->directory_fd, source_name, + transaction->directory_fd, destination_name); + } while (result != 0 && errno == EINTR); + return result == 0; +#endif +} + +typedef enum { + ACTIVATION_UNLINK_OK = 0, + ACTIVATION_UNLINK_DEFERRED = 1, + ACTIVATION_UNLINK_ERROR = 2, +} activation_unlink_status_t; + +static activation_unlink_status_t activation_unlink_expected( + const cbm_activation_transaction_t *transaction, const char *path, + const char *name, const activation_file_identity_t *expected, + bool allow_windows_deferred) { + bool exists = false; + if (!activation_path_matches(transaction, path, name, expected, &exists)) { + return ACTIVATION_UNLINK_ERROR; + } + if (!exists) { + return ACTIVATION_UNLINK_OK; + } +#ifdef _WIN32 + wchar_t *wide = activation_utf8_to_wide(path); + if (!wide) { + return ACTIVATION_UNLINK_ERROR; + } + if (DeleteFileW(wide)) { + free(wide); + return ACTIVATION_UNLINK_OK; + } + DWORD error = GetLastError(); + bool deferred = allow_windows_deferred && + (error == ERROR_ACCESS_DENIED || + error == ERROR_SHARING_VIOLATION || + error == ERROR_LOCK_VIOLATION) && + MoveFileExW(wide, NULL, MOVEFILE_DELAY_UNTIL_REBOOT) != 0; + free(wide); + return deferred ? ACTIVATION_UNLINK_DEFERRED : ACTIVATION_UNLINK_ERROR; +#else + (void)allow_windows_deferred; + int result; + do { + result = unlinkat(transaction->directory_fd, name, 0); + } while (result != 0 && errno == EINTR); + return result == 0 || errno == ENOENT ? ACTIVATION_UNLINK_OK + : ACTIVATION_UNLINK_ERROR; +#endif +} + +static void activation_transaction_destroy( + cbm_activation_transaction_t *transaction) { + if (!transaction) { + return; + } +#ifndef _WIN32 + if (transaction->directory_fd >= 0) { + (void)close(transaction->directory_fd); + } +#endif + free(transaction->target_path); + free(transaction->directory_path); + free(transaction->target_name); + free(transaction->staged_path); + free(transaction->staged_name); + free(transaction->backup_path); + free(transaction->backup_name); + free(transaction); +} + +static cbm_activation_transaction_status_t activation_discard_staged_assets( + cbm_activation_transaction_t *transaction) { + bool ok = true; + if (transaction->staged_exists) { + activation_unlink_status_t removed = activation_unlink_expected( + transaction, transaction->staged_path, transaction->staged_name, + &transaction->staged_identity, false); + ok = removed == ACTIVATION_UNLINK_OK && ok; + if (removed == ACTIVATION_UNLINK_OK) { + transaction->staged_exists = false; + } + } + if (transaction->backup_exists && !transaction->backup_contains_target) { + activation_unlink_status_t removed = activation_unlink_expected( + transaction, transaction->backup_path, transaction->backup_name, + &transaction->backup_identity, false); + ok = removed == ACTIVATION_UNLINK_OK && ok; + if (removed == ACTIVATION_UNLINK_OK) { + transaction->backup_exists = false; + } + } + ok = activation_sync_directory(transaction) && ok; + return ok ? CBM_ACTIVATION_TRANSACTION_OK + : CBM_ACTIVATION_TRANSACTION_IO; +} + +static cbm_activation_transaction_status_t activation_transaction_prepare( + const char *target_path, activation_action_t action, + cbm_activation_transaction_t **transaction_out) { + *transaction_out = NULL; + cbm_activation_transaction_t *transaction = calloc(1, sizeof(*transaction)); + if (!transaction) { + return CBM_ACTIVATION_TRANSACTION_NO_MEMORY; + } +#ifndef _WIN32 + transaction->directory_fd = -1; +#endif + transaction->action = action; + transaction->state = ACTIVATION_STAGED; + transaction->target_path = activation_string_copy(target_path); + if (!transaction->target_path) { + activation_transaction_destroy(transaction); + return CBM_ACTIVATION_TRANSACTION_NO_MEMORY; + } + if (!activation_target_parts(target_path, &transaction->directory_path, + &transaction->target_name)) { + activation_transaction_destroy(transaction); + return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT; + } +#ifdef _WIN32 + int ignored = 0; + if (!activation_directory_secure(transaction->directory_path, &ignored, + &transaction->directory_identity)) { +#else + if (!activation_directory_secure(transaction->directory_path, + &transaction->directory_fd, + &transaction->directory_identity)) { +#endif + activation_transaction_destroy(transaction); + return CBM_ACTIVATION_TRANSACTION_IO; + } + if (!activation_entry_snapshot( + transaction, transaction->target_path, transaction->target_name, + &transaction->target_existed, &transaction->target_identity)) { + activation_transaction_destroy(transaction); + return CBM_ACTIVATION_TRANSACTION_IO; + } + if (transaction->target_existed) { + activation_native_file_t reservation = ACTIVATION_INVALID_FILE; + cbm_activation_transaction_status_t status = activation_create_unique( + transaction, "backup", &transaction->backup_path, + &transaction->backup_name, &reservation, + &transaction->backup_identity); + if (status != CBM_ACTIVATION_TRANSACTION_OK) { + activation_transaction_destroy(transaction); + return status; + } + transaction->backup_exists = true; + bool durable = activation_native_sync(reservation); + bool closed = activation_native_close(reservation); + if (!durable || !closed || !activation_sync_directory(transaction)) { + (void)activation_discard_staged_assets(transaction); + activation_transaction_destroy(transaction); + return CBM_ACTIVATION_TRANSACTION_IO; + } + } + *transaction_out = transaction; + return CBM_ACTIVATION_TRANSACTION_OK; +} + +static void activation_failed_stage_cleanup( + cbm_activation_transaction_t *transaction) { + if (transaction) { + (void)activation_discard_staged_assets(transaction); + activation_transaction_destroy(transaction); + } +} + +cbm_activation_transaction_status_t cbm_activation_transaction_stage_bytes( + const char *target_path, const void *candidate, size_t candidate_size, + cbm_activation_transaction_t **transaction_out) { + if (transaction_out) { + *transaction_out = NULL; + } + if (!target_path || !candidate || candidate_size == 0 || !transaction_out) { + return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT; + } + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t status = activation_transaction_prepare( + target_path, ACTIVATION_REPLACE, &transaction); + if (status != CBM_ACTIVATION_TRANSACTION_OK) { + return status; + } + activation_native_file_t staged = ACTIVATION_INVALID_FILE; + status = activation_create_unique( + transaction, "stage", &transaction->staged_path, + &transaction->staged_name, &staged, &transaction->staged_identity); + if (status != CBM_ACTIVATION_TRANSACTION_OK) { + activation_failed_stage_cleanup(transaction); + return status; + } + transaction->staged_exists = true; + bool written = activation_native_write_all(staged, candidate, candidate_size); + bool durable = written && activation_native_sync(staged); + bool closed = activation_native_close(staged); + if (!written || !durable || !closed || + !activation_sync_directory(transaction)) { + activation_failed_stage_cleanup(transaction); + return CBM_ACTIVATION_TRANSACTION_IO; + } + *transaction_out = transaction; + return CBM_ACTIVATION_TRANSACTION_OK; +} + +static bool activation_source_open(const char *path, + activation_native_file_t *file_out) { + *file_out = ACTIVATION_INVALID_FILE; + char *directory = NULL; + char *name = NULL; + if (!activation_target_parts(path, &directory, &name)) { + return false; + } +#ifdef _WIN32 + int ignored = 0; + activation_file_identity_t directory_identity; + activation_file_identity_t expected; + bool exists = false; + if (!activation_directory_secure(directory, &ignored, &directory_identity) || + !activation_external_snapshot(path, &exists, &expected) || !exists) { + free(directory); + free(name); + return false; + } + wchar_t *wide = activation_utf8_to_wide(path); + if (!wide) { + free(directory); + free(name); + return false; + } + HANDLE file = CreateFileW( + wide, GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, + NULL); + free(wide); + activation_file_identity_t actual; + activation_file_identity_t directory_now; + bool valid = file != INVALID_HANDLE_VALUE && + activation_windows_identity(file, &actual, true) && + activation_windows_owner_is_current(file) && + activation_windows_acl_secure(file) && + activation_identity_equal(&actual, &expected) && + activation_directory_secure(directory, &ignored, + &directory_now) && + activation_identity_equal(&directory_now, + &directory_identity); + if (!valid) { + if (file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(file); + } + free(directory); + free(name); + return false; + } +#else + int directory_fd = -1; + activation_file_identity_t directory_identity; + if (!activation_directory_secure(directory, &directory_fd, + &directory_identity)) { + free(directory); + free(name); + return false; + } + struct stat before; + bool before_valid = + fstatat(directory_fd, name, &before, AT_SYMLINK_NOFOLLOW) == 0 && + S_ISREG(before.st_mode) && before.st_uid == geteuid() && + before.st_nlink == 1 && (before.st_mode & 0022) == 0; + int flags = O_RDONLY | O_CLOEXEC; +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif + int file = before_valid ? openat(directory_fd, name, flags) : -1; + struct stat information; + bool valid = file >= 0 && fstat(file, &information) == 0 && + S_ISREG(information.st_mode) && + information.st_uid == geteuid() && + information.st_nlink == 1 && + (information.st_mode & 0022) == 0 && + activation_posix_acl_empty(file) && + information.st_dev == before.st_dev && + information.st_ino == before.st_ino; + (void)close(directory_fd); + if (!valid) { + if (file >= 0) { + (void)close(file); + } + free(directory); + free(name); + return false; + } +#endif + free(directory); + free(name); + *file_out = file; + return true; +} + +static bool activation_native_read(activation_native_file_t file, void *buffer, + size_t capacity, size_t *read_out) { +#ifdef _WIN32 + DWORD amount = 0; + DWORD request = capacity > (size_t)UINT32_MAX ? UINT32_MAX : (DWORD)capacity; + if (!ReadFile(file, buffer, request, &amount, NULL)) { + return false; + } + *read_out = (size_t)amount; + return true; +#else + ssize_t result; + do { + result = read(file, buffer, capacity); + } while (result < 0 && errno == EINTR); + if (result < 0) { + return false; + } + *read_out = (size_t)result; + return true; +#endif +} + +cbm_activation_transaction_status_t cbm_activation_transaction_stage_file( + const char *target_path, const char *candidate_path, + cbm_activation_transaction_t **transaction_out) { + if (transaction_out) { + *transaction_out = NULL; + } + if (!target_path || !candidate_path || !candidate_path[0] || + !transaction_out) { + return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT; + } + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t status = activation_transaction_prepare( + target_path, ACTIVATION_REPLACE, &transaction); + if (status != CBM_ACTIVATION_TRANSACTION_OK) { + return status; + } + activation_native_file_t source = ACTIVATION_INVALID_FILE; + if (!activation_source_open(candidate_path, &source)) { + activation_failed_stage_cleanup(transaction); + return CBM_ACTIVATION_TRANSACTION_IO; + } + activation_native_file_t staged = ACTIVATION_INVALID_FILE; + status = activation_create_unique( + transaction, "stage", &transaction->staged_path, + &transaction->staged_name, &staged, &transaction->staged_identity); + if (status != CBM_ACTIVATION_TRANSACTION_OK) { + (void)activation_native_close(source); + activation_failed_stage_cleanup(transaction); + return status; + } + transaction->staged_exists = true; + unsigned char buffer[64U * 1024U]; + size_t total = 0; + bool copied = true; + for (;;) { + size_t amount = 0; + if (!activation_native_read(source, buffer, sizeof(buffer), &amount)) { + copied = false; + break; + } + if (amount == 0) { + break; + } + if (SIZE_MAX - total < amount || + !activation_native_write_all(staged, buffer, amount)) { + copied = false; + break; + } + total += amount; + } + bool durable = copied && total > 0 && activation_native_sync(staged); + bool source_closed = activation_native_close(source); + bool staged_closed = activation_native_close(staged); + if (!copied || total == 0 || !durable || !source_closed || + !staged_closed || !activation_sync_directory(transaction)) { + activation_failed_stage_cleanup(transaction); + return CBM_ACTIVATION_TRANSACTION_IO; + } + *transaction_out = transaction; + return CBM_ACTIVATION_TRANSACTION_OK; +} + +cbm_activation_transaction_status_t cbm_activation_transaction_stage_removal( + const char *target_path, + cbm_activation_transaction_t **transaction_out) { + if (transaction_out) { + *transaction_out = NULL; + } + if (!target_path || !transaction_out) { + return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT; + } + return activation_transaction_prepare(target_path, ACTIVATION_REMOVE, + transaction_out); +} + +#ifdef _WIN32 +static bool activation_windows_copy_target_to_backup( + cbm_activation_transaction_t *transaction) { + if (!activation_directory_still_valid(transaction)) { + return false; + } + wchar_t *target_path = activation_utf8_to_wide(transaction->target_path); + wchar_t *backup_path = activation_utf8_to_wide(transaction->backup_path); + if (!target_path || !backup_path) { + free(target_path); + free(backup_path); + return false; + } + HANDLE target = CreateFileW( + target_path, GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, + NULL); + HANDLE backup = CreateFileW( + backup_path, + GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, + NULL); + free(target_path); + free(backup_path); + activation_file_identity_t target_identity; + activation_file_identity_t backup_identity; + LARGE_INTEGER beginning = {.QuadPart = 0}; + bool valid = target != INVALID_HANDLE_VALUE && + backup != INVALID_HANDLE_VALUE && + activation_windows_identity(target, &target_identity, true) && + activation_windows_identity(backup, &backup_identity, true) && + activation_windows_owner_is_current(target) && + activation_windows_owner_is_current(backup) && + activation_windows_acl_secure(target) && + activation_windows_acl_secure(backup) && + activation_identity_equal(&target_identity, + &transaction->target_identity) && + activation_identity_equal(&backup_identity, + &transaction->backup_identity) && + SetFilePointerEx(backup, beginning, NULL, FILE_BEGIN) != 0 && + SetEndOfFile(backup) != 0; + unsigned char buffer[64U * 1024U]; + while (valid) { + DWORD amount = 0; + valid = ReadFile(target, buffer, (DWORD)sizeof(buffer), &amount, NULL) != 0; + if (!valid || amount == 0) { + break; + } + valid = activation_native_write_all(backup, buffer, (size_t)amount); + } + valid = valid && activation_native_sync(backup); + bool target_closed = target != INVALID_HANDLE_VALUE && CloseHandle(target) != 0; + bool backup_closed = backup != INVALID_HANDLE_VALUE && CloseHandle(backup) != 0; + return valid && target_closed && backup_closed && + activation_directory_still_valid(transaction); +} +#endif + +typedef enum { + ACTIVATION_PUBLISH_OK = 0, + ACTIVATION_PUBLISH_UNCHANGED_ERROR = 1, + ACTIVATION_PUBLISH_CHANGED_ERROR = 2, +} activation_publish_status_t; + +#if defined(__APPLE__) || (defined(__linux__) && defined(SYS_renameat2)) +static bool activation_noreplace_primitive_unavailable(int error) { + bool unavailable = error == ENOSYS || error == EINVAL; +#ifdef ENOTSUP + unavailable = unavailable || error == ENOTSUP; +#endif +#ifdef EOPNOTSUPP + unavailable = unavailable || error == EOPNOTSUPP; +#endif + return unavailable; +} +#endif + +#ifndef _WIN32 +/* Portable last resort for platforms/filesystems without a no-replace rename. + * linkat() atomically claims an absent destination. Until the staging link is + * removed both names deliberately retain the same verified inode, and the + * transaction's staged_exists flag records that partial publication so + * rollback can remove only the target link. */ +static activation_publish_status_t activation_publish_absent_link_fallback( + cbm_activation_transaction_t *transaction) { + if (!activation_directory_still_valid(transaction)) { + return ACTIVATION_PUBLISH_UNCHANGED_ERROR; + } + int linked; + do { + linked = linkat(transaction->directory_fd, transaction->staged_name, + transaction->directory_fd, transaction->target_name, + 0); + } while (linked != 0 && errno == EINTR); + if (linked != 0) { + return ACTIVATION_PUBLISH_UNCHANGED_ERROR; + } + + activation_file_identity_t staged_identity; + activation_file_identity_t target_identity; + bool staged_exists = false; + bool target_exists = false; + bool linked_pair = + activation_posix_entry_snapshot_with_links( + transaction, transaction->staged_name, (nlink_t)2, + &staged_exists, &staged_identity) && + staged_exists && + activation_identity_equal(&staged_identity, + &transaction->staged_identity) && + activation_posix_entry_snapshot_with_links( + transaction, transaction->target_name, (nlink_t)2, + &target_exists, &target_identity) && + target_exists && + activation_identity_equal(&target_identity, + &transaction->staged_identity); + if (!linked_pair) { + return ACTIVATION_PUBLISH_CHANGED_ERROR; + } + + int removed; + do { + removed = unlinkat(transaction->directory_fd, + transaction->staged_name, 0); + } while (removed != 0 && errno == EINTR); + if (removed != 0 && errno != ENOENT) { + return ACTIVATION_PUBLISH_CHANGED_ERROR; + } + transaction->staged_exists = false; + + activation_file_identity_t published_identity; + bool published_exists = false; + if (!activation_posix_entry_snapshot_with_links( + transaction, transaction->target_name, (nlink_t)1, + &published_exists, &published_identity) || + !published_exists || + !activation_identity_equal(&published_identity, + &transaction->staged_identity)) { + return ACTIVATION_PUBLISH_CHANGED_ERROR; + } + return ACTIVATION_PUBLISH_OK; +} +#endif + +static activation_publish_status_t activation_finish_absent_publish( + cbm_activation_transaction_t *transaction) { + transaction->staged_exists = false; + bool target_exists = false; + return activation_path_matches( + transaction, transaction->target_path, + transaction->target_name, &transaction->staged_identity, + &target_exists) && + target_exists + ? ACTIVATION_PUBLISH_OK + : ACTIVATION_PUBLISH_CHANGED_ERROR; +} + +static activation_publish_status_t activation_publish_absent_replacement( + cbm_activation_transaction_t *transaction) { + if (!activation_directory_still_valid(transaction)) { + return ACTIVATION_PUBLISH_UNCHANGED_ERROR; + } + bool staged_exists = false; + if (!activation_path_matches( + transaction, transaction->staged_path, + transaction->staged_name, &transaction->staged_identity, + &staged_exists) || + !staged_exists) { + return ACTIVATION_PUBLISH_UNCHANGED_ERROR; + } +#ifdef _WIN32 + wchar_t *source = activation_utf8_to_wide(transaction->staged_path); + wchar_t *destination = activation_utf8_to_wide(transaction->target_path); + bool published = source && destination && + MoveFileExW(source, destination, + MOVEFILE_WRITE_THROUGH) != 0; + free(source); + free(destination); + if (!published) { + return ACTIVATION_PUBLISH_UNCHANGED_ERROR; + } + return activation_finish_absent_publish(transaction); +#elif defined(__APPLE__) + int renamed; + do { + renamed = renameatx_np(transaction->directory_fd, + transaction->staged_name, + transaction->directory_fd, + transaction->target_name, RENAME_EXCL); + } while (renamed != 0 && errno == EINTR); + if (renamed == 0) { + return activation_finish_absent_publish(transaction); + } + if (!activation_noreplace_primitive_unavailable(errno)) { + return ACTIVATION_PUBLISH_UNCHANGED_ERROR; + } + return activation_publish_absent_link_fallback(transaction); +#elif defined(__linux__) && defined(SYS_renameat2) + const unsigned int rename_noreplace = 1U; + long renamed; + do { + renamed = syscall(SYS_renameat2, transaction->directory_fd, + transaction->staged_name, + transaction->directory_fd, + transaction->target_name, rename_noreplace); + } while (renamed != 0 && errno == EINTR); + if (renamed == 0) { + return activation_finish_absent_publish(transaction); + } + if (!activation_noreplace_primitive_unavailable(errno)) { + return ACTIVATION_PUBLISH_UNCHANGED_ERROR; + } + return activation_publish_absent_link_fallback(transaction); +#else + return activation_publish_absent_link_fallback(transaction); +#endif +} + +#ifndef _WIN32 +static bool activation_linked_backup_pair_valid( + const cbm_activation_transaction_t *transaction) { + activation_file_identity_t target_identity; + activation_file_identity_t backup_identity; + bool target_exists = false; + bool backup_exists = false; + return activation_posix_entry_snapshot_with_links( + transaction, transaction->target_name, (nlink_t)2, + &target_exists, &target_identity) && + target_exists && + activation_identity_equal(&target_identity, + &transaction->target_identity) && + activation_posix_entry_snapshot_with_links( + transaction, transaction->backup_name, (nlink_t)2, + &backup_exists, &backup_identity) && + backup_exists && + activation_identity_equal(&backup_identity, + &transaction->target_identity); +} + +static bool activation_remove_linked_backup( + cbm_activation_transaction_t *transaction) { + if (!activation_linked_backup_pair_valid(transaction)) { + return false; + } + int result; + do { + result = unlinkat(transaction->directory_fd, + transaction->backup_name, 0); + } while (result != 0 && errno == EINTR); + if (result == 0) { + transaction->backup_exists = false; + transaction->backup_contains_target = false; + } + return result == 0; +} +#endif + +/* Publish over an existing target without a disappearance window. POSIX + * retains the old inode through a same-directory hard link before renameat. + * Windows copies the verified old bytes into the already-private backup, then + * MoveFileExW atomically replaces the target with the private staged file. */ +static activation_publish_status_t activation_publish_existing_replacement( + cbm_activation_transaction_t *transaction) { +#ifdef _WIN32 + transaction->backup_contains_target = false; + if (!activation_windows_copy_target_to_backup(transaction)) { + return ACTIVATION_PUBLISH_UNCHANGED_ERROR; + } + if (!activation_rename( + transaction, transaction->staged_path, transaction->staged_name, + transaction->target_path, transaction->target_name, true)) { + return ACTIVATION_PUBLISH_UNCHANGED_ERROR; + } + transaction->staged_exists = false; + transaction->backup_contains_target = true; + bool target_exists = false; + if (!activation_path_matches( + transaction, transaction->target_path, transaction->target_name, + &transaction->staged_identity, &target_exists) || + !target_exists) { + return ACTIVATION_PUBLISH_CHANGED_ERROR; + } +#else + activation_unlink_status_t reservation_removed = activation_unlink_expected( + transaction, transaction->backup_path, transaction->backup_name, + &transaction->backup_identity, false); + if (reservation_removed != ACTIVATION_UNLINK_OK) { + return ACTIVATION_PUBLISH_UNCHANGED_ERROR; + } + transaction->backup_exists = false; + if (!activation_directory_still_valid(transaction) || + linkat(transaction->directory_fd, transaction->target_name, + transaction->directory_fd, transaction->backup_name, 0) != 0) { + return ACTIVATION_PUBLISH_UNCHANGED_ERROR; + } + transaction->backup_exists = true; + transaction->backup_contains_target = true; + transaction->backup_identity = transaction->target_identity; + bool staged_exists = false; + if (!activation_linked_backup_pair_valid(transaction) || + !activation_path_matches( + transaction, transaction->staged_path, + transaction->staged_name, &transaction->staged_identity, + &staged_exists) || + !staged_exists) { + return activation_remove_linked_backup(transaction) + ? ACTIVATION_PUBLISH_UNCHANGED_ERROR + : ACTIVATION_PUBLISH_CHANGED_ERROR; + } + if (!activation_sync_directory(transaction)) { + return activation_remove_linked_backup(transaction) + ? ACTIVATION_PUBLISH_UNCHANGED_ERROR + : ACTIVATION_PUBLISH_CHANGED_ERROR; + } + if (!activation_rename( + transaction, transaction->staged_path, transaction->staged_name, + transaction->target_path, transaction->target_name, true)) { + return activation_remove_linked_backup(transaction) + ? ACTIVATION_PUBLISH_UNCHANGED_ERROR + : ACTIVATION_PUBLISH_CHANGED_ERROR; + } + transaction->staged_exists = false; +#endif + return ACTIVATION_PUBLISH_OK; +} + +static bool activation_target_still_original( + const cbm_activation_transaction_t *transaction) { + activation_file_identity_t current; + bool exists = false; + if (!activation_entry_snapshot( + transaction, transaction->target_path, transaction->target_name, + &exists, ¤t) || + exists != transaction->target_existed) { + return false; + } + return !exists || activation_identity_equal(¤t, + &transaction->target_identity); +} + +static bool activation_absent_target_snapshot( + const cbm_activation_transaction_t *transaction, bool *exists_out, + bool *linked_pair_out) { + *exists_out = false; + *linked_pair_out = false; + if (activation_path_matches( + transaction, transaction->target_path, transaction->target_name, + &transaction->staged_identity, exists_out)) { + return true; + } +#ifndef _WIN32 + if (!transaction->staged_exists) { + return false; + } + activation_file_identity_t staged_identity; + activation_file_identity_t target_identity; + bool staged_exists = false; + bool target_exists = false; + if (!activation_posix_entry_snapshot_with_links( + transaction, transaction->target_name, (nlink_t)2, + &target_exists, &target_identity) || + !target_exists || + !activation_identity_equal(&target_identity, + &transaction->staged_identity) || + !activation_posix_entry_snapshot_with_links( + transaction, transaction->staged_name, (nlink_t)2, + &staged_exists, &staged_identity) || + !staged_exists || + !activation_identity_equal(&staged_identity, + &transaction->staged_identity)) { + return false; + } + *exists_out = true; + *linked_pair_out = true; + return true; +#else + return false; +#endif +} + +static bool activation_remove_absent_published_target( + cbm_activation_transaction_t *transaction, bool linked_pair) { + if (!linked_pair) { + return activation_unlink_expected( + transaction, transaction->target_path, + transaction->target_name, &transaction->staged_identity, + false) == ACTIVATION_UNLINK_OK; + } +#ifndef _WIN32 + bool target_exists = false; + bool still_linked = false; + if (!activation_absent_target_snapshot(transaction, &target_exists, + &still_linked) || + !target_exists || !still_linked) { + return false; + } + int removed; + do { + removed = unlinkat(transaction->directory_fd, + transaction->target_name, 0); + } while (removed != 0 && errno == EINTR); + if (removed != 0) { + return false; + } + activation_file_identity_t staged_identity; + bool staged_exists = false; + activation_file_identity_t absent_identity; + bool target_remains = false; + return activation_posix_entry_snapshot_with_links( + transaction, transaction->staged_name, (nlink_t)1, + &staged_exists, &staged_identity) && + staged_exists && + activation_identity_equal(&staged_identity, + &transaction->staged_identity) && + activation_posix_entry_snapshot_with_links( + transaction, transaction->target_name, (nlink_t)1, + &target_remains, &absent_identity) && + !target_remains; +#else + return false; +#endif +} + +static cbm_activation_transaction_status_t activation_rollback_internal( + cbm_activation_transaction_t *transaction) { + if (transaction->target_existed) { + bool backup_exists = false; + if (!transaction->backup_contains_target || + !activation_path_matches( + transaction, transaction->backup_path, + transaction->backup_name, &transaction->backup_identity, + &backup_exists) || + !backup_exists) { + transaction->state = ACTIVATION_RECOVERY_NEEDED; + return CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; + } + bool target_exists = false; + activation_file_identity_t current_target; + if (!activation_entry_snapshot( + transaction, transaction->target_path, + transaction->target_name, &target_exists, ¤t_target)) { + transaction->state = ACTIVATION_RECOVERY_NEEDED; + return CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; + } + if (target_exists && + (transaction->action != ACTIVATION_REPLACE || + !activation_identity_equal(¤t_target, + &transaction->staged_identity))) { + transaction->state = ACTIVATION_RECOVERY_NEEDED; + return CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; + } + if (!activation_rename( + transaction, transaction->backup_path, + transaction->backup_name, transaction->target_path, + transaction->target_name, target_exists)) { + transaction->state = ACTIVATION_RECOVERY_NEEDED; + return CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; + } + transaction->backup_exists = false; + transaction->backup_contains_target = false; + } else if (transaction->action == ACTIVATION_REPLACE) { + bool target_exists = false; + bool linked_pair = false; + if (!activation_absent_target_snapshot(transaction, &target_exists, + &linked_pair)) { + transaction->state = ACTIVATION_RECOVERY_NEEDED; + return CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; + } + if (target_exists && !activation_remove_absent_published_target( + transaction, linked_pair)) { + transaction->state = ACTIVATION_RECOVERY_NEEDED; + return CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; + } + } + transaction->state = ACTIVATION_ROLLED_BACK; + return activation_sync_directory(transaction) + ? CBM_ACTIVATION_TRANSACTION_OK + : CBM_ACTIVATION_TRANSACTION_IO; +} + +cbm_activation_transaction_status_t cbm_activation_transaction_commit( + cbm_activation_transaction_t *transaction, + cbm_activation_transaction_validator_fn validator, + void *validator_context) { + if (!transaction) { + return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT; + } + if (transaction->state != ACTIVATION_STAGED) { + return CBM_ACTIVATION_TRANSACTION_INVALID_STATE; + } + if (!activation_target_still_original(transaction)) { + return CBM_ACTIVATION_TRANSACTION_IO; + } + if (transaction->action == ACTIVATION_REPLACE) { + bool staged_exists = false; + if (!transaction->staged_exists || + !activation_path_matches( + transaction, transaction->staged_path, + transaction->staged_name, &transaction->staged_identity, + &staged_exists) || + !staged_exists) { + return CBM_ACTIVATION_TRANSACTION_IO; + } + } + if (transaction->target_existed) { + if (transaction->action == ACTIVATION_REPLACE) { + activation_publish_status_t published = + activation_publish_existing_replacement(transaction); + if (published == ACTIVATION_PUBLISH_UNCHANGED_ERROR) { + return CBM_ACTIVATION_TRANSACTION_IO; + } + if (published == ACTIVATION_PUBLISH_CHANGED_ERROR) { + transaction->state = ACTIVATION_RECOVERY_NEEDED; + cbm_activation_transaction_status_t restored = + activation_rollback_internal(transaction); + return restored == CBM_ACTIVATION_TRANSACTION_OK + ? CBM_ACTIVATION_TRANSACTION_IO + : CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; + } + } else { + bool reservation_exists = false; + if (!transaction->backup_exists || + !activation_path_matches( + transaction, transaction->backup_path, + transaction->backup_name, &transaction->backup_identity, + &reservation_exists) || + !reservation_exists || + !activation_rename( + transaction, transaction->target_path, + transaction->target_name, transaction->backup_path, + transaction->backup_name, true)) { + return CBM_ACTIVATION_TRANSACTION_IO; + } + transaction->backup_identity = transaction->target_identity; + transaction->backup_contains_target = true; + } + } + if (transaction->action == ACTIVATION_REPLACE && + !transaction->target_existed) { + if (activation_before_absent_publish_for_test) { + activation_before_absent_publish_for_test( + transaction->target_path, + activation_before_absent_publish_context_for_test); + } + activation_publish_status_t published = + activation_publish_absent_replacement(transaction); + if (published == ACTIVATION_PUBLISH_UNCHANGED_ERROR) { + return CBM_ACTIVATION_TRANSACTION_IO; + } + if (published == ACTIVATION_PUBLISH_CHANGED_ERROR) { + transaction->state = ACTIVATION_RECOVERY_NEEDED; + cbm_activation_transaction_status_t restored = + activation_rollback_internal(transaction); + return restored == CBM_ACTIVATION_TRANSACTION_OK + ? CBM_ACTIVATION_TRANSACTION_IO + : CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; + } + } + transaction->state = ACTIVATION_COMMITTED; + if (!activation_directory_still_valid(transaction) || + !activation_sync_directory(transaction)) { + cbm_activation_transaction_status_t restored = + activation_rollback_internal(transaction); + return restored == CBM_ACTIVATION_TRANSACTION_OK + ? CBM_ACTIVATION_TRANSACTION_IO + : CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; + } + if (validator && !validator(transaction->target_path, validator_context)) { + cbm_activation_transaction_status_t restored = + activation_rollback_internal(transaction); + return restored == CBM_ACTIVATION_TRANSACTION_OK + ? CBM_ACTIVATION_TRANSACTION_VALIDATION_FAILED + : CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; + } + return CBM_ACTIVATION_TRANSACTION_OK; +} + +cbm_activation_transaction_status_t cbm_activation_transaction_rollback( + cbm_activation_transaction_t *transaction) { + if (!transaction) { + return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT; + } + if (transaction->state != ACTIVATION_COMMITTED && + transaction->state != ACTIVATION_RECOVERY_NEEDED) { + return CBM_ACTIVATION_TRANSACTION_INVALID_STATE; + } + return activation_rollback_internal(transaction); +} + +cbm_activation_transaction_status_t cbm_activation_transaction_finalize( + cbm_activation_transaction_t *transaction) { + if (!transaction) { + return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT; + } + if (transaction->state != ACTIVATION_COMMITTED) { + return CBM_ACTIVATION_TRANSACTION_INVALID_STATE; + } + if (!activation_directory_still_valid(transaction)) { + return CBM_ACTIVATION_TRANSACTION_IO; + } + if (transaction->backup_contains_target) { + activation_unlink_status_t removed = activation_unlink_expected( + transaction, transaction->backup_path, transaction->backup_name, + &transaction->backup_identity, true); + if (removed == ACTIVATION_UNLINK_ERROR) { + return CBM_ACTIVATION_TRANSACTION_IO; + } + if (removed == ACTIVATION_UNLINK_DEFERRED) { + transaction->deferred_cleanup = true; + transaction->state = ACTIVATION_FINALIZED_DEFERRED; + return CBM_ACTIVATION_TRANSACTION_DEFERRED; + } + transaction->backup_exists = false; + transaction->backup_contains_target = false; + } + if (!activation_sync_directory(transaction)) { + return CBM_ACTIVATION_TRANSACTION_IO; + } + transaction->state = ACTIVATION_FINALIZED; + return CBM_ACTIVATION_TRANSACTION_OK; +} + +cbm_activation_transaction_status_t cbm_activation_transaction_close( + cbm_activation_transaction_t **transaction_io) { + if (!transaction_io || !*transaction_io) { + return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT; + } + cbm_activation_transaction_t *transaction = *transaction_io; + if (transaction->state == ACTIVATION_COMMITTED || + transaction->state == ACTIVATION_RECOVERY_NEEDED) { + cbm_activation_transaction_status_t status = + activation_rollback_internal(transaction); + if (status != CBM_ACTIVATION_TRANSACTION_OK) { + return status; + } + } + if (transaction->state == ACTIVATION_STAGED || + transaction->state == ACTIVATION_ROLLED_BACK) { + cbm_activation_transaction_status_t status = + activation_discard_staged_assets(transaction); + if (status != CBM_ACTIVATION_TRANSACTION_OK) { + return status; + } + } + activation_transaction_destroy(transaction); + *transaction_io = NULL; + return CBM_ACTIVATION_TRANSACTION_OK; +} + +const char *cbm_activation_transaction_target_path( + const cbm_activation_transaction_t *transaction) { + return transaction ? transaction->target_path : NULL; +} + +const char *cbm_activation_transaction_staged_path( + const cbm_activation_transaction_t *transaction) { + return transaction ? transaction->staged_path : NULL; +} + +const char *cbm_activation_transaction_backup_path( + const cbm_activation_transaction_t *transaction) { + return transaction ? transaction->backup_path : NULL; +} + +const char *cbm_activation_transaction_deferred_path( + const cbm_activation_transaction_t *transaction) { + return transaction && transaction->deferred_cleanup + ? transaction->backup_path + : NULL; +} + +const char *cbm_activation_transaction_status_message( + cbm_activation_transaction_status_t status) { + switch (status) { + case CBM_ACTIVATION_TRANSACTION_OK: + return "activation transaction completed"; + case CBM_ACTIVATION_TRANSACTION_DEFERRED: + return "activation completed; old executable cleanup was deferred"; + case CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT: + return "invalid activation transaction argument"; + case CBM_ACTIVATION_TRANSACTION_NO_MEMORY: + return "activation transaction allocation failed"; + case CBM_ACTIVATION_TRANSACTION_IO: + return "activation transaction I/O failed"; + case CBM_ACTIVATION_TRANSACTION_INVALID_STATE: + return "activation transaction is in the wrong state"; + case CBM_ACTIVATION_TRANSACTION_VALIDATION_FAILED: + return "activated candidate failed validation and was rolled back"; + case CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED: + return "activation failed and the retained backup could not be restored"; + default: + return "unknown activation transaction status"; + } +} diff --git a/src/cli/activation_transaction.h b/src/cli/activation_transaction.h new file mode 100644 index 000000000..9d5c264f1 --- /dev/null +++ b/src/cli/activation_transaction.h @@ -0,0 +1,95 @@ +/* + * activation_transaction.h -- Transactional binary activation primitives. + * + * This is an internal CLI module. It deliberately knows nothing about daemon + * coordination or editor configuration: callers must acquire the maintenance + * barrier before commit and retain it until finalize/rollback completes. + */ +#ifndef CBM_ACTIVATION_TRANSACTION_H +#define CBM_ACTIVATION_TRANSACTION_H + +#include +#include + +typedef struct cbm_activation_transaction cbm_activation_transaction_t; + +typedef enum { + CBM_ACTIVATION_TRANSACTION_OK = 0, + /* Windows could not unlink an inactive backup (normally because the old + * executable image is still mapped) but safely registered it for deletion + * at reboot. The committed activation remains valid. */ + CBM_ACTIVATION_TRANSACTION_DEFERRED = 1, + CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT = -1, + CBM_ACTIVATION_TRANSACTION_NO_MEMORY = -2, + CBM_ACTIVATION_TRANSACTION_IO = -3, + CBM_ACTIVATION_TRANSACTION_INVALID_STATE = -4, + /* The post-commit validator rejected the candidate and rollback succeeded. */ + CBM_ACTIVATION_TRANSACTION_VALIDATION_FAILED = -5, + /* The target changed, but restoring the retained backup also failed. */ + CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED = -6, +} cbm_activation_transaction_status_t; + +typedef bool (*cbm_activation_transaction_validator_fn)( + const char *target_path, void *context); + +/* Test-only seam: invoked after an absent target has been revalidated and + * immediately before its staged candidate is published. Production callers + * leave this unset. */ +typedef void (*cbm_activation_transaction_before_absent_publish_for_test_fn)( + const char *target_path, void *context); +void cbm_activation_transaction_set_before_absent_publish_for_test( + cbm_activation_transaction_before_absent_publish_for_test_fn hook, + void *context); + +/* Stage a candidate beside target_path (therefore on the same filesystem). + * The staged file is private to the current account and executable. */ +cbm_activation_transaction_status_t cbm_activation_transaction_stage_bytes( + const char *target_path, const void *candidate, size_t candidate_size, + cbm_activation_transaction_t **transaction_out); + +/* Copy candidate_path into a private executable stage beside target_path. */ +cbm_activation_transaction_status_t cbm_activation_transaction_stage_file( + const char *target_path, const char *candidate_path, + cbm_activation_transaction_t **transaction_out); + +/* Prepare an atomic removal. A missing target is a valid no-op transaction. */ +cbm_activation_transaction_status_t cbm_activation_transaction_stage_removal( + const char *target_path, + cbm_activation_transaction_t **transaction_out); + +/* Atomically publish the candidate (or remove the target), retaining any old + * target at backup_path. If validator rejects the post-commit state, this + * function rolls back before returning VALIDATION_FAILED. */ +cbm_activation_transaction_status_t cbm_activation_transaction_commit( + cbm_activation_transaction_t *transaction, + cbm_activation_transaction_validator_fn validator, void *validator_context); + +/* Restore the retained target after a successful commit. */ +cbm_activation_transaction_status_t cbm_activation_transaction_rollback( + cbm_activation_transaction_t *transaction); + +/* Accept the committed state and delete the retained backup. On Windows, + * DEFERRED means deletion was safely registered for reboot; deferred_path + * remains available for logging until close(). */ +cbm_activation_transaction_status_t cbm_activation_transaction_finalize( + cbm_activation_transaction_t *transaction); + +/* Close an object. An uncommitted object is cleanly aborted; a committed but + * unfinalized object is rolled back. On cleanup failure, ownership stays with + * the caller so paths and rollback can be retried. */ +cbm_activation_transaction_status_t cbm_activation_transaction_close( + cbm_activation_transaction_t **transaction_io); + +const char *cbm_activation_transaction_target_path( + const cbm_activation_transaction_t *transaction); +const char *cbm_activation_transaction_staged_path( + const cbm_activation_transaction_t *transaction); +const char *cbm_activation_transaction_backup_path( + const cbm_activation_transaction_t *transaction); +const char *cbm_activation_transaction_deferred_path( + const cbm_activation_transaction_t *transaction); + +const char *cbm_activation_transaction_status_message( + cbm_activation_transaction_status_t status); + +#endif /* CBM_ACTIVATION_TRANSACTION_H */ diff --git a/src/cli/cli.c b/src/cli/cli.c index aec3cf102..4cb673fc4 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7,13 +7,19 @@ #include "cli/agent_clients.h" #include "cli/agent_profiles.h" #include "cli/cli.h" +#include "cli/activation_transaction.h" #include "cli/config_json_like.h" #include "cli/config_text_edit.h" #include "cli/config_toml_edit.h" #include "cli/config_yaml_edit.h" +#include "daemon/bootstrap.h" +#include "daemon/ipc.h" +#include "daemon/runtime.h" +#include "daemon/version_cohort.h" #include "foundation/compat.h" #include "foundation/platform.h" #include "foundation/constants.h" +#include "foundation/log.h" #include "foundation/sha256.h" #include "mcp/mcp.h" // cbm_mcp_tool_input_schema — CLI flag parser + per-tool --help @@ -35,6 +41,7 @@ enum { CLI_ERR = -1, CLI_OK = 0, CLI_TRUE = 1, + CLI_ACTIVATION_PARTIAL = 2, CLI_ELEM_SIZE = 1, /* fread/fwrite element size */ CLI_IDX_1 = 1, /* array index 1 */ CLI_IDX_2 = 2, /* array index 2 */ @@ -66,6 +73,10 @@ enum { VARIANT_A = 1, VARIANT_B = 2, OCTAL_BASE = 8, + CLI_ACTIVATION_DRAIN_TIMEOUT_MS = 15000, + CLI_ACTIVATION_CONTROL_TIMEOUT_MS = 2000, + CLI_ACTIVATION_RETRY_US = 10000, + CLI_ACTIVATION_LOG_CAP_BYTES = CLI_BUF_1K * CLI_BUF_1K, }; /* String length helper for strncmp. */ @@ -77,7 +88,6 @@ static int cbm_powershell_quote_word(const char *value, char *out, size_t out_si // the correct standard headers are included below but clang-tidy doesn't map them. #include #ifndef _WIN32 -#include #include #endif #ifdef __APPLE__ @@ -96,7 +106,12 @@ static int cbm_powershell_quote_word(const char *value, char *out, size_t out_si #include #include // strtok_r #include // mode_t, S_IXUSR +#include +#include #include // MAX_WBITS +#ifdef _WIN32 +#include +#endif /* yyjson for JSON read-modify-write */ #include "yyjson/yyjson.h" @@ -120,6 +135,593 @@ static void (*cbm_sqlite_transient_fn(void))(void *) { /* Decompression buffer cap (500 MB) */ #define DECOMPRESS_MAX_BYTES ((size_t)500 * CLI_BUF_1K * CBM_SZ_1K) +bool cbm_cli_mcp_result_is_error(const char *result) { + if (!result) { + return false; + } + yyjson_doc *document = yyjson_read(result, strlen(result), 0); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + yyjson_val *error = yyjson_is_obj(root) + ? yyjson_obj_get(root, "isError") + : NULL; + bool is_error = yyjson_is_bool(error) && yyjson_get_bool(error); + yyjson_doc_free(document); + return is_error; +} + +static const char CLI_ACTIVATION_REFUSED_MESSAGE[] = + "error: active CBM sessions and operations could not be stopped safely; " + "no activation was committed."; +static const char CLI_ACTIVATION_PARTIAL_MESSAGE[] = + "error: activation stopped after one or more agent configuration or " + "cleanup operations failed; the published/current executable was kept, " + "and configuration changes that completed may remain. Please restart " + "your coding-agent sessions after resolving the errors above."; +static const char CLI_ACTIVATION_MUTATION_FAILED_MESSAGE[] = + "error: activation failed while CBM sessions were stopped; filesystem " + "changes that completed before the failure may remain. Review the errors " + "above and restart your coding-agent sessions before retrying."; + +typedef struct { + cbm_daemon_ipc_endpoint_t *endpoint; + cbm_version_cohort_manager_t *cohort_manager; + cbm_version_cohort_lease_t *cohort_lease; + cbm_daemon_ipc_startup_lock_t *startup_lock; + cbm_daemon_build_identity_t identity; + char source_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + cbm_daemon_runtime_activation_action_t action; + cbm_daemon_runtime_activation_result_t daemon_result; + uint64_t deadline_ms; + uint64_t control_deadline_ms; + const char *target_version; + const char *target_build; + FILE *activation_log; + bool shutdown_requested; + bool mutation_authorized; + bool cleanup_ok; +} cli_activation_production_context_t; + +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 void cli_activation_diagnostic(const cbm_cli_activation_ops_t *ops, + const char *message) { + const char *diagnostic = message ? message : CLI_ACTIVATION_REFUSED_MESSAGE; + if (ops && ops->visible_diagnostic) { + ops->visible_diagnostic(ops->context, diagnostic); + return; + } + (void)fprintf(stderr, "%s\n", diagnostic); +} + +int cbm_cli_activation_guard_with_ops(const cbm_cli_activation_ops_t *ops, + cbm_cli_activation_mutation_fn mutation, + void *mutation_context) { + if (!ops || !ops->reserve_for_mutation || + !ops->mutation_lease_release) { + cli_activation_diagnostic(ops, CLI_ACTIVATION_REFUSED_MESSAGE); + return CLI_TRUE; + } + + cbm_cli_activation_lock_t mutation_lease = NULL; + int reserve_status = + ops->reserve_for_mutation(ops->context, &mutation_lease); + if (reserve_status != 1 || !mutation_lease) { + /* A failed reservation must not normally return authority, but the + * boundary is injectable and production can surface cleanup-only + * state after an I/O failure. Never strand such a lease. */ + if (mutation_lease) { + ops->mutation_lease_release(ops->context, mutation_lease); + } + cli_activation_diagnostic(ops, CLI_ACTIVATION_REFUSED_MESSAGE); + return CLI_TRUE; + } + + int rc = mutation ? mutation(mutation_context) : CLI_OK; + ops->mutation_lease_release(ops->context, mutation_lease); + if (rc != CLI_OK) { + cli_activation_diagnostic( + ops, rc == CLI_ACTIVATION_PARTIAL + ? CLI_ACTIVATION_PARTIAL_MESSAGE + : CLI_ACTIVATION_MUTATION_FAILED_MESSAGE); + } + return rc; +} + +void cbm_cli_set_activation_ops_for_test(const cbm_cli_activation_ops_t *ops) { + if (!ops) { + memset(&g_cli_activation_test_ops, 0, sizeof(g_cli_activation_test_ops)); + g_cli_activation_test_ops_set = false; + return; + } + g_cli_activation_test_ops = *ops; + g_cli_activation_test_ops_set = true; +} + +void cbm_cli_set_activation_runtime_parent_for_test( + const char *runtime_parent) { + g_cli_activation_runtime_parent_for_test = runtime_parent; +} + +static const char *cli_activation_action_text( + cbm_daemon_runtime_activation_action_t action) { + switch (action) { + case CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL: + return "install"; + case CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE: + return "update"; + case CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL: + return "uninstall"; + default: + return "activation"; + } +} + +static uint64_t cli_activation_deadline_after(uint32_t timeout_ms) { + uint64_t now = cbm_now_ms(); + if (now >= UINT64_MAX - (uint64_t)timeout_ms - 1U) { + return UINT64_MAX - 1U; + } + return now + (uint64_t)timeout_ms; +} + +static uint64_t cli_activation_process_id(void) { +#ifdef _WIN32 + return (uint64_t)GetCurrentProcessId(); +#else + return (uint64_t)getpid(); +#endif +} + +static bool cli_activation_log_event( + cli_activation_production_context_t *context, const char *phase, + const char *detail) { + if (!context || !context->activation_log || !phase) { + return false; + } + yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; + if (!document || !root) { + yyjson_mut_doc_free(document); + return false; + } + yyjson_mut_doc_set_root(document, root); + bool encoded = + yyjson_mut_obj_add_str(document, root, "event", "cbm.activation") && + yyjson_mut_obj_add_strcpy(document, root, "phase", phase) && + yyjson_mut_obj_add_strcpy( + document, root, "action", + cli_activation_action_text(context->action)) && + yyjson_mut_obj_add_int(document, root, "requester_pid", + (int64_t)cli_activation_process_id()) && + yyjson_mut_obj_add_int(document, root, "timestamp_unix_s", + (int64_t)time(NULL)) && + yyjson_mut_obj_add_str(document, root, "source_version", + CBM_VERSION) && + yyjson_mut_obj_add_strcpy( + document, root, "source_build", + context->identity.build_fingerprint + ? context->identity.build_fingerprint + : "") && + yyjson_mut_obj_add_int( + document, root, "daemon_active_clients", + (int64_t)context->daemon_result.active_clients) && + yyjson_mut_obj_add_int( + document, root, "daemon_active_connections", + (int64_t)context->daemon_result.active_connections) && + yyjson_mut_obj_add_bool(document, root, "restart_required", true); + if (encoded && context->target_version && context->target_version[0]) { + encoded = yyjson_mut_obj_add_strcpy( + document, root, "target_version", context->target_version); + } + if (encoded && context->target_build && context->target_build[0]) { + encoded = yyjson_mut_obj_add_strcpy( + document, root, "target_build", context->target_build); + } + if (encoded && detail && detail[0]) { + encoded = yyjson_mut_obj_add_strcpy(document, root, "detail", detail); + } + size_t json_size = 0; + char *json = encoded ? yyjson_mut_write(document, 0, &json_size) : NULL; + bool written = json && json_size > 0 && + fwrite(json, 1, json_size, context->activation_log) == + json_size && + fputc('\n', context->activation_log) != EOF && + fflush(context->activation_log) == 0; + if (written) { + int descriptor = cbm_fileno(context->activation_log); +#ifdef _WIN32 + written = descriptor >= 0 && _commit(descriptor) == 0; +#else + written = descriptor >= 0 && fsync(descriptor) == 0; +#endif + } + free(json); + yyjson_mut_doc_free(document); + return written; +} + +static int cli_activation_startup_lock_acquire( + cli_activation_production_context_t *context) { + if (!context || !context->endpoint) { + return CLI_ERR; + } + if (context->startup_lock) { + return 1; + } + do { + cbm_daemon_ipc_startup_lock_t *lock = NULL; + int status = cbm_daemon_ipc_startup_lock_try_acquire( + context->endpoint, &lock); + if (status == 1 && lock) { + context->startup_lock = lock; + return 1; + } + if (status < 0) { + return CLI_ERR; + } + cbm_usleep(CLI_ACTIVATION_RETRY_US); + } while (cbm_now_ms() < context->control_deadline_ms); + return 0; +} + +static _Noreturn void cli_activation_cleanup_fail_stop( + cli_activation_production_context_t *context, const char *component) { + if (context) { + (void)cli_activation_log_event( + context, "failed", + "coordination cleanup timed out; process exit releases retained claims"); + } + cbm_log_error("coordination.cleanup_timeout", "component", component, + "action", "process_exit"); + (void)fflush(stdout); + (void)fflush(stderr); + _Exit(EXIT_FAILURE); +} + +static void cli_activation_startup_lock_release_complete( + cli_activation_production_context_t *context) { + uint64_t deadline = + cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + while (context && context->startup_lock) { + (void)cbm_daemon_ipc_startup_lock_release( + &context->startup_lock); + if (!context->startup_lock) { + return; + } + if (cbm_now_ms() >= deadline) { + cli_activation_cleanup_fail_stop(context, + "startup_lock_cleanup"); + } + cbm_usleep(CLI_ACTIVATION_RETRY_US); + } +} + +static uint32_t cli_activation_remaining_timeout( + const cli_activation_production_context_t *context) { + uint64_t now = cbm_now_ms(); + if (!context || now >= context->control_deadline_ms) { + return 1U; + } + uint64_t remaining = context->control_deadline_ms - now; + return remaining >= UINT32_MAX ? UINT32_MAX - 1U + : (uint32_t)remaining; +} + +static uint64_t cli_activation_count_add(uint64_t left, uint64_t right) { + return left > UINT64_MAX - right ? UINT64_MAX : left + right; +} + +static void cli_activation_merge_daemon_result( + cli_activation_production_context_t *context, + const cbm_daemon_runtime_activation_result_t *result) { + if (!context || !result || !result->accepted) { + return; + } + context->daemon_result.accepted = true; + context->daemon_result.active_clients = cli_activation_count_add( + context->daemon_result.active_clients, result->active_clients); + context->daemon_result.active_connections = cli_activation_count_add( + context->daemon_result.active_connections, + result->active_connections); + context->shutdown_requested = true; +} + +static cbm_version_cohort_quiesce_result_t +cli_activation_request_quiescence(void *opaque) { + cli_activation_production_context_t *context = opaque; + if (!context || !context->endpoint) { + return CBM_VERSION_COHORT_QUIESCE_ERROR; + } + + /* Never acquire startup while maintenance+admission are held EX. A + * bootstrap participant can already hold both its lifetime SH and startup + * while it discovers maintenance; waiting here would invert its teardown + * order and time out both sides. OP8 is safe to attempt directly. An + * absent endpoint or lost ACK is not mutation authority: the finite + * lifetime-EX wait below remains the authoritative drain proof. */ + uint64_t control = + cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + context->control_deadline_ms = + control < context->deadline_ms ? control : context->deadline_ms; + cbm_daemon_runtime_activation_result_t result = {0}; + if (cbm_daemon_runtime_request_activation_shutdown( + context->endpoint, &context->identity, context->action, + cli_activation_remaining_timeout(context), &result)) { + cli_activation_merge_daemon_result(context, &result); + } + context->shutdown_requested = true; + return CBM_VERSION_COHORT_QUIESCE_REQUESTED; +} + +static void cli_activation_release_cleanup_lease( + cli_activation_production_context_t *context, + cbm_version_cohort_lease_t **lease_io) { + uint64_t cleanup_deadline = + cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + while (lease_io && *lease_io && cbm_now_ms() < cleanup_deadline) { + if (cbm_version_cohort_lease_release(lease_io) == + CBM_PRIVATE_FILE_LOCK_OK) { + return; + } + cbm_usleep(CLI_ACTIVATION_RETRY_US); + } + if (lease_io && *lease_io) { + context->cleanup_ok = false; + } +} + +static int cli_activation_production_reserve( + void *opaque, cbm_cli_activation_lock_t *lease_out) { + cli_activation_production_context_t *context = opaque; + if (lease_out) { + *lease_out = NULL; + } + if (!context || !context->cohort_manager || !lease_out) { + return CLI_ERR; + } + cbm_version_cohort_quiesce_result_t quiesce = + CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + cbm_version_cohort_lease_t *lease = NULL; + context->control_deadline_ms = + cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + + /* Ask the current daemon to snapshot and stop its sessions before the + * maintenance marker wakes thin frontends. Otherwise cooperative clients + * can disconnect so quickly that the durable activation audit records + * zero even though they were drained. This eager request grants no + * mutation authority: the exclusive maintenance/admission/lifetime lease + * below remains mandatory and its callback repeats OP8 to catch any daemon + * that races into the small preflight-to-lock window. */ + cbm_daemon_runtime_activation_result_t eager_result = {0}; + if (cbm_daemon_runtime_request_activation_shutdown( + context->endpoint, &context->identity, context->action, + cli_activation_remaining_timeout(context), &eager_result)) { + cli_activation_merge_daemon_result(context, &eager_result); + } + + cbm_version_cohort_status_t status = + cbm_version_cohort_reserve_for_mutation( + context->cohort_manager, context->deadline_ms, + cli_activation_request_quiescence, context, &quiesce, &lease); + if (status != CBM_VERSION_COHORT_OK || !lease) { + cli_activation_release_cleanup_lease(context, &lease); + context->cohort_lease = lease; + return status == CBM_VERSION_COHORT_BUSY ? 0 : CLI_ERR; + } + + /* Quiescence never touches startup. Only after + * maintenance+admission+lifetime are all held EX may activation acquire + * startup in the final global order and retain it through mutation. */ + context->control_deadline_ms = + cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + if (!context->startup_lock && + cli_activation_startup_lock_acquire(context) != 1) { + cli_activation_release_cleanup_lease(context, &lease); + context->cohort_lease = lease; + return CLI_ERR; + } + int generation = cbm_daemon_ipc_generation_probe_under_startup_lock( + context->endpoint, context->startup_lock); + if (generation != 0) { + cli_activation_startup_lock_release_complete(context); + cli_activation_release_cleanup_lease(context, &lease); + context->cohort_lease = lease; + return generation == 1 ? 0 : CLI_ERR; + } + + context->cohort_lease = lease; + if (!cli_activation_log_event( + context, "daemon_stopped", + context->shutdown_requested ? "cohort drained" + : "no active cohort")) { + cli_activation_startup_lock_release_complete(context); + cli_activation_release_cleanup_lease(context, &lease); + context->cohort_lease = lease; + return CLI_ERR; + } + context->mutation_authorized = true; + *lease_out = lease; + return 1; +} + +static void cli_activation_production_release( + void *opaque, cbm_cli_activation_lock_t lease) { + cli_activation_production_context_t *context = opaque; + if (!context) { + return; + } + /* Global release order is the inverse of acquisition: startup first, + * then lifetime/admission/maintenance through the cohort lease. */ + if (context->startup_lock) { + cli_activation_startup_lock_release_complete(context); + } + cbm_version_cohort_lease_t *cohort_lease = + (cbm_version_cohort_lease_t *)lease; + if (cohort_lease != context->cohort_lease) { + context->cleanup_ok = false; + return; + } + cli_activation_release_cleanup_lease(context, &cohort_lease); + context->cohort_lease = cohort_lease; +} + +static void cli_activation_production_diagnostic(void *opaque, + const char *message) { + cli_activation_production_context_t *context = opaque; + if (context && context->mutation_authorized) { + (void)fprintf(stderr, "%s\n", + message ? message + : CLI_ACTIVATION_MUTATION_FAILED_MESSAGE); + return; + } + (void)fprintf(stderr, "%s\n", + message ? message : CLI_ACTIVATION_REFUSED_MESSAGE); +} + +static bool cli_activation_production_context_init( + cli_activation_production_context_t *context, + cbm_daemon_runtime_activation_action_t action, + const char *target_version, const char *target_build) { + memset(context, 0, sizeof(*context)); + context->action = action; + context->target_version = target_version; + context->target_build = target_build; + context->cleanup_ok = true; + context->deadline_ms = + cli_activation_deadline_after(CLI_ACTIVATION_DRAIN_TIMEOUT_MS); + context->control_deadline_ms = + cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + context->endpoint = cbm_daemon_bootstrap_endpoint_new( + g_cli_activation_runtime_parent_for_test); + context->cohort_manager = context->endpoint + ? cbm_version_cohort_manager_new( + context->endpoint) + : NULL; + if (!context->endpoint || !context->cohort_manager || + !cbm_daemon_runtime_process_build_fingerprint( + cli_activation_process_id(), context->source_build)) { + return false; + } + context->identity = (cbm_daemon_build_identity_t){ + .semantic_version = CBM_VERSION, + .build_fingerprint = context->source_build, + .protocol_abi = CBM_DAEMON_RUNTIME_WIRE_ABI, + .store_abi = 1, + .feature_abi = 1, + }; + const char *cache_dir = cbm_resolve_cache_dir(); + char log_dir[CLI_BUF_1K]; + int written = cache_dir + ? snprintf(log_dir, sizeof(log_dir), "%s/logs", cache_dir) + : CLI_ERR; + if (written <= 0 || (size_t)written >= sizeof(log_dir)) { + return false; + } + context->activation_log = cbm_daemon_ipc_private_log_open( + log_dir, "activation-events.ndjson", + CLI_ACTIVATION_LOG_CAP_BYTES); + return context->activation_log != NULL; +} + +static void cli_activation_production_context_close( + cli_activation_production_context_t *context) { + if (!context) { + return; + } + if (context->startup_lock) { + cli_activation_startup_lock_release_complete(context); + } + cli_activation_release_cleanup_lease(context, &context->cohort_lease); + uint64_t manager_deadline = + cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + while (context->cohort_manager && cbm_now_ms() < manager_deadline) { + if (cbm_version_cohort_manager_free(&context->cohort_manager) == + CBM_PRIVATE_FILE_LOCK_OK) { + break; + } + cbm_usleep(CLI_ACTIVATION_RETRY_US); + } + if (context->cohort_manager) { + context->cleanup_ok = false; + } + if (context->activation_log) { + if (fclose(context->activation_log) != 0) { + context->cleanup_ok = false; + } + context->activation_log = NULL; + } + cbm_daemon_ipc_endpoint_free(context->endpoint); + context->endpoint = NULL; +} + +static int cli_activation_guard( + cbm_daemon_runtime_activation_action_t action, + const char *target_version, const char *target_build, + cbm_cli_activation_mutation_fn mutation, void *mutation_context) { + if (g_cli_activation_test_ops_set) { + return cbm_cli_activation_guard_with_ops( + &g_cli_activation_test_ops, mutation, mutation_context); + } + + cli_activation_production_context_t context; + if (!cli_activation_production_context_init( + &context, action, target_version, target_build)) { + cli_activation_production_context_close(&context); + cli_activation_production_diagnostic(NULL, + CLI_ACTIVATION_REFUSED_MESSAGE); + return CLI_TRUE; + } + printf("Stopping active CBM sessions and operations for %s...\n", + cli_activation_action_text(action)); + (void)fflush(stdout); + if (!cli_activation_log_event(&context, "requested", NULL)) { + cli_activation_production_context_close(&context); + (void)fprintf(stderr, + "error: activation request could not be recorded safely; " + "no activation was committed.\n"); + return CLI_TRUE; + } + + cbm_cli_activation_ops_t ops = { + .context = &context, + .reserve_for_mutation = cli_activation_production_reserve, + .mutation_lease_release = cli_activation_production_release, + .visible_diagnostic = cli_activation_production_diagnostic, + }; + int rc = cbm_cli_activation_guard_with_ops( + &ops, mutation, mutation_context); + if (rc == CLI_OK) { + if (!cli_activation_log_event( + &context, "completed", + "activation mutation completed; configuration APIs do not " + "provide an aggregate rollback status")) { + (void)fprintf(stderr, + "warning: activation completed, but its final log " + "record could not be written.\n"); + } + } else { + (void)cli_activation_log_event( + &context, "failed", + context.mutation_authorized + ? (rc == CLI_ACTIVATION_PARTIAL + ? "published/current binary retained; agent " + "configuration refresh or cleanup incomplete" + : "activation mutation failed; filesystem writes may " + "have completed") + : "cohort drain or coordination failed"); + } + cli_activation_production_context_close(&context); + if (!context.cleanup_ok) { + (void)fprintf(stderr, + "error: activation coordination cleanup failed; restart " + "your coding-agent sessions before retrying.\n"); + return CLI_TRUE; + } + return rc; +} + /* Tar header field offsets */ #define TAR_NAME_LEN 101 /* filename field: bytes 0-99 + NUL */ #define TAR_SIZE_OFFSET 124 /* octal size field offset */ @@ -413,75 +1015,212 @@ static bool cbm_same_file(const char *a, const char *b) { #endif } -/* Copy the running binary into the canonical install target, preserving the - * executable bit. When src and dst are the same on-disk file the copy is - * skipped: cbm_copy_file opens dst "wb" before reading src, so copying a file - * onto itself would truncate it to zero. Returns 0 on success or skip, - * CLI_ERR on failure. Exposed (non-static) as the regression surface for the - * `install --force` binary-swap bug (#472). */ -int cbm_copy_binary_to_target(const char *src, const char *dst) { - if (cbm_same_file(src, dst)) { - return 0; /* already in place — nothing to copy */ +typedef struct { + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; +} cli_binary_validator_t; + +static bool cli_binary_fingerprint_validator(const char *target_path, + void *opaque) { + cli_binary_validator_t *validator = opaque; + char actual[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + return validator && target_path && + cbm_daemon_build_fingerprint_file(target_path, actual) && + strcmp(actual, validator->fingerprint) == 0; +} + +static int cli_activation_transaction_abort( + cbm_activation_transaction_t **transaction_io) { + if (!transaction_io || !*transaction_io) { + return CLI_OK; + } + return cbm_activation_transaction_close(transaction_io) == + CBM_ACTIVATION_TRANSACTION_OK + ? CLI_OK + : CLI_ERR; +} + +#ifdef CBM_CLI_ENABLE_TEST_API +static bool g_cli_force_activation_cleanup_failure_for_test = false; +void cbm_cli_set_activation_cleanup_failure_for_test(bool enabled); +int cbm_cli_activation_abort_cleanup_probe_for_test(void); +#endif + +static void cli_activation_transaction_abort_or_fail_stop( + cbm_activation_transaction_t **transaction_io, const char *component) { + int cleanup_status = cli_activation_transaction_abort(transaction_io); +#ifdef CBM_CLI_ENABLE_TEST_API + if (g_cli_force_activation_cleanup_failure_for_test) { + cleanup_status = CLI_ERR; + } +#endif + if (cleanup_status != CLI_OK) { + cli_activation_cleanup_fail_stop( + NULL, component ? component : "activation_transaction_cleanup"); + } +} + +#ifdef CBM_CLI_ENABLE_TEST_API +void cbm_cli_set_activation_cleanup_failure_for_test(bool enabled) { + g_cli_force_activation_cleanup_failure_for_test = enabled; +} + +int cbm_cli_activation_abort_cleanup_probe_for_test(void) { + cbm_activation_transaction_t *transaction = NULL; + cli_activation_transaction_abort_or_fail_stop( + &transaction, "activation_transaction_recovery_test"); + return CLI_OK; +} +#endif + +static int cli_activation_transaction_commit_validated( + cbm_activation_transaction_t *transaction, + const cli_binary_validator_t *validator, int mode) { + if (!transaction) { + return CLI_ERR; } - if (cbm_copy_file(src, dst) != 0) { + cbm_activation_transaction_status_t status = + cbm_activation_transaction_commit( + transaction, + validator ? cli_binary_fingerprint_validator : NULL, + (void *)validator); + if (status != CBM_ACTIVATION_TRANSACTION_OK) { return CLI_ERR; } #ifndef _WIN32 - (void)chmod(dst, CLI_OCTAL_PERM); + const char *target_path = + cbm_activation_transaction_target_path(transaction); + if (!target_path || chmod(target_path, (mode_t)mode) != 0) { + (void)cbm_activation_transaction_rollback(transaction); + return CLI_ERR; + } +#else + (void)mode; #endif - return 0; + return CLI_OK; } -/* Replace a binary file. Unlinks the old file first (handles read-only and - * running binaries on Unix where unlink succeeds on open files). On all - * platforms, the caller should tell the user to restart after update. */ -int cbm_replace_binary(const char *path, const unsigned char *data, int len, int mode) { - if (!path || !data || len <= 0) { +static int cli_activation_transaction_finalize_close( + cbm_activation_transaction_t **transaction_io) { + if (!transaction_io || !*transaction_io) { return CLI_ERR; } + cbm_activation_transaction_status_t status = + cbm_activation_transaction_finalize(*transaction_io); + if (status != CBM_ACTIVATION_TRANSACTION_OK && + status != CBM_ACTIVATION_TRANSACTION_DEFERRED) { + (void)cli_activation_transaction_abort(transaction_io); + return CLI_ERR; + } + if (status == CBM_ACTIVATION_TRANSACTION_DEFERRED) { + const char *deferred = cbm_activation_transaction_deferred_path( + *transaction_io); + cbm_log_warn("cli.activation_backup_cleanup_deferred", "path", + deferred ? deferred : "unknown"); + (void)fprintf(stderr, + "warning: old executable cleanup was deferred until " + "reboot: %s\n", + deferred ? deferred : "unknown path"); + } + return cli_activation_transaction_abort(transaction_io); +} - /* Remove existing file if it exists. On Unix, unlink works even if the - * binary is running (inode stays alive until the process exits). On Windows, - * unlink fails on running .exe — rename it aside as fallback. */ - struct stat st_check; - if (stat(path, &st_check) == 0) { - /* File exists — remove or rename it */ - if (cbm_unlink(path) != 0) { -#ifdef _WIN32 - /* Windows: can't unlink running .exe — rename aside */ - char old_path[CLI_BUF_1K]; - snprintf(old_path, sizeof(old_path), "%s.old", path); - (void)cbm_unlink(old_path); - if (rename(path, old_path) != 0) { - return CLI_ERR; - } -#else - return CLI_ERR; -#endif - } +static void cli_activation_transaction_finalize_committed_or_fail_stop( + cbm_activation_transaction_t **transaction_io, const char *component) { + if (!transaction_io || !*transaction_io) { + return; + } + cbm_activation_transaction_status_t status = + cbm_activation_transaction_finalize(*transaction_io); + if (status != CBM_ACTIVATION_TRANSACTION_OK && + status != CBM_ACTIVATION_TRANSACTION_DEFERRED) { + /* The committed executable is already the only state consistent with + * any configuration writes that preceded this call. Never roll it + * back after finalize itself reports an uncertain cleanup state. */ + cli_activation_cleanup_fail_stop( + NULL, component ? component + : "activation_transaction_finalize"); + } + if (status == CBM_ACTIVATION_TRANSACTION_DEFERRED) { + const char *deferred = cbm_activation_transaction_deferred_path( + *transaction_io); + cbm_log_warn("cli.activation_backup_cleanup_deferred", "path", + deferred ? deferred : "unknown"); + (void)fprintf(stderr, + "warning: old executable cleanup was deferred until " + "reboot: %s\n", + deferred ? deferred : "unknown path"); } + cli_activation_transaction_abort_or_fail_stop(transaction_io, component); +} -#ifndef _WIN32 - int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, (mode_t)mode); - if (fd < 0) { +static int cli_activation_transaction_commit_removal( + cbm_activation_transaction_t *transaction) { + return transaction && + cbm_activation_transaction_commit(transaction, NULL, NULL) == + CBM_ACTIVATION_TRANSACTION_OK + ? CLI_OK + : CLI_ERR; +} + +static bool cli_activation_transaction_expected_build( + cbm_activation_transaction_t *transaction, + cli_binary_validator_t *validator) { + const char *staged = + cbm_activation_transaction_staged_path(transaction); + return staged && validator && cbm_daemon_build_fingerprint_file( + staged, validator->fingerprint); +} + +/* Copy the running binary transactionally. The command-level install path + * stages before draining the cohort; this public helper preserves the old + * focused regression surface for independent callers. */ +int cbm_copy_binary_to_target(const char *src, const char *dst) { + if (!src || !dst) { return CLI_ERR; } - FILE *f = fdopen(fd, "wb"); - if (!f) { - close(fd); + if (cbm_same_file(src, dst)) { + return CLI_OK; + } + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t status = + cbm_activation_transaction_stage_file(dst, src, &transaction); + cli_binary_validator_t validator; + if (status != CBM_ACTIVATION_TRANSACTION_OK || !transaction || + !cli_activation_transaction_expected_build(transaction, &validator)) { + (void)cli_activation_transaction_abort(&transaction); return CLI_ERR; } -#else - (void)mode; - FILE *f = fopen(path, "wb"); - if (!f) { + if (cli_activation_transaction_commit_validated( + transaction, &validator, CLI_OCTAL_PERM) != CLI_OK) { + (void)cli_activation_transaction_abort(&transaction); return CLI_ERR; } -#endif + return cli_activation_transaction_finalize_close(&transaction); +} - size_t written = fwrite(data, CLI_ELEM_SIZE, (size_t)len, f); - (void)fclose(f); - return written == (size_t)len ? 0 : CLI_ERR; +/* Replace a binary transactionally, retaining the old target until the exact + * staged bytes have been published and validated. */ +int cbm_replace_binary(const char *path, const unsigned char *data, int len, + int mode) { + if (!path || !data || len <= 0) { + return CLI_ERR; + } + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t status = + cbm_activation_transaction_stage_bytes( + path, data, (size_t)len, &transaction); + cli_binary_validator_t validator; + if (status != CBM_ACTIVATION_TRANSACTION_OK || !transaction || + !cli_activation_transaction_expected_build(transaction, &validator)) { + (void)cli_activation_transaction_abort(&transaction); + return CLI_ERR; + } + if (cli_activation_transaction_commit_validated( + transaction, &validator, mode) != CLI_OK) { + (void)cli_activation_transaction_abort(&transaction); + return CLI_ERR; + } + return cli_activation_transaction_finalize_close(&transaction); } /* ── Skill file content (embedded) ────────────────────────────── */ @@ -4678,6 +5417,157 @@ int cbm_ensure_path(const char *bin_dir, const char *rc_file, bool dry_run) { return 0; } +#ifdef _WIN32 +static wchar_t *cli_windows_utf8_to_wide(const char *value) { + if (!value || !value[0]) { + return NULL; + } + int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, + NULL, 0); + if (needed <= 0) { + return NULL; + } + wchar_t *wide = malloc((size_t)needed * sizeof(*wide)); + if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, + wide, needed) <= 0) { + free(wide); + return NULL; + } + return wide; +} + +static bool cli_windows_path_segment_equal(const wchar_t *segment, + size_t segment_length, + const wchar_t *directory) { + while (segment_length > 0 && + (*segment == L' ' || *segment == L'\t')) { + segment++; + segment_length--; + } + while (segment_length > 0 && + (segment[segment_length - 1U] == L' ' || + segment[segment_length - 1U] == L'\t' || + segment[segment_length - 1U] == L'/' || + segment[segment_length - 1U] == L'\\')) { + segment_length--; + } + size_t directory_length = wcslen(directory); + while (directory_length > 0 && + (directory[directory_length - 1U] == L'/' || + directory[directory_length - 1U] == L'\\')) { + directory_length--; + } + return segment_length == directory_length && + _wcsnicmp(segment, directory, segment_length) == 0; +} + +/* Persist the current-user PATH while the activation lease is held. The + * installer script may update only its process-local PATH after this returns; + * it must not perform a second persistent mutation outside coordination. */ +static int cli_ensure_windows_user_path(const char *bin_dir, bool dry_run) { + wchar_t *wide_dir = cli_windows_utf8_to_wide(bin_dir); + HKEY environment = NULL; + if (!wide_dir || RegOpenKeyExW(HKEY_CURRENT_USER, L"Environment", 0, + KEY_QUERY_VALUE | KEY_SET_VALUE, + &environment) != ERROR_SUCCESS) { + free(wide_dir); + return CLI_ERR; + } + + DWORD type = REG_EXPAND_SZ; + DWORD bytes = 0; + LONG queried = RegQueryValueExW(environment, L"Path", NULL, &type, NULL, + &bytes); + bool missing = queried == ERROR_FILE_NOT_FOUND; + if ((!missing && queried != ERROR_SUCCESS) || + (!missing && type != REG_SZ && type != REG_EXPAND_SZ)) { + RegCloseKey(environment); + free(wide_dir); + return CLI_ERR; + } + size_t existing_capacity = missing ? 1U : + (size_t)bytes / sizeof(wchar_t) + 1U; + wchar_t *existing = calloc(existing_capacity, sizeof(*existing)); + if (!existing) { + RegCloseKey(environment); + free(wide_dir); + return CLI_ERR; + } + if (!missing) { + DWORD read_bytes = bytes; + if (RegQueryValueExW(environment, L"Path", NULL, &type, + (BYTE *)existing, &read_bytes) != ERROR_SUCCESS) { + RegCloseKey(environment); + free(existing); + free(wide_dir); + return CLI_ERR; + } + existing[existing_capacity - 1U] = L'\0'; + } + + bool present = false; + const wchar_t *cursor = existing; + while (!present && *cursor) { + const wchar_t *separator = wcschr(cursor, L';'); + size_t length = separator ? (size_t)(separator - cursor) + : wcslen(cursor); + present = cli_windows_path_segment_equal(cursor, length, wide_dir); + cursor = separator ? separator + 1 : cursor + length; + } + if (present || dry_run) { + RegCloseKey(environment); + free(existing); + free(wide_dir); + return present ? CLI_TRUE : CLI_OK; + } + + size_t existing_length = wcslen(existing); + size_t directory_length = wcslen(wide_dir); + bool separator_needed = existing_length > 0 && + existing[existing_length - 1U] != L';'; + if (existing_length > SIZE_MAX - directory_length - 2U) { + RegCloseKey(environment); + free(existing); + free(wide_dir); + return CLI_ERR; + } + size_t combined_length = existing_length + directory_length + + (separator_needed ? 1U : 0U); + if (combined_length + 1U > UINT32_MAX / sizeof(wchar_t)) { + RegCloseKey(environment); + free(existing); + free(wide_dir); + return CLI_ERR; + } + wchar_t *combined = calloc(combined_length + 1U, sizeof(*combined)); + if (!combined) { + RegCloseKey(environment); + free(existing); + free(wide_dir); + return CLI_ERR; + } + memcpy(combined, existing, existing_length * sizeof(*combined)); + size_t offset = existing_length; + if (separator_needed) { + combined[offset++] = L';'; + } + memcpy(combined + offset, wide_dir, + (directory_length + 1U) * sizeof(*combined)); + DWORD output_bytes = (DWORD)((combined_length + 1U) * sizeof(*combined)); + LONG stored = RegSetValueExW(environment, L"Path", 0, + missing ? REG_EXPAND_SZ : type, + (const BYTE *)combined, output_bytes); + RegCloseKey(environment); + free(combined); + free(existing); + free(wide_dir); + if (stored != ERROR_SUCCESS) { + return CLI_ERR; + } + return CLI_OK; +} +#endif + /* ── Tar.gz extraction ────────────────────────────────────────── */ /* Decompress gzip data into a malloc'd buffer. Returns NULL on failure. @@ -4951,15 +5841,13 @@ unsigned char *cbm_extract_binary_from_zip(const unsigned char *data, int data_l /* ── Index management ─────────────────────────────────────────── */ static const char *get_cache_dir(const char *home_dir) { - static char buf[CLI_BUF_1K]; if (!home_dir) { home_dir = cbm_get_home_dir(); } if (!home_dir) { return NULL; } - snprintf(buf, sizeof(buf), "%s", cbm_resolve_cache_dir()); - return buf; + return cbm_resolve_cache_dir(); } int cbm_list_indexes(const char *home_dir) { @@ -5023,7 +5911,6 @@ int cbm_remove_indexes(const char *home_dir) { struct cbm_config { sqlite3 *db; - char get_buf[CLI_BUF_4K]; /* static buffer for cbm_config_get return values */ }; cbm_config_t *cbm_config_open(const char *cache_dir) { @@ -5074,6 +5961,7 @@ void cbm_config_close(cbm_config_t *cfg) { } const char *cbm_config_get(cbm_config_t *cfg, const char *key, const char *default_val) { + static CBM_TLS char result_buf[CLI_BUF_4K]; if (!cfg || !key) { return default_val; } @@ -5089,8 +5977,8 @@ const char *cbm_config_get(cbm_config_t *cfg, const char *key, const char *defau if (sqlite3_step(stmt) == SQLITE_ROW) { const char *val = (const char *)sqlite3_column_text(stmt, 0); if (val) { - snprintf(cfg->get_buf, sizeof(cfg->get_buf), "%s", val); - result = cfg->get_buf; + snprintf(result_buf, sizeof(result_buf), "%s", val); + result = result_buf; } } sqlite3_finalize(stmt); @@ -5162,7 +6050,9 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key) { /* ── Config CLI subcommand ────────────────────────────────────── */ int cbm_cmd_config(int argc, char **argv) { - if (argc == 0) { + if (argc == 0 || + (argv && (strcmp(argv[0], "--help") == 0 || + strcmp(argv[0], "-h") == 0))) { printf("Usage: codebase-memory-mcp config [args]\n\n"); printf("Commands:\n"); printf(" list Show all config values\n"); @@ -5302,8 +6192,7 @@ static bool prompt_yn(const char *question) { /* SHA-256 hex digest: 64 hex chars + NUL */ #define SHA256_HEX_LEN CBM_SZ_64 #define SHA256_BUF_SIZE (SHA256_HEX_LEN + CLI_SKIP_ONE) -/* Minimum line length in checksums.txt: 64 hex + 2 spaces + 1 char filename */ -#define CHECKSUM_LINE_MIN (SHA256_HEX_LEN + 2) +#define CHECKSUM_MANIFEST_MAX_BYTES (64U * CLI_BUF_1K) /* Compute the SHA-256 of a file in-process (no external hashing tool — those * differ per OS, may be absent, and mis-quote paths under cmd.exe). Writes a @@ -5340,116 +6229,313 @@ int cbm_cli_sha256_file(const char *path, char *out, size_t out_size) { return 0; } -/* ── Download helper (shell-free curl via exec) ───────────────── */ - -static int cbm_download_to_file(const char *url, const char *dest) { - const char *argv[] = {"curl", "-fSL", "--progress-bar", "-o", dest, url, NULL}; - return cbm_exec_no_shell(argv); +static int cli_checksum_hex_nibble(unsigned char value) { + if (value >= (unsigned char)'0' && value <= (unsigned char)'9') { + return (int)(value - (unsigned char)'0'); + } + if (value >= (unsigned char)'a' && value <= (unsigned char)'f') { + return (int)(value - (unsigned char)'a') + 10; + } + if (value >= (unsigned char)'A' && value <= (unsigned char)'F') { + return (int)(value - (unsigned char)'A') + 10; + } + return CLI_ERR; } -static int cbm_download_to_file_quiet(const char *url, const char *dest) { - const char *argv[] = {"curl", "-fsSL", "-o", dest, url, NULL}; - return cbm_exec_no_shell(argv); +static bool cli_checksum_line_references_archive( + const unsigned char *line, size_t line_length, const char *archive_name, + size_t archive_name_length) { + if (!line || !archive_name || line_length < archive_name_length || + memcmp(line + line_length - archive_name_length, archive_name, + archive_name_length) != 0) { + return false; + } + size_t prefix_length = line_length - archive_name_length; + if (prefix_length == 0) { + return true; + } + unsigned char separator = line[prefix_length - 1U]; + return separator == (unsigned char)' ' || + separator == (unsigned char)'\t' || + separator == (unsigned char)'*'; } -/* ── macOS ad-hoc signing ─────────────────────────────────────── */ +static bool cli_checksum_line_digest( + const unsigned char *line, size_t line_length, const char *archive_name, + size_t archive_name_length, char digest[SHA256_BUF_SIZE]) { + if (!line || line_length <= SHA256_HEX_LEN || + (line[SHA256_HEX_LEN] != (unsigned char)' ' && + line[SHA256_HEX_LEN] != (unsigned char)'\t')) { + return false; + } + size_t filename_offset = SHA256_HEX_LEN; + while (filename_offset < line_length && + (line[filename_offset] == (unsigned char)' ' || + line[filename_offset] == (unsigned char)'\t')) { + filename_offset++; + } + if (filename_offset < line_length && + line[filename_offset] == (unsigned char)'*') { + filename_offset++; + } + if (line_length - filename_offset != archive_name_length || + memcmp(line + filename_offset, archive_name, + archive_name_length) != 0) { + return false; + } + + static const char lower_hex[] = "0123456789abcdef"; + for (size_t i = 0; i < SHA256_HEX_LEN; i++) { + int nibble = cli_checksum_hex_nibble(line[i]); + if (nibble < 0) { + return false; + } + digest[i] = lower_hex[nibble]; + } + digest[SHA256_HEX_LEN] = '\0'; + return true; +} + +/* Parse one downloaded checksum manifest without trusting line truncation or + * substring matches. This non-header symbol is intentionally exercised by the + * focused CLI regression tests. Duplicate entries are accepted only when they + * name the exact artifact and normalize to the same SHA-256 digest. */ +int cbm_cli_checksum_manifest_digest(const char *manifest_path, + const char *archive_name, char *out, + size_t out_size) { + if (out && out_size > 0) { + out[0] = '\0'; + } + if (!manifest_path || !archive_name || !archive_name[0] || !out || + out_size < SHA256_BUF_SIZE || strchr(archive_name, '\n') || + strchr(archive_name, '\r')) { + return CLI_ERR; + } + + FILE *fp = cbm_fopen(manifest_path, "rb"); + if (!fp) { + return CLI_ERR; + } + unsigned char *manifest = malloc(CHECKSUM_MANIFEST_MAX_BYTES + 1U); + if (!manifest) { + (void)fclose(fp); + return CLI_ERR; + } + size_t manifest_length = 0; + bool read_ok = true; + while (manifest_length <= CHECKSUM_MANIFEST_MAX_BYTES) { + size_t capacity = CHECKSUM_MANIFEST_MAX_BYTES + 1U - manifest_length; + size_t count = fread(manifest + manifest_length, 1, capacity, fp); + manifest_length += count; + if (ferror(fp)) { + read_ok = false; + break; + } + if (feof(fp)) { + break; + } + if (count == 0) { + read_ok = false; + break; + } + } + if (fclose(fp) != 0) { + read_ok = false; + } + if (!read_ok || manifest_length == 0 || + manifest_length > CHECKSUM_MANIFEST_MAX_BYTES || + memchr(manifest, '\0', manifest_length)) { + free(manifest); + return CLI_ERR; + } + + size_t archive_name_length = strlen(archive_name); + char selected[SHA256_BUF_SIZE] = {0}; + bool found = false; + const unsigned char *cursor = manifest; + const unsigned char *end = manifest + manifest_length; + while (cursor < end) { + const unsigned char *newline = + memchr(cursor, '\n', (size_t)(end - cursor)); + const unsigned char *line_end = newline ? newline : end; + size_t line_length = (size_t)(line_end - cursor); + if (line_length > 0 && cursor[line_length - 1U] == (unsigned char)'\r') { + line_length--; + } + bool references = cli_checksum_line_references_archive( + cursor, line_length, archive_name, archive_name_length); + char candidate[SHA256_BUF_SIZE] = {0}; + bool valid = cli_checksum_line_digest( + cursor, line_length, archive_name, archive_name_length, + candidate); + if (references && !valid) { + free(manifest); + return CLI_ERR; + } + if (valid) { + if (found && strcmp(selected, candidate) != 0) { + free(manifest); + return CLI_ERR; + } + memcpy(selected, candidate, sizeof(selected)); + found = true; + } + cursor = newline ? newline + 1 : end; + } + free(manifest); + if (!found) { + return CLI_ERR; + } + memcpy(out, selected, sizeof(selected)); + return CLI_OK; +} + +/* ── Download helper (shell-free curl via exec) ───────────────── */ + +static bool cli_download_is_explicit_file_override(const char *url) { + char override_buffer[CLI_BUF_512]; + const char *override = cbm_safe_getenv( + "CBM_DOWNLOAD_URL", override_buffer, sizeof(override_buffer), NULL); + if (!url || !override || strncmp(override, "file://", 7) != 0) { + return false; + } + size_t override_length = strlen(override); + return override_length > 0 && + strncmp(url, override, override_length) == 0 && + (url[override_length] == '\0' || url[override_length] == '/' || + override[override_length - 1U] == '/'); +} + +static const char *cli_download_protocol(const char *url) { + if (url && strncmp(url, "https://", 8) == 0) { + return "=https"; + } + if (url && strncmp(url, "file://", 7) == 0 && + cli_download_is_explicit_file_override(url)) { + return "=file"; + } + return NULL; +} + +static int cbm_download_to_file(const char *url, const char *dest) { + const char *protocol = cli_download_protocol(url); + if (!protocol || !dest) { + (void)fprintf(stderr, + "error: update downloads require HTTPS (file:// is " + "reserved for an explicit CBM_DOWNLOAD_URL test " + "override)\n"); + return CLI_TRUE; + } + const char *argv[] = {"curl", "-fSL", + "--progress-bar", "--proto", + protocol, "--proto-redir", + protocol, "-o", + dest, url, + NULL}; + return cbm_exec_no_shell(argv); +} + +static int cbm_download_to_file_quiet(const char *url, const char *dest) { + const char *protocol = cli_download_protocol(url); + if (!protocol || !dest) { + (void)fprintf(stderr, + "error: checksum downloads require HTTPS (file:// is " + "reserved for an explicit CBM_DOWNLOAD_URL test " + "override)\n"); + return CLI_TRUE; + } + const char *argv[] = {"curl", "-fsSL", + "--proto", protocol, + "--proto-redir", protocol, + "-o", dest, + url, NULL}; + return cbm_exec_no_shell(argv); +} + +/* ── macOS ad-hoc signing ─────────────────────────────────────── */ #ifdef __APPLE__ static int cbm_macos_adhoc_sign(const char *binary_path) { /* Remove quarantine xattr (best effort — may not exist) */ - const char *xattr_argv[] = {"xattr", "-d", "com.apple.quarantine", binary_path, NULL}; + const char *xattr_argv[] = {"/usr/bin/xattr", "-d", "com.apple.quarantine", binary_path, + NULL}; (void)cbm_exec_no_shell(xattr_argv); /* Ad-hoc sign (required for arm64, harmless for x86_64) */ - const char *sign_argv[] = {"codesign", "--sign", "-", "--force", binary_path, NULL}; + const char *sign_argv[] = {"/usr/bin/codesign", "--sign", "-", "--force", binary_path, + NULL}; return cbm_exec_no_shell(sign_argv); } #endif -/* ── Kill other MCP server instances ──────────────────────────── */ - -static int cbm_kill_other_instances(void) { -#ifdef _WIN32 - /* taskkill /IM kills ALL matching processes INCLUDING self. - * Use /FI filter to exclude our own PID. */ - char pid_filter[CBM_SZ_64]; - snprintf(pid_filter, sizeof(pid_filter), "PID ne %lu", (unsigned long)GetCurrentProcessId()); - const char *argv[] = {"taskkill", "/F", "/FI", "IMAGENAME eq codebase-memory-mcp.exe", - "/FI", pid_filter, NULL}; - (void)cbm_exec_no_shell(argv); - return 0; -#else - int killed = 0; - pid_t self = getpid(); - FILE *fp = cbm_popen("pgrep -x codebase-memory-mcp", "r"); - if (!fp) { - return 0; +/* Download checksums.txt and verify the archive integrity. Every non-zero + * result is a fail-closed refusal; verification is never optional. */ +static int verify_download_checksum(const char *archive_path, const char *archive_name) { + char checksum_file[CLI_BUF_256]; + int checksum_path_length = snprintf( + checksum_file, sizeof(checksum_file), "%s/cbm-checksums-XXXXXX", + cbm_tmpdir()); + if (checksum_path_length <= 0 || + (size_t)checksum_path_length >= sizeof(checksum_file)) { + return CLI_ERR; } - char line[CLI_BUF_32]; - while (fgets(line, sizeof(line), fp)) { - pid_t pid = (pid_t)strtol(line, NULL, CLI_STRTOL_BASE); - if (pid > 0 && pid != self) { - if (kill(pid, SIGTERM) == 0) { - killed++; - } - } + int checksum_descriptor = cbm_mkstemp(checksum_file); + if (checksum_descriptor < 0) { + return CLI_ERR; } - cbm_pclose(fp); - return killed; +#ifdef _WIN32 + int checksum_close_status = _close(checksum_descriptor); +#else + int checksum_close_status = close(checksum_descriptor); #endif -} - -/* Download checksums.txt and verify the archive integrity. - * Returns: 0 = verified OK, 1 = mismatch (FAIL), -1 = could not verify (warning). */ -static int verify_download_checksum(const char *archive_path, const char *archive_name) { - char checksum_file[CLI_BUF_256]; - snprintf(checksum_file, sizeof(checksum_file), "%s/cbm-checksums.txt", cbm_tmpdir()); + if (checksum_close_status != 0) { + cbm_unlink(checksum_file); + return CLI_ERR; + } char dl_base_buf[CLI_BUF_512]; const char *dl_base = cbm_safe_getenv("CBM_DOWNLOAD_URL", dl_base_buf, sizeof(dl_base_buf), NULL); char checksum_url[CLI_BUF_512]; + int checksum_url_length; if (dl_base && dl_base[0]) { - snprintf(checksum_url, sizeof(checksum_url), "%s/checksums.txt", dl_base); + checksum_url_length = snprintf(checksum_url, sizeof(checksum_url), + "%s/checksums.txt", dl_base); } else { - snprintf(checksum_url, sizeof(checksum_url), "%s", - "https://github.com/DeusData/codebase-memory-mcp/releases/latest/download/" - "checksums.txt"); + checksum_url_length = snprintf( + checksum_url, sizeof(checksum_url), "%s", + "https://github.com/DeusData/codebase-memory-mcp/releases/latest/" + "download/checksums.txt"); + } + if (checksum_url_length <= 0 || + (size_t)checksum_url_length >= sizeof(checksum_url)) { + cbm_unlink(checksum_file); + return CLI_ERR; } int rc = cbm_download_to_file_quiet(checksum_url, checksum_file); if (rc != 0) { (void)fprintf(stderr, - "warning: could not download checksums.txt — skipping verification\n"); + "error: could not download checksums.txt for mandatory " + "verification\n"); cbm_unlink(checksum_file); return CLI_ERR; } - FILE *fp = fopen(checksum_file, "r"); - cbm_unlink(checksum_file); - if (!fp) { - return CLI_ERR; - } - char expected[SHA256_BUF_SIZE] = {0}; - char line[CLI_BUF_512]; - while (fgets(line, sizeof(line), fp)) { - /* Format: \n */ - if (strlen(line) > CHECKSUM_LINE_MIN && strstr(line, archive_name)) { - memcpy(expected, line, SHA256_HEX_LEN); - expected[SHA256_HEX_LEN] = '\0'; - break; - } - } - (void)fclose(fp); - - if (expected[0] == '\0') { - (void)fprintf(stderr, "warning: %s not found in checksums.txt\n", archive_name); + int manifest_status = cbm_cli_checksum_manifest_digest( + checksum_file, archive_name, expected, sizeof(expected)); + cbm_unlink(checksum_file); + if (manifest_status != CLI_OK) { + (void)fprintf(stderr, + "error: checksums.txt has no single valid SHA-256 entry " + "for exact artifact %s\n", + archive_name); return CLI_ERR; } char actual[SHA256_BUF_SIZE] = {0}; if (cbm_cli_sha256_file(archive_path, actual, sizeof(actual)) != 0) { - (void)fprintf(stderr, "error: could not compute checksum (sha256 tool unavailable)\n"); + (void)fprintf(stderr, "error: could not compute archive checksum\n"); return CLI_ERR; } @@ -7370,7 +8456,11 @@ static int count_db_indexes(const char *home) { * in cli.h (internal helper); the test carries an extern forward declaration. */ int cbm_install_handle_existing_indexes(const char *home, bool reset, bool dry_run); -int cbm_install_handle_existing_indexes(const char *home, bool reset, bool dry_run) { +static int cbm_install_prepare_existing_indexes(const char *home, bool reset, bool dry_run, + bool *delete_indexes_out) { + if (delete_indexes_out) { + *delete_indexes_out = false; + } int index_count = count_db_indexes(home); if (index_count <= 0) { return 1; /* nothing to handle, proceed */ @@ -7394,11 +8484,21 @@ int cbm_install_handle_existing_indexes(const char *home, bool reset, bool dry_r printf("Install cancelled.\n"); return 0; /* abort */ } - if (!dry_run) { + if (!dry_run && delete_indexes_out) { + *delete_indexes_out = true; + } + return 1; /* proceed */ +} + +int cbm_install_handle_existing_indexes(const char *home, bool reset, bool dry_run) { + bool delete_indexes = false; + int prepare_result = + cbm_install_prepare_existing_indexes(home, reset, dry_run, &delete_indexes); + if (prepare_result == 1 && delete_indexes) { int removed = cbm_remove_indexes(home); printf("Removed %d index(es).\n\n", removed); } - return 1; /* proceed */ + return prepare_result; } /* ── Subcommand: install ──────────────────────────────────────── */ @@ -7433,7 +8533,9 @@ static void cbm_detect_self_path(char *buf, size_t buf_sz, const char *home) { * the config / instruction / skill / agent / hook files `install` WOULD write, produced by * running the real install dispatch in record-only mode (no mutation, no * network). Returns a heap JSON string (caller frees) or NULL. */ -char *cbm_build_install_plan_json(const char *home, const char *binary_path) { +static char *cbm_build_install_plan_json_options(const char *home, + const char *binary_path, + bool skip_config) { if (!home || !binary_path) { return NULL; } @@ -7441,9 +8543,11 @@ char *cbm_build_install_plan_json(const char *home, const char *binary_path) { /* Same code path as a real install, but mutations disabled and every write * site records into `plan` — so the receipt cannot drift from behavior. */ cbm_install_plan_t plan = {0}; - g_install_plan = &plan; - cbm_install_agent_configs(home, binary_path, false, true); - g_install_plan = NULL; + if (!skip_config) { + g_install_plan = &plan; + cbm_install_agent_configs(home, binary_path, false, true); + g_install_plan = NULL; + } cbm_detected_agents_t det = cbm_detect_agents(home); struct { @@ -7542,26 +8646,196 @@ char *cbm_build_install_plan_json(const char *home, const char *binary_path) { return json; /* malloc'd; caller frees */ } +char *cbm_build_install_plan_json(const char *home, + const char *binary_path) { + return cbm_build_install_plan_json_options(home, binary_path, false); +} + +typedef struct { + const char *bin_target; + const char *bin_dir; + const char *home; + const char *shell_rc; + const char *prepared_candidate; + cbm_activation_transaction_t *binary_transaction; + cli_binary_validator_t binary_validator; + bool has_binary_validator; + bool copy_binary; + bool delete_indexes; + bool skip_config; + bool force; + bool dry_run; +} cli_install_activation_t; + +static int cli_install_activate(void *opaque) { + cli_install_activation_t *activation = opaque; + if (!activation || !activation->bin_target || !activation->bin_dir || + !activation->home || !activation->shell_rc) { + return CLI_TRUE; + } + if (activation->copy_binary) { + if (activation->dry_run) { + printf("Would install binary -> %s\n\n", activation->bin_target); + } + } + if (!activation->dry_run && !activation->binary_transaction && + activation->prepared_candidate) { + if (!cbm_mkdir_p(activation->bin_dir, CLI_OCTAL_PERM)) { + (void)fprintf(stderr, + "error: cannot create install directory %s\n", + activation->bin_dir); + return CLI_TRUE; + } + cbm_activation_transaction_status_t stage_status = + cbm_activation_transaction_stage_file( + activation->bin_target, activation->prepared_candidate, + &activation->binary_transaction); + cli_binary_validator_t restaged_validator = {{0}}; + if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || + !activation->binary_transaction || + !cli_activation_transaction_expected_build( + activation->binary_transaction, &restaged_validator) || + !activation->has_binary_validator || + strcmp(restaged_validator.fingerprint, + activation->binary_validator.fingerprint) != 0) { + (void)fprintf(stderr, + "error: verified install candidate could not be " + "re-staged on the target filesystem\n"); + cli_activation_transaction_abort_or_fail_stop( + &activation->binary_transaction, + "install_transaction_restaging_cleanup"); + return CLI_TRUE; + } + } + if (!activation->dry_run && activation->binary_transaction) { + if (cli_activation_transaction_commit_validated( + activation->binary_transaction, + activation->has_binary_validator + ? &activation->binary_validator + : NULL, + CLI_OCTAL_PERM) != CLI_OK) { + cli_activation_transaction_abort_or_fail_stop( + &activation->binary_transaction, + "install_transaction_publish_recovery"); + (void)fprintf(stderr, + "error: failed to publish the staged binary to %s\n", + activation->bin_target); + return CLI_TRUE; + } + printf("Installed binary -> %s\n\n", activation->bin_target); + } + /* Config and PATH refreshes are install mutations too. Keep them in this + * callback so the startup lock covers the complete filesystem window, + * including same-binary and non-force installs. */ + int agent_config_rc = CLI_OK; + if (!activation->skip_config) { + agent_config_rc = cbm_install_agent_configs( + activation->home, activation->bin_target, activation->force, + activation->dry_run); + } + if (agent_config_rc != CLI_OK) { + cli_activation_transaction_finalize_committed_or_fail_stop( + &activation->binary_transaction, + "install_transaction_partial_finalize"); + (void)fprintf( + stderr, + "error: one or more agent configurations failed; the " + "published/current executable was kept, and PATH/index cleanup " + "was not attempted\n"); + return CLI_ACTIVATION_PARTIAL; + } + int path_rc = CLI_TRUE; +#ifdef _WIN32 + path_rc = cli_ensure_windows_user_path(activation->bin_dir, + activation->dry_run || + g_cli_activation_test_ops_set); + if (path_rc == CLI_OK) { + printf("\nAdded %s to the current-user PATH\n", activation->bin_dir); + } else if (path_rc == CLI_TRUE) { + printf("\nPATH already includes %s\n", activation->bin_dir); + } +#else + if (activation->shell_rc[0]) { + path_rc = cbm_ensure_path(activation->bin_dir, activation->shell_rc, + activation->dry_run); + if (path_rc == 0) { + printf("\nAdded %s to PATH in %s\n", activation->bin_dir, + activation->shell_rc); + } else if (path_rc == CLI_TRUE) { + printf("\nPATH already includes %s\n", activation->bin_dir); + } + } +#endif + if (path_rc == CLI_ERR) { + cli_activation_transaction_finalize_committed_or_fail_stop( + &activation->binary_transaction, + "install_transaction_path_failure_finalize"); + (void)fprintf( + stderr, + "error: PATH configuration failed; the published/current " + "executable was kept, and index cleanup was not attempted\n"); + return CLI_ACTIVATION_PARTIAL; + } + if (!activation->dry_run && activation->delete_indexes) { + int expected = count_db_indexes(activation->home); + int removed = cbm_remove_indexes(activation->home); + printf("Removed %d index(es).\n\n", removed); + if (removed != expected) { + cli_activation_transaction_finalize_committed_or_fail_stop( + &activation->binary_transaction, + "install_transaction_index_failure_finalize"); + (void)fprintf(stderr, + "error: only %d of %d indexes could be removed\n", + removed, expected); + return CLI_ACTIVATION_PARTIAL; + } + } + cli_activation_transaction_finalize_committed_or_fail_stop( + &activation->binary_transaction, "install_transaction_finalize"); + return CLI_OK; +} + int cbm_cmd_install(int argc, char **argv) { parse_auto_answer(argc, argv); bool dry_run = false; bool force = false; bool plan = false; bool reset_indexes = false; + bool skip_config = false; + const char *requested_bin_dir = NULL; for (int i = 0; i < argc; i++) { if (strcmp(argv[i], "--dry-run") == 0) { dry_run = true; - } - if (strcmp(argv[i], "--force") == 0) { + } else if (strcmp(argv[i], "--force") == 0) { force = true; - } - if (strcmp(argv[i], "--plan") == 0) { + } else if (strcmp(argv[i], "--plan") == 0) { plan = true; - } - /* Opt-in: delete existing indexes during install. Default preserves - * the indexed graph (#607). Only this flag triggers deletion. */ - if (strcmp(argv[i], "--reset-indexes") == 0) { + } else if (strcmp(argv[i], "--reset-indexes") == 0) { reset_indexes = true; + } else if (strcmp(argv[i], "--skip-config") == 0) { + skip_config = true; + } else if (strncmp(argv[i], "--dir=", SLEN("--dir=")) == 0) { + requested_bin_dir = argv[i] + SLEN("--dir="); + if (!requested_bin_dir[0]) { + (void)fprintf(stderr, + "error: --dir requires a non-empty path\n"); + return CLI_TRUE; + } + } else if (strcmp(argv[i], "--dir") == 0) { + if (i + 1 >= argc || !argv[i + 1] || !argv[i + 1][0] || + argv[i + 1][0] == '-') { + (void)fprintf(stderr, + "error: --dir requires a non-empty path\n"); + return CLI_TRUE; + } + requested_bin_dir = argv[++i]; + } else if (strcmp(argv[i], "-y") != 0 && + strcmp(argv[i], "--yes") != 0 && + strcmp(argv[i], "-n") != 0 && + strcmp(argv[i], "--no") != 0) { + (void)fprintf(stderr, "error: unknown install option: %s\n", + argv[i]); + return CLI_TRUE; } } @@ -7571,13 +8845,36 @@ int cbm_cmd_install(int argc, char **argv) { return CLI_TRUE; } + char bin_dir[CLI_BUF_1K]; + int bin_dir_length = requested_bin_dir + ? snprintf(bin_dir, sizeof(bin_dir), "%s", + requested_bin_dir) + : snprintf(bin_dir, sizeof(bin_dir), + "%s/.local/bin", home); + if (bin_dir_length <= 0 || (size_t)bin_dir_length >= sizeof(bin_dir)) { + (void)fprintf(stderr, "error: install directory path is too long\n"); + return CLI_TRUE; + } + cbm_normalize_path_sep(bin_dir); + char bin_target[CLI_BUF_1K]; +#ifdef _WIN32 + int target_length = snprintf(bin_target, sizeof(bin_target), + "%s/codebase-memory-mcp.exe", bin_dir); +#else + int target_length = snprintf(bin_target, sizeof(bin_target), + "%s/codebase-memory-mcp", bin_dir); +#endif + if (target_length <= 0 || (size_t)target_length >= sizeof(bin_target)) { + (void)fprintf(stderr, "error: install target path is too long\n"); + return CLI_TRUE; + } + /* --plan: emit the machine-readable install receipt and exit WITHOUT * mutating anything (no config writes, no index deletion, no network) so * an agent can inspect exactly what install would touch first (#388). */ if (plan) { - char self_path[CLI_BUF_1K] = {0}; - cbm_detect_self_path(self_path, sizeof(self_path), home); - char *json = cbm_build_install_plan_json(home, self_path); + char *json = cbm_build_install_plan_json_options( + home, bin_target, skip_config); if (!json) { (void)fprintf(stderr, "error: failed to build install plan\n"); return CLI_TRUE; @@ -7589,103 +8886,246 @@ int cbm_cmd_install(int argc, char **argv) { printf("codebase-memory-mcp install %s\n\n", CBM_VERSION); + char self_path[CLI_BUF_1K] = {0}; + cbm_detect_self_path(self_path, sizeof(self_path), home); + + struct stat target_status; + bool target_exists = (stat(bin_target, &target_status) == 0); + bool same_binary = cbm_same_file(self_path, bin_target); + bool do_copy = !same_binary && (!target_exists || force); + /* (#607) Default: preserve existing indexes. `--reset-indexes` opts into * the old prompt-and-delete behaviour. The helper returns 0 only when the * user declines the reset prompt, in which case we abort the install. */ - if (cbm_install_handle_existing_indexes(home, reset_indexes, dry_run) == 0) { + bool delete_indexes = false; + if (cbm_install_prepare_existing_indexes(home, reset_indexes, dry_run, &delete_indexes) == 0) { return CLI_TRUE; } - /* Step 1b: Kill running MCP server instances so agents pick up new config */ - if (!dry_run) { - int killed = cbm_kill_other_instances(); - if (killed > 0) { - printf("Stopped %d running MCP server instance(s).\n\n", killed); - } - } - /* Step 1c: Place the running binary at the canonical install target. * Previously install only re-signed whatever was already at the target, so * `install --force` from a freshly built binary silently kept the OLD file * — operators ran stale code believing they had upgraded (#472). Copy the * running binary to ~/.local/bin (unless we ARE that file), then sign it. */ - char self_path[CLI_BUF_1K] = {0}; - cbm_detect_self_path(self_path, sizeof(self_path), home); - - char bin_target[CLI_BUF_1K]; + if (!same_binary && target_exists && !force) { + printf("A different binary already exists at:\n %s\n", bin_target); + if (prompt_yn("Replace it with the binary you ran install from?")) { + do_copy = true; + force = true; /* user approved replacement for this run */ + } else { + printf("Keeping existing binary; configs will point at it.\n\n"); + } + } +#ifdef __APPLE__ + /* A freshly clang-built arm64 binary is linker-signed (flags=0x20002) + * and gets Killed:9 when spawned by an MCP host. Sign the private staged + * candidate before disrupting sessions, then publish those exact verified + * bytes inside the activation window. */ + bool sign_binary = do_copy || target_exists; + bool prepare_binary = sign_binary; +#else + bool prepare_binary = do_copy; +#endif + cbm_activation_transaction_t *binary_transaction = NULL; + cli_binary_validator_t binary_validator = {{0}}; + bool has_binary_validator = false; + char prepared_dir[CLI_BUF_1K] = {0}; + char prepared_candidate[CLI_BUF_1K] = {0}; + if (!dry_run && prepare_binary) { +#ifdef __APPLE__ + const char *candidate = do_copy ? self_path : bin_target; +#else + /* Non-macOS activation reaches this block only for a real copy. */ + const char *candidate = self_path; +#endif + bool target_parent_exists = cbm_is_dir(bin_dir); + bool prepare_out_of_line = !target_parent_exists; +#ifdef __APPLE__ + /* codesign may replace the file's inode. Sign a private published copy + * first, then open the final transaction over those immutable bytes; + * mutating a transaction-owned stage invalidates its identity snapshot. */ + prepare_out_of_line = prepare_out_of_line || sign_binary; +#endif + const char *stage_target = bin_target; + if (prepare_out_of_line) { + int dir_length = snprintf( + prepared_dir, sizeof(prepared_dir), "%s/cbm-install-XXXXXX", + cbm_tmpdir()); + if (dir_length <= 0 || (size_t)dir_length >= sizeof(prepared_dir) || + !cbm_mkdtemp(prepared_dir)) { + (void)fprintf(stderr, + "error: cannot create private install staging " + "directory\n"); + return CLI_TRUE; + } #ifdef _WIN32 - snprintf(bin_target, sizeof(bin_target), "%s/.local/bin/codebase-memory-mcp.exe", home); + int candidate_length = snprintf( + prepared_candidate, sizeof(prepared_candidate), + "%s/codebase-memory-mcp.exe", prepared_dir); #else - snprintf(bin_target, sizeof(bin_target), "%s/.local/bin/codebase-memory-mcp", home); + int candidate_length = snprintf( + prepared_candidate, sizeof(prepared_candidate), + "%s/codebase-memory-mcp", prepared_dir); #endif - - if (!cbm_same_file(self_path, bin_target)) { - struct stat tgt_st; - bool target_exists = (stat(bin_target, &tgt_st) == 0); - bool do_copy = !target_exists || force; - if (target_exists && !force) { - printf("A different binary already exists at:\n %s\n", bin_target); - if (prompt_yn("Replace it with the binary you ran install from?")) { - do_copy = true; - force = true; /* user approved replacement for this run */ - } else { - printf("Keeping existing binary; configs will point at it.\n\n"); + if (candidate_length <= 0 || + (size_t)candidate_length >= sizeof(prepared_candidate)) { + (void)cbm_rmdir(prepared_dir); + (void)fprintf(stderr, + "error: private install staging path is too long\n"); + return CLI_TRUE; } + stage_target = prepared_candidate; + } + cbm_activation_transaction_status_t stage_status = + cbm_activation_transaction_stage_file( + stage_target, candidate, &binary_transaction); + cli_binary_validator_t staged_validator = {{0}}; + if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || + !binary_transaction || + !cli_activation_transaction_expected_build( + binary_transaction, &staged_validator)) { + (void)fprintf(stderr, "error: failed to stage install candidate: %s\n", + cbm_activation_transaction_status_message(stage_status)); + (void)cli_activation_transaction_abort(&binary_transaction); + if (prepared_dir[0]) { + (void)cbm_rmdir(prepared_dir); + } + return CLI_TRUE; } - if (do_copy) { - char bin_dir[CLI_BUF_1K]; - snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", home); - if (dry_run) { - printf("Would install binary -> %s\n\n", bin_target); - } else { - cbm_mkdir_p(bin_dir, CLI_OCTAL_PERM); - if (cbm_copy_binary_to_target(self_path, bin_target) != 0) { - (void)fprintf(stderr, "error: failed to copy binary to %s\n", bin_target); + if (prepare_out_of_line) { + if (cli_activation_transaction_commit_validated( + binary_transaction, &staged_validator, + CLI_OCTAL_PERM) != CLI_OK || + cli_activation_transaction_finalize_close( + &binary_transaction) != CLI_OK) { + (void)cli_activation_transaction_abort(&binary_transaction); + (void)cbm_unlink(prepared_candidate); + (void)cbm_rmdir(prepared_dir); + (void)fprintf(stderr, + "error: private install candidate preparation " + "failed\n"); + return CLI_TRUE; + } +#ifdef __APPLE__ + if (sign_binary && + cbm_macos_adhoc_sign(prepared_candidate) != 0) { + (void)fprintf( + stderr, + "error: ad-hoc signing the private macOS candidate failed\n"); + (void)cbm_unlink(prepared_candidate); + (void)cbm_rmdir(prepared_dir); + return CLI_TRUE; + } +#endif + has_binary_validator = cbm_daemon_build_fingerprint_file( + prepared_candidate, binary_validator.fingerprint); + if (has_binary_validator && !g_cli_activation_test_ops_set) { + const char *candidate_argv[] = { + prepared_candidate, "--version", NULL}; + has_binary_validator = + cbm_exec_no_shell(candidate_argv) == CLI_OK; + } + if (!has_binary_validator) { + (void)fprintf(stderr, + "error: prepared install candidate could not be " + "verified\n"); + (void)cbm_unlink(prepared_candidate); + (void)cbm_rmdir(prepared_dir); + return CLI_TRUE; + } + if (target_parent_exists) { + stage_status = cbm_activation_transaction_stage_file( + bin_target, prepared_candidate, &binary_transaction); + cli_binary_validator_t final_validator = {{0}}; + if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || + !binary_transaction || + !cli_activation_transaction_expected_build( + binary_transaction, &final_validator) || + strcmp(final_validator.fingerprint, + binary_validator.fingerprint) != 0) { + (void)fprintf( + stderr, + "error: signed install candidate could not be staged " + "on the target filesystem\n"); + (void)cli_activation_transaction_abort( + &binary_transaction); + (void)cbm_unlink(prepared_candidate); + (void)cbm_rmdir(prepared_dir); return CLI_TRUE; } - printf("Installed binary -> %s\n\n", bin_target); + binary_validator = final_validator; } + } else { + binary_validator = staged_validator; + has_binary_validator = true; } - } - - /* Step 1d: macOS ad-hoc signing of the installed binary. A freshly - * clang-built arm64 binary is linker-signed (flags=0x20002) and gets - * Killed:9 when spawned by an MCP host; re-signing ad-hoc (flags=0x2) - * makes it launchable. Sign the target, not whatever the operator ran. */ -#ifdef __APPLE__ - if (!dry_run) { - struct stat sign_st; - if (stat(bin_target, &sign_st) == 0) { - if (cbm_macos_adhoc_sign(bin_target) != 0) { - (void)fprintf( - stderr, "warning: ad-hoc signing failed — binary may not run on macOS arm64\n"); + if (!has_binary_validator) { + (void)fprintf(stderr, + "error: staged install candidate could not be verified\n"); + if (binary_transaction) { + (void)cli_activation_transaction_abort(&binary_transaction); + } + if (prepared_candidate[0]) { + (void)cbm_unlink(prepared_candidate); + } + if (prepared_dir[0]) { + (void)cbm_rmdir(prepared_dir); } + return CLI_TRUE; } } + char shell_rc[CLI_BUF_1K] = {0}; +#ifndef _WIN32 + snprintf(shell_rc, sizeof(shell_rc), "%s", cbm_detect_shell_rc(home)); #endif - - /* Step 3: Install/refresh all agent configs, pointing at the install target. */ - int agent_config_rc = cbm_install_agent_configs(home, bin_target, force, dry_run); - - /* Step 4: Ensure PATH */ - char bin_dir[CLI_BUF_1K]; - snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", home); - const char *rc = cbm_detect_shell_rc(home); - if (rc[0]) { - int path_rc = cbm_ensure_path(bin_dir, rc, dry_run); - if (path_rc == 0) { - printf("\nAdded %s to PATH in %s\n", bin_dir, rc); - } else if (path_rc == CLI_TRUE) { - printf("\nPATH already includes %s\n", bin_dir); - } + cli_install_activation_t activation = { + .bin_target = bin_target, + .bin_dir = bin_dir, + .home = home, + .shell_rc = shell_rc, + .prepared_candidate = prepared_candidate[0] + ? prepared_candidate + : NULL, + .binary_transaction = binary_transaction, + .binary_validator = binary_validator, + .has_binary_validator = has_binary_validator, + .copy_binary = do_copy, + .delete_indexes = delete_indexes, + .skip_config = skip_config, + .force = force, + .dry_run = dry_run, + }; + int activation_rc = dry_run ? cli_install_activate(&activation) + : cli_activation_guard( + CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL, + CBM_VERSION, + has_binary_validator + ? binary_validator.fingerprint + : NULL, + cli_install_activate, &activation); + if (activation.binary_transaction) { + (void)cli_activation_transaction_abort( + &activation.binary_transaction); + } + if (prepared_candidate[0]) { + (void)cbm_unlink(prepared_candidate); + } + if (prepared_dir[0]) { + (void)cbm_rmdir(prepared_dir); + } + if (activation_rc != CLI_OK) { + return CLI_TRUE; } - printf("\nInstall complete. Restart your shell or run:\n"); - printf(" source %s\n", rc); + printf("\nInstall complete. Please restart your coding-agent sessions to " + "properly take this into account.\n"); +#ifndef _WIN32 + printf("Restart your shell or run:\n source %s\n", shell_rc); +#endif if (dry_run) { printf("\n(dry-run — no files were modified)\n"); } - return agent_config_rc == CLI_OK ? 0 : CLI_TRUE; + return 0; } /* ── Subcommand: uninstall ────────────────────────────────────── */ @@ -8787,12 +10227,99 @@ static void uninstall_additional_agents(const cbm_detected_agents_t *agents, con } } +typedef struct { + const char *home; + const char *bin_path; + cbm_activation_transaction_t *binary_transaction; + cbm_detected_agents_t agents; + bool binary_exists; + bool delete_indexes; + bool dry_run; +} cli_uninstall_activation_t; + +/* Uninstall is an activation too: removing the executable or its indexes + * while a daemon generation is starting/running would leave live sessions on + * a partially removed installation. Keep every filesystem mutation inside + * the same startup-lock + lifetime-reservation guard as install/update. */ +static int cli_uninstall_activate(void *opaque) { + cli_uninstall_activation_t *activation = opaque; + if (!activation || !activation->home) { + return CLI_TRUE; + } + + if (activation->agents.claude_code) { + uninstall_claude_code(activation->home, activation->dry_run); + } + uninstall_cli_agents(&activation->agents, activation->home, activation->dry_run); + uninstall_editor_agents(&activation->agents, activation->home, activation->dry_run); + uninstall_additional_agents(&activation->agents, activation->home, + activation->dry_run); + uninstall_agent_client_registry(activation->home, activation->dry_run); + + if (g_agent_uninstall_errors != 0) { + cli_activation_transaction_abort_or_fail_stop( + &activation->binary_transaction, + "uninstall_transaction_config_cleanup_abort"); + (void)fprintf( + stderr, + "error: one or more agent cleanup operations failed; executable " + "and index removal were not started\n"); + return CLI_ACTIVATION_PARTIAL; + } + + if (activation->delete_indexes && !activation->dry_run) { + int expected = count_db_indexes(activation->home); + int idx_removed = cbm_remove_indexes(activation->home); + printf("Removed %d index(es).\n", idx_removed); + if (idx_removed != expected) { + cli_activation_transaction_abort_or_fail_stop( + &activation->binary_transaction, + "uninstall_transaction_index_failure_abort"); + (void)fprintf(stderr, + "error: only %d of %d indexes could be removed\n", + idx_removed, expected); + return CLI_ACTIVATION_PARTIAL; + } + } + if (!activation->dry_run && activation->binary_transaction) { + if (cli_activation_transaction_commit_removal( + activation->binary_transaction) != CLI_OK) { + cli_activation_transaction_abort_or_fail_stop( + &activation->binary_transaction, + "uninstall_transaction_removal_recovery"); + (void)fprintf(stderr, "error: failed to remove %s; completed " + "configuration/index cleanup may remain\n", + activation->bin_path); + return CLI_ACTIVATION_PARTIAL; + } + cli_activation_transaction_finalize_committed_or_fail_stop( + &activation->binary_transaction, + "uninstall_transaction_removal_finalize"); + } + if (activation->binary_exists) { + printf("Removed %s\n", activation->bin_path); + } + return CLI_OK; +} + int cbm_cmd_uninstall(int argc, char **argv) { parse_auto_answer(argc, argv); bool dry_run = false; for (int i = 0; i < argc; i++) { + /* The public command dispatcher passes option-only argv, while the + * long-standing direct API/tests include the subcommand at argv[0]. */ + if (i == 0 && strcmp(argv[i], "uninstall") == 0) { + continue; + } if (strcmp(argv[i], "--dry-run") == 0) { dry_run = true; + } else if (strcmp(argv[i], "-y") != 0 && + strcmp(argv[i], "--yes") != 0 && + strcmp(argv[i], "-n") != 0 && + strcmp(argv[i], "--no") != 0) { + (void)fprintf(stderr, "error: unknown uninstall option: %s\n", + argv[i]); + return CLI_TRUE; } } @@ -8806,43 +10333,85 @@ int cbm_cmd_uninstall(int argc, char **argv) { g_agent_uninstall_errors = 0; cbm_detected_agents_t agents = cbm_detect_agents(home); - if (agents.claude_code) { - uninstall_claude_code(home, dry_run); - } - uninstall_cli_agents(&agents, home, dry_run); - uninstall_editor_agents(&agents, home, dry_run); - uninstall_additional_agents(&agents, home, dry_run); - uninstall_agent_client_registry(home, dry_run); - /* Step 2: Remove indexes */ + /* Confirm index removal outside the startup lock, but defer the mutation + * until the final guarded activation. Dry-run never removes indexes. */ + bool delete_indexes = false; int index_count = count_db_indexes(home); if (index_count > 0) { printf("\nFound %d index(es):\n", index_count); cbm_list_indexes(home); if (prompt_yn("Delete these indexes?")) { - int idx_removed = cbm_remove_indexes(home); - printf("Removed %d index(es).\n", idx_removed); + if (dry_run) { + printf("(dry-run — indexes would be deleted)\n"); + } else { + delete_indexes = true; + } } else { printf("Indexes kept.\n"); } } - /* Step 3: Remove binary */ char bin_path[CLI_BUF_1K]; #ifdef _WIN32 - snprintf(bin_path, sizeof(bin_path), "%s/.local/bin/codebase-memory-mcp.exe", home); + snprintf(bin_path, sizeof(bin_path), + "%s/.local/bin/codebase-memory-mcp.exe", home); #else - snprintf(bin_path, sizeof(bin_path), "%s/.local/bin/codebase-memory-mcp", home); + snprintf(bin_path, sizeof(bin_path), + "%s/.local/bin/codebase-memory-mcp", home); #endif - struct stat st; - if (stat(bin_path, &st) == 0) { - if (!dry_run) { - cbm_unlink(bin_path); + struct stat binary_status; + bool binary_exists = stat(bin_path, &binary_status) == 0; + cbm_activation_transaction_t *binary_transaction = NULL; + if (!dry_run && binary_exists) { +#ifdef _WIN32 + char self_path[CLI_BUF_1K] = {0}; + cbm_detect_self_path(self_path, sizeof(self_path), home); + if (cbm_same_file(self_path, bin_path)) { + (void)fprintf( + stderr, + "error: Windows cannot remove the executable that is running " + "this uninstall. Run uninstall from a verified candidate copy; " + "no CBM sessions were stopped.\n"); + return CLI_TRUE; } - printf("Removed %s\n", bin_path); +#endif + cbm_activation_transaction_status_t stage_status = + cbm_activation_transaction_stage_removal( + bin_path, &binary_transaction); + if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || + !binary_transaction) { + (void)fprintf(stderr, + "error: failed to stage uninstall transaction: %s\n", + cbm_activation_transaction_status_message(stage_status)); + (void)cli_activation_transaction_abort(&binary_transaction); + return CLI_TRUE; + } + } + cli_uninstall_activation_t activation = { + .home = home, + .bin_path = bin_path, + .binary_transaction = binary_transaction, + .agents = agents, + .binary_exists = binary_exists, + .delete_indexes = delete_indexes, + .dry_run = dry_run, + }; + int activation_rc = + dry_run ? cli_uninstall_activate(&activation) + : cli_activation_guard( + CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL, NULL, NULL, + cli_uninstall_activate, &activation); + if (activation.binary_transaction) { + (void)cli_activation_transaction_abort( + &activation.binary_transaction); + } + if (activation_rc != CLI_OK) { + return CLI_TRUE; } - printf("\nUninstall complete.\n"); + printf("\nUninstall complete. Please restart your coding-agent sessions " + "to properly take this into account.\n"); if (dry_run) { printf("(dry-run — no files were modified)\n"); } @@ -8858,7 +10427,68 @@ typedef struct { const char *tmp_archive; const char *ext; const char *bin_dest; + const char *home; + bool delete_indexes; } extract_install_args_t; + +typedef struct { + const char *bin_dest; + const char *home; + cbm_activation_transaction_t *binary_transaction; + cli_binary_validator_t binary_validator; + bool delete_indexes; +} cli_update_activation_t; + +static int cli_update_activate_binary(void *opaque) { + cli_update_activation_t *activation = opaque; + if (!activation || !activation->bin_dest || + !activation->binary_transaction) { + return CLI_TRUE; + } + if (cli_activation_transaction_commit_validated( + activation->binary_transaction, &activation->binary_validator, + CLI_OCTAL_PERM) != CLI_OK) { + cli_activation_transaction_abort_or_fail_stop( + &activation->binary_transaction, + "update_transaction_publish_recovery"); + (void)fprintf(stderr, "error: cannot publish staged update to %s\n", + activation->bin_dest); + return CLI_TRUE; + } + /* Agent configs must never observe a replacement binary outside the same + * exact-build activation window. Otherwise another CBM process can start + * after the binary swap but before its MCP/hook entries are refreshed. */ + printf("Refreshing agent configurations...\n"); + if (cbm_install_agent_configs(activation->home, activation->bin_dest, true, + false) != CLI_OK) { + cli_activation_transaction_finalize_committed_or_fail_stop( + &activation->binary_transaction, + "update_transaction_config_failure_finalize"); + (void)fprintf(stderr, + "error: one or more agent configurations failed; the " + "published update was kept. Review the errors above and " + "rerun update\n"); + return CLI_ACTIVATION_PARTIAL; + } + if (activation->delete_indexes) { + int expected = count_db_indexes(activation->home); + int removed = cbm_remove_indexes(activation->home); + printf("Removed %d index(es).\n\n", removed); + if (removed != expected) { + cli_activation_transaction_finalize_committed_or_fail_stop( + &activation->binary_transaction, + "update_transaction_index_failure_finalize"); + (void)fprintf(stderr, + "error: only %d of %d indexes could be removed\n", + removed, expected); + return CLI_ACTIVATION_PARTIAL; + } + } + cli_activation_transaction_finalize_committed_or_fail_stop( + &activation->binary_transaction, "update_transaction_finalize"); + return CLI_OK; +} + static int extract_and_install_binary(extract_install_args_t args) { const char *tmp_archive = args.tmp_archive; const char *ext = args.ext; @@ -8868,9 +10498,17 @@ static int extract_and_install_binary(extract_install_args_t args) { (void)fprintf(stderr, "error: cannot open %s\n", tmp_archive); return CLI_TRUE; } - (void)fseek(f, 0, SEEK_END); + if (fseek(f, 0, SEEK_END) != 0) { + (void)fclose(f); + cbm_unlink(tmp_archive); + return CLI_TRUE; + } long fsize = ftell(f); - (void)fseek(f, 0, SEEK_SET); + if (fsize <= 0 || fsize > INT_MAX || fseek(f, 0, SEEK_SET) != 0) { + (void)fclose(f); + cbm_unlink(tmp_archive); + return CLI_TRUE; + } unsigned char *data = malloc((size_t)fsize); if (!data) { @@ -8878,8 +10516,13 @@ static int extract_and_install_binary(extract_install_args_t args) { cbm_unlink(tmp_archive); return CLI_TRUE; } - (void)fread(data, CLI_ELEM_SIZE, (size_t)fsize, f); - (void)fclose(f); + size_t bytes_read = fread(data, CLI_ELEM_SIZE, (size_t)fsize, f); + int close_status = fclose(f); + if (bytes_read != (size_t)fsize || close_status != 0) { + free(data); + cbm_unlink(tmp_archive); + return CLI_TRUE; + } int bin_len = 0; unsigned char *bin_data = NULL; @@ -8897,13 +10540,131 @@ static int extract_and_install_binary(extract_install_args_t args) { return CLI_TRUE; } - if (cbm_replace_binary(bin_dest, bin_data, bin_len, CLI_OCTAL_PERM) != 0) { - (void)fprintf(stderr, "error: cannot write to %s\n", bin_dest); - free(bin_data); + cbm_activation_transaction_t *binary_transaction = NULL; + cbm_activation_transaction_status_t stage_status; + cli_binary_validator_t validator = {{0}}; +#ifdef __APPLE__ + /* codesign replaces the signed file on current macOS releases. Publish and + * sign a disposable private copy first, then stage the resulting immutable + * bytes into the final transaction. */ + char prepared_dir[CLI_BUF_1K]; + char prepared_candidate[CLI_BUF_1K]; + int prepared_dir_length = snprintf( + prepared_dir, sizeof(prepared_dir), "%s/cbm-update-sign-XXXXXX", + cbm_tmpdir()); + bool prepared = + prepared_dir_length > 0 && + (size_t)prepared_dir_length < sizeof(prepared_dir) && + cbm_mkdtemp(prepared_dir) != NULL; + int prepared_candidate_length = + prepared + ? snprintf(prepared_candidate, sizeof(prepared_candidate), + "%s/codebase-memory-mcp", prepared_dir) + : CLI_ERR; + prepared = prepared && prepared_candidate_length > 0 && + (size_t)prepared_candidate_length < sizeof(prepared_candidate); + cbm_activation_transaction_t *preparation = NULL; + stage_status = + prepared + ? cbm_activation_transaction_stage_bytes( + prepared_candidate, bin_data, (size_t)bin_len, &preparation) + : CBM_ACTIVATION_TRANSACTION_IO; + free(bin_data); + cli_binary_validator_t unsigned_validator = {{0}}; + prepared = stage_status == CBM_ACTIVATION_TRANSACTION_OK && preparation && + cli_activation_transaction_expected_build( + preparation, &unsigned_validator) && + cli_activation_transaction_commit_validated( + preparation, &unsigned_validator, CLI_OCTAL_PERM) == CLI_OK && + cli_activation_transaction_finalize_close(&preparation) == CLI_OK && + cbm_macos_adhoc_sign(prepared_candidate) == CLI_OK && + cbm_daemon_build_fingerprint_file( + prepared_candidate, validator.fingerprint); + const char *prepared_argv[] = { + prepared_candidate, "--version", NULL}; + prepared = prepared && cbm_exec_no_shell(prepared_argv) == CLI_OK; + if (prepared) { + stage_status = cbm_activation_transaction_stage_file( + bin_dest, prepared_candidate, &binary_transaction); + cli_binary_validator_t staged_validator = {{0}}; + prepared = stage_status == CBM_ACTIVATION_TRANSACTION_OK && + binary_transaction && + cli_activation_transaction_expected_build( + binary_transaction, &staged_validator) && + strcmp(staged_validator.fingerprint, + validator.fingerprint) == 0; + if (prepared) { + validator = staged_validator; + } + } + (void)cli_activation_transaction_abort(&preparation); + if (prepared_candidate_length > 0 && + (size_t)prepared_candidate_length < sizeof(prepared_candidate)) { + (void)cbm_unlink(prepared_candidate); + } + if (prepared_dir_length > 0 && + (size_t)prepared_dir_length < sizeof(prepared_dir)) { + (void)cbm_rmdir(prepared_dir); + } + if (!prepared) { + (void)fprintf(stderr, + "error: signed update candidate preparation failed\n"); + (void)cli_activation_transaction_abort(&binary_transaction); return CLI_TRUE; } +#else + stage_status = cbm_activation_transaction_stage_bytes( + bin_dest, bin_data, (size_t)bin_len, &binary_transaction); free(bin_data); - return 0; + if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || + !binary_transaction || + !cli_activation_transaction_expected_build(binary_transaction, + &validator)) { + (void)fprintf(stderr, "error: failed to stage verified update: %s\n", + cbm_activation_transaction_status_message(stage_status)); + (void)cli_activation_transaction_abort(&binary_transaction); + return CLI_TRUE; + } +#ifndef _WIN32 + const char *staged = + cbm_activation_transaction_staged_path(binary_transaction); + const char *candidate_argv[] = {staged, "--version", NULL}; + if (!staged || cbm_exec_no_shell(candidate_argv) != 0) { + (void)fprintf(stderr, + "error: staged update candidate failed its execution check\n"); + (void)cli_activation_transaction_abort(&binary_transaction); + return CLI_TRUE; + } +#endif +#endif +#ifdef _WIN32 + char self_path[CLI_BUF_1K] = {0}; + cbm_detect_self_path(self_path, sizeof(self_path), args.home); + if (cbm_same_file(self_path, bin_dest)) { + (void)fprintf( + stderr, + "error: Windows cannot atomically replace the executable running " + "this update. Re-run update from a verified candidate copy; no " + "CBM sessions were stopped.\n"); + (void)cli_activation_transaction_abort(&binary_transaction); + return CLI_TRUE; + } +#endif + cli_update_activation_t activation = { + .bin_dest = bin_dest, + .home = args.home, + .binary_transaction = binary_transaction, + .binary_validator = validator, + .delete_indexes = args.delete_indexes, + }; + int activation_rc = cli_activation_guard( + CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE, NULL, + validator.fingerprint, cli_update_activate_binary, &activation); + if (activation.binary_transaction) { + (void)cli_activation_transaction_abort( + &activation.binary_transaction); + } + return activation_rc == CLI_OK ? CLI_OK : CLI_TRUE; } /* Build the download URL for the update command. */ @@ -8924,8 +10685,14 @@ static void build_update_url(char *url, int url_sz, const char *os, const char * arch, portable, ext); } -/* Prompt to delete existing indexes. Returns 0 to continue, 1 to abort. */ -static int update_clear_indexes(const char *home, bool dry_run) { +/* Confirm index deletion before network I/O, but defer the deletion itself to + * the final guarded activation after the verified binary is ready. Returns 0 + * to continue and 1 to abort. */ +static int update_prepare_clear_indexes(const char *home, bool dry_run, + bool *delete_indexes_out) { + if (delete_indexes_out) { + *delete_indexes_out = false; + } int index_count = count_db_indexes(home); if (index_count == 0) { return 0; @@ -8941,16 +10708,37 @@ static int update_clear_indexes(const char *home, bool dry_run) { printf("Update cancelled.\n"); return CLI_TRUE; } - int removed = cbm_remove_indexes(home); - printf("Removed %d index(es).\n\n", removed); + if (delete_indexes_out) { + *delete_indexes_out = true; + } return 0; } -/* Download, verify checksum, kill old instances, and install binary. Returns 0 on success. */ +/* Download and verify before disruption, then activate under daemon locks. */ static int download_verify_install(const char *url, const char *ext, const char *os, - const char *arch, bool want_ui, const char *bin_dest) { + const char *arch, bool want_ui, const char *bin_dest, + const char *home, bool delete_indexes) { char tmp_archive[CLI_BUF_256]; - snprintf(tmp_archive, sizeof(tmp_archive), "%s/cbm-update.%s", cbm_tmpdir(), ext); + int archive_path_length = snprintf( + tmp_archive, sizeof(tmp_archive), "%s/cbm-update-XXXXXX", + cbm_tmpdir()); + if (archive_path_length <= 0 || + (size_t)archive_path_length >= sizeof(tmp_archive)) { + return CLI_TRUE; + } + int archive_descriptor = cbm_mkstemp(tmp_archive); + if (archive_descriptor < 0) { + return CLI_TRUE; + } +#ifdef _WIN32 + int archive_close_status = _close(archive_descriptor); +#else + int archive_close_status = close(archive_descriptor); +#endif + if (archive_close_status != 0) { + cbm_unlink(tmp_archive); + return CLI_TRUE; + } int rc = cbm_download_to_file(url, tmp_archive); if (rc != 0) { @@ -8974,12 +10762,13 @@ static int download_verify_install(const char *url, const char *ext, const char return CLI_TRUE; } - int killed = cbm_kill_other_instances(); - if (killed > 0) { - printf("Stopped %d running MCP server instance(s).\n", killed); - } - - if (extract_and_install_binary((extract_install_args_t){tmp_archive, ext, bin_dest}) != 0) { + if (extract_and_install_binary((extract_install_args_t){ + .tmp_archive = tmp_archive, + .ext = ext, + .bin_dest = bin_dest, + .home = home, + .delete_indexes = delete_indexes, + }) != 0) { return CLI_TRUE; } return 0; @@ -9102,6 +10891,13 @@ int cbm_cmd_update(int argc, char **argv) { variant_flag = VARIANT_B; } else if (strcmp(argv[i], "--force") == 0) { force = true; + } else if (strcmp(argv[i], "-y") != 0 && + strcmp(argv[i], "--yes") != 0 && + strcmp(argv[i], "-n") != 0 && + strcmp(argv[i], "--no") != 0) { + (void)fprintf(stderr, "error: unknown update option: %s\n", + argv[i]); + return CLI_TRUE; } } @@ -9119,7 +10915,8 @@ int cbm_cmd_update(int argc, char **argv) { } /* Step 1: Check for existing indexes */ - if (update_clear_indexes(home, dry_run) != 0) { + bool delete_indexes = false; + if (update_prepare_clear_indexes(home, dry_run, &delete_indexes) != 0) { return CLI_TRUE; } @@ -9165,30 +10962,20 @@ int cbm_cmd_update(int argc, char **argv) { #endif char bin_dir[CLI_BUF_1K]; snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", home); - cbm_mkdir_p(bin_dir, CLI_OCTAL_PERM); - - int rc = download_verify_install(url, ext, os, arch, want_ui, bin_dest); - if (rc != 0) { + if (!cbm_mkdir_p(bin_dir, CLI_OCTAL_PERM)) { + (void)fprintf(stderr, "error: cannot prepare update directory %s\n", + bin_dir); return CLI_TRUE; } - /* Step 5b: macOS ad-hoc signing (required for arm64, harmless for x86_64) */ -#ifdef __APPLE__ - if (cbm_macos_adhoc_sign(bin_dest) != 0) { - (void)fprintf(stderr, - "warning: ad-hoc signing failed — binary may not run on macOS arm64\n"); - } -#endif - - /* Step 6: Refresh all agent configs (skills, MCP entries, hooks) */ - printf("Refreshing agent configurations...\n"); - if (cbm_install_agent_configs(home, bin_dest, true, false) != CLI_OK) { - (void)fprintf(stderr, "error: binary updated, but one or more agent configurations failed; " - "review the errors above and rerun update\n"); + int rc = download_verify_install(url, ext, os, arch, want_ui, bin_dest, home, + delete_indexes); + if (rc != 0) { return CLI_TRUE; } - /* Step 7: Verify new version (exec directly, no shell interpretation) */ + /* Step 6: Agent configs were refreshed inside the protected activation + * callback. Verify the new version only after that complete mutation. */ printf("\nUpdate complete. Verifying:\n"); { const char *ver_argv[] = {bin_dest, "--version", NULL}; @@ -9197,7 +10984,8 @@ int cbm_cmd_update(int argc, char **argv) { printf("\nAll project indexes were cleared. They will be rebuilt\n"); printf("automatically when you next use the MCP server.\n"); - printf("\nPlease restart your MCP client to use the new binary.\n"); + printf("\nUpdate complete. Please restart your coding-agent sessions to " + "properly take this into account.\n"); (void)variant; return 0; } diff --git a/src/cli/cli.h b/src/cli/cli.h index de1ac04a2..9bb899836 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -12,6 +12,8 @@ #include #include +typedef struct cbm_mcp_server cbm_mcp_server_t; + /* ── Version ──────────────────────────────────────────────────── */ /* Set the version string (called from main). */ @@ -36,6 +38,11 @@ char *cbm_cli_build_args_json(const char *tool_name, int argc, char **argv, char * non-zero (and prints nothing) if it is not. */ int cbm_cli_print_tool_help(const char *tool_name); +/* Inspect a raw MCP tool-result envelope. Returns true only when the root + * object carries the exact boolean field `isError: true`; malformed JSON, + * strings, and nested lookalikes are not tool errors. */ +bool cbm_cli_mcp_result_is_error(const char *result); + /* ── Self-update: version comparison ──────────────────────────── */ /* Compare two semver strings (e.g. "0.2.1" vs "0.2.0"). @@ -345,7 +352,8 @@ cbm_config_t *cbm_config_open(const char *cache_dir); /* Close the config store. */ void cbm_config_close(cbm_config_t *cfg); -/* Get a config value. Returns default_val if key not found. */ +/* Get a config value. Returns default_val if key not found. Database-backed + * results remain valid until the next call on the same thread. */ const char *cbm_config_get(cbm_config_t *cfg, const char *key, const char *default_val); /* Get a config value as bool. "true"/"1"/"on" → true. */ @@ -366,6 +374,53 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key); #define CBM_CONFIG_AUTO_WATCH "auto_watch" #define CBM_CONFIG_UI_LANG "ui-lang" +/* ── Binary activation safety ─────────────────────────────────── */ + +typedef void *cbm_cli_activation_lock_t; +typedef int (*cbm_cli_activation_mutation_fn)(void *context); + +/* High-level mutation reservation seam. Production maps this to the + * crash-safe version-cohort maintenance barrier, which requests bounded + * quiescence of active modern CBM processes before returning an exclusive + * lease. Tests use it to prove that mutation never runs before the cohort has + * drained. Return 1 with a non-NULL lease on success, 0 when participants did + * not drain before the deadline, or -1 on an unsafe/IO failure. */ +typedef int (*cbm_cli_activation_reserve_fn)( + void *context, cbm_cli_activation_lock_t *lease_out); +typedef void (*cbm_cli_activation_release_fn)( + void *context, cbm_cli_activation_lock_t lease); + +/* Injectable high-level boundary for deterministic drain/mutation ordering + * tests. Production never acquires startup while asking participants to + * quiesce: a bootstrap participant may already retain it. Only after lifetime + * EX proves the cohort drained does production acquire startup, re-probe the + * daemon generation, and retain startup through mutation. Startup is released + * before the maintenance lease. */ +typedef struct { + void *context; + cbm_cli_activation_reserve_fn reserve_for_mutation; + cbm_cli_activation_release_fn mutation_lease_release; + void (*visible_diagnostic)(void *context, const char *message); +} cbm_cli_activation_ops_t; + +/* Reserve and drain the coordinated cohort, run the mutation only while its + * lease is retained, then release it on every callback result. A NULL mutation + * is a guarded no-op. Returns 0 on success, 1 when activation is refused, or + * the mutation callback's non-zero error. */ +int cbm_cli_activation_guard_with_ops(const cbm_cli_activation_ops_t *ops, + cbm_cli_activation_mutation_fn mutation, + void *mutation_context); + +/* Internal test seam used only by the C suite to route install/update/uninstall + * through deterministic callbacks. NULL restores the production daemon IPC path. */ +void cbm_cli_set_activation_ops_for_test(const cbm_cli_activation_ops_t *ops); + +/* Internal integration-test seam: isolate the stable endpoint beneath a + * private runtime parent. NULL restores the platform default. This is not a + * command-line or environment override. */ +void cbm_cli_set_activation_runtime_parent_for_test( + const char *runtime_parent); + /* ── Subcommands (wired from main.c) ─────────────────────────── */ /* install: copy binary, install skills, install editor MCP configs, ensure PATH. @@ -398,6 +453,27 @@ char *cbm_hook_augment_lifecycle_json(const char *input); char *cbm_hook_augment_lifecycle_json_for(const char *input, const char *forced_event, bool copilot_dialect); +/* Thin daemon frontend support: preserve the hook's bounded stdin read and + * hard fail-open deadline without constructing a local MCP/store instance. */ +void cbm_hook_augment_arm_deadline(void); +char *cbm_hook_augment_read_stdin(void); + +/* Process one already-read hook payload using a caller-owned MCP session. + * Returns a malloc-owned hook output JSON string, or NULL for fail-open/no + * augmentation. This is the daemon entry; it never arms a process-global + * timer and never constructs a second store/session. */ +char *cbm_hook_augment_process(cbm_mcp_server_t *srv, const char *input_json); + +/* Dialect-aware daemon entry. forced_event and dialect_name are borrowed and + * may be NULL for the ordinary event dialect. Unsupported combinations fail + * open with NULL, matching the direct hook command. */ +bool cbm_hook_augment_invocation_supported(const char *forced_event, + const char *dialect_name); +char *cbm_hook_augment_process_for(cbm_mcp_server_t *srv, + const char *input_json, + const char *forced_event, + const char *dialect_name); + /* True for an absolute path the augmenter can walk up: POSIX "/..." or a * Windows drive root — "X:/..." or a bare "X:" (callers normalize '\\' to '/' * first). Exposed for tests — regression coverage for the Windows drive-letter diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 029c490e8..64b71445a 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -123,7 +123,7 @@ static void ha_open_crumb_log(int deadline_ms) { g_ha_crumb_len = (n > 0 && n < (int)sizeof(g_ha_crumb_msg)) ? (size_t)n : 0; } -static void ha_arm_deadline(void) { +void cbm_hook_augment_arm_deadline(void) { int ms = ha_deadline_ms(); ha_open_crumb_log(ms); @@ -145,7 +145,7 @@ static VOID CALLBACK ha_deadline_exit_windows(PVOID context, BOOLEAN fired) { ExitProcess(0U); } -static void ha_arm_deadline(void) { +void cbm_hook_augment_arm_deadline(void) { HANDLE timer = NULL; (void)CreateTimerQueueTimer(&timer, NULL, ha_deadline_exit_windows, NULL, HA_DEADLINE_MS, 0U, WT_EXECUTEONLYONCE); @@ -154,7 +154,7 @@ static void ha_arm_deadline(void) { /* ── stdin ────────────────────────────────────────────────────────── */ -static char *ha_read_stdin(void) { +char *cbm_hook_augment_read_stdin(void) { char *buf = malloc(HA_STDIN_CAP + 1); if (!buf) { return NULL; @@ -646,14 +646,6 @@ static char *ha_build_cline_json(const char *text) { return json; } -/* Emit one context-only hook response. Never add decisions or tool rewrites. */ -static void ha_emit(const char *event, const char *text) { - char *json = event && text ? ha_build_event_json(event, text) : NULL; - if (json) { - fputs(json, stdout); - free(json); - } -} /* True for an absolute path we can walk up: POSIX "/..." or a Windows drive * root — "X:/..." or a bare "X:" (callers normalize '\\' to '/' first). @@ -876,7 +868,10 @@ static const char *ha_hook_event_name(yyjson_val *root) { return event ? event : ha_obj_str(root, "hookEventName"); } -static const char *ha_normalized_cwd(yyjson_val *root, char *buffer, size_t buffer_size) { +static const char *ha_normalized_cwd_with_server(yyjson_val *root, + cbm_mcp_server_t *srv, + char *buffer, + size_t buffer_size) { const char *cwd = ha_obj_str(root, "cwd"); if (!cwd) { yyjson_val *roots = root ? yyjson_obj_get(root, "workspace_roots") : NULL; @@ -897,6 +892,18 @@ static const char *ha_normalized_cwd(yyjson_val *root, char *buffer, size_t buff return buffer; } } + const char *session_root = srv ? cbm_mcp_server_session_root(srv) : NULL; + if (session_root && strlen(session_root) < buffer_size) { + snprintf(buffer, buffer_size, "%s", session_root); + for (char *cursor = buffer; *cursor; cursor++) { + if (*cursor == '\\') { + *cursor = '/'; + } + } + if (cbm_hook_path_is_abs(buffer)) { + return buffer; + } + } #ifndef _WIN32 return getcwd(buffer, buffer_size) && cbm_hook_path_is_abs(buffer) ? buffer : NULL; #else @@ -904,6 +911,11 @@ static const char *ha_normalized_cwd(yyjson_val *root, char *buffer, size_t buff #endif } +static const char *ha_normalized_cwd(yyjson_val *root, char *buffer, + size_t buffer_size) { + return ha_normalized_cwd_with_server(root, NULL, buffer, buffer_size); +} + static bool ha_lifecycle_event_supported(const char *event) { return event && (strcmp(event, "SessionStart") == 0 || strcmp(event, "SubagentStart") == 0); } @@ -1104,7 +1116,9 @@ static bool ha_invocation_supported(ha_lifecycle_dialect_t dialect, const char * return !forced_event || ha_dialect_event_supported(dialect, forced_event); } -static char *ha_lifecycle_json_from_root(yyjson_val *root, const char *forced_event, +static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, + yyjson_val *root, + const char *forced_event, ha_lifecycle_dialect_t dialect) { if (!root || !yyjson_is_obj(root)) { return NULL; @@ -1115,12 +1129,15 @@ static char *ha_lifecycle_json_from_root(yyjson_val *root, const char *forced_ev } char cwd_buffer[4096]; - const char *cwd = ha_normalized_cwd(root, cwd_buffer, sizeof(cwd_buffer)); - cbm_mcp_server_t *server = cbm_mcp_server_new(NULL); - char *project = server && cwd ? ha_resolve_indexed_project(server, cwd) : NULL; - if (server) { - cbm_mcp_server_free(server); + cbm_mcp_server_t *owned_server = NULL; + if (!srv) { + owned_server = cbm_mcp_server_new(NULL); + srv = owned_server; } + const char *cwd = ha_normalized_cwd_with_server( + root, srv, cwd_buffer, sizeof(cwd_buffer)); + char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; + cbm_mcp_server_free(owned_server); char context[2048]; const char *scope = "Session"; @@ -1198,7 +1215,8 @@ char *cbm_hook_augment_lifecycle_json_for(const char *input, const char *forced_ return NULL; } ha_lifecycle_dialect_t dialect = copilot_dialect ? HA_DIALECT_COPILOT : HA_DIALECT_EVENT; - char *json = ha_lifecycle_json_from_root(yyjson_doc_get_root(doc), forced_event, dialect); + char *json = ha_lifecycle_json_from_root( + NULL, yyjson_doc_get_root(doc), forced_event, dialect); yyjson_doc_free(doc); return json; } @@ -1217,7 +1235,8 @@ char *cbm_hook_augment_lifecycle_json_for_dialect(const char *input, const char if (!doc) { return NULL; } - char *json = ha_lifecycle_json_from_root(yyjson_doc_get_root(doc), forced_event, dialect); + char *json = ha_lifecycle_json_from_root( + NULL, yyjson_doc_get_root(doc), forced_event, dialect); yyjson_doc_free(doc); return json; } @@ -1268,53 +1287,33 @@ const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) { } #endif -int cbm_cmd_hook_augment(int argc, char **argv) { - ha_arm_deadline(); - - const char *forced_event = NULL; - ha_lifecycle_dialect_t dialect = HA_DIALECT_EVENT; - for (int i = 0; i < argc; i++) { - if (strcmp(argv[i], "--event") == 0 && i + 1 < argc) { - forced_event = argv[++i]; - } else if (strcmp(argv[i], "--dialect") == 0 && i + 1 < argc && - ha_dialect_from_name(argv[i + 1], &dialect)) { - i++; - } else { - return 0; - } - } - /* Copilot omits the event in stdin and therefore requires --event. Other - * forced events must be documented lifecycle events for their dialect. */ - if (!ha_invocation_supported(dialect, forced_event)) { - return 0; +static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, + const char *forced_event, + ha_lifecycle_dialect_t dialect) { + if (!srv || !input_json) { + return NULL; } - - char *input = ha_read_stdin(); - if (!input) { - return 0; + if (strlen(input_json) > HA_STDIN_CAP) { + return NULL; } - yyjson_doc *doc = yyjson_read(input, strlen(input), 0); + yyjson_doc *doc = yyjson_read(input_json, strlen(input_json), 0); if (!doc) { - free(input); - return 0; + return NULL; } yyjson_val *root = yyjson_doc_get_root(doc); - char *lifecycle = ha_lifecycle_json_from_root(root, forced_event, dialect); + char *lifecycle = + ha_lifecycle_json_from_root(srv, root, forced_event, dialect); if (lifecycle) { - fputs(lifecycle, stdout); - free(lifecycle); yyjson_doc_free(doc); - free(input); - return 0; + return lifecycle; } const char *event = ha_hook_event_name(root); const char *tool = ha_obj_str(root, "tool_name"); bool coverage = false; if (!ha_tool_event_supported(dialect, event, tool, &coverage)) { yyjson_doc_free(doc); - free(input); - return 0; + return NULL; } yyjson_val *tin = yyjson_obj_get(root, "tool_input"); @@ -1324,76 +1323,102 @@ int cbm_cmd_hook_augment(int argc, char **argv) { if (coverage) { char fpbuf[4096]; if (ha_normalized_tool_path(root, tin, fpbuf, sizeof(fpbuf))) { - cbm_mcp_server_t *rsrv = cbm_mcp_server_new(NULL); - if (rsrv) { - char *note = ha_resolve_coverage(rsrv, fpbuf); - if (note) { - ha_emit(event, note); - free(note); - } - cbm_mcp_server_free(rsrv); + char *note = ha_resolve_coverage(srv, fpbuf); + if (note) { + char *output = ha_build_event_json(event, note); + free(note); + yyjson_doc_free(doc); + return output; } } yyjson_doc_free(doc); - free(input); - return 0; + return NULL; } const char *pattern = ha_obj_str(tin, "pattern"); char token[HA_MAX_TOKEN + 1]; if (!ha_extract_token(pattern, token, sizeof(token))) { yyjson_doc_free(doc); - free(input); - return 0; + return NULL; } - const char *cwd = ha_obj_str(root, "cwd"); char cwdbuf[4096]; -#ifndef _WIN32 - if (!cwd || !cbm_hook_path_is_abs(cwd)) { - if (!getcwd(cwdbuf, sizeof(cwdbuf))) { - yyjson_doc_free(doc); - free(input); - return 0; - } - cwd = cwdbuf; + const char *cwd = ha_normalized_cwd_with_server( + root, srv, cwdbuf, sizeof(cwdbuf)); + if (!cwd) { + yyjson_doc_free(doc); + return NULL; } -#else - /* Windows: Claude Code passes an absolute drive-letter cwd in the hook - * payload (e.g. C:\repo). Normalize '\\' -> '/' and require an absolute - * path; the walk-up loop handles POSIX and "X:/..." roots alike. Without - * a usable cwd there is nothing to augment — fail open cleanly. */ - if (cwd) { - snprintf(cwdbuf, sizeof(cwdbuf), "%s", cwd); - for (char *p = cwdbuf; *p; p++) { - if (*p == '\\') { - *p = '/'; - } + + char *ctx = ha_resolve_and_query(srv, cwd, token); + char *output = ctx ? ha_build_event_json(event, ctx) : NULL; + free(ctx); + yyjson_doc_free(doc); + return output; +} + +char *cbm_hook_augment_process(cbm_mcp_server_t *srv, + const char *input_json) { + return cbm_hook_augment_process_for(srv, input_json, NULL, NULL); +} + +bool cbm_hook_augment_invocation_supported(const char *forced_event, + const char *dialect_name) { + ha_lifecycle_dialect_t dialect = HA_DIALECT_EVENT; + if (dialect_name && dialect_name[0] && + !ha_dialect_from_name(dialect_name, &dialect)) { + return false; + } + return ha_invocation_supported(dialect, forced_event); +} + +char *cbm_hook_augment_process_for(cbm_mcp_server_t *srv, + const char *input_json, + const char *forced_event, + const char *dialect_name) { + ha_lifecycle_dialect_t dialect = HA_DIALECT_EVENT; + if (!srv || !input_json || + (dialect_name && dialect_name[0] && + !ha_dialect_from_name(dialect_name, &dialect)) || + !ha_invocation_supported(dialect, forced_event)) { + return NULL; + } + return ha_process(srv, input_json, forced_event, dialect); +} + +int cbm_cmd_hook_augment(int argc, char **argv) { + cbm_hook_augment_arm_deadline(); + + const char *forced_event = NULL; + ha_lifecycle_dialect_t dialect = HA_DIALECT_EVENT; + for (int i = 0; i < argc; i++) { + if (strcmp(argv[i], "--event") == 0 && i + 1 < argc) { + forced_event = argv[++i]; + } else if (strcmp(argv[i], "--dialect") == 0 && i + 1 < argc && + ha_dialect_from_name(argv[i + 1], &dialect)) { + i++; + } else { + return 0; } - cwd = cwdbuf; } - if (!cwd || !cbm_hook_path_is_abs(cwd)) { - yyjson_doc_free(doc); - free(input); + /* Copilot omits the event in stdin and therefore requires --event. Other + * forced events must be documented lifecycle events for their dialect. */ + if (!ha_invocation_supported(dialect, forced_event)) { return 0; } -#endif - cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - if (!srv) { - yyjson_doc_free(doc); - free(input); + char *input = cbm_hook_augment_read_stdin(); + if (!input) { return 0; } - - char *ctx = ha_resolve_and_query(srv, cwd, token); - if (ctx) { - ha_emit(event, ctx); - free(ctx); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + char *output = + srv ? ha_process(srv, input, forced_event, dialect) : NULL; + if (output) { + fputs(output, stdout); } - + free(output); cbm_mcp_server_free(srv); - yyjson_doc_free(doc); free(input); return 0; } diff --git a/src/cli/progress_sink.c b/src/cli/progress_sink.c index 231277373..97279332d 100644 --- a/src/cli/progress_sink.c +++ b/src/cli/progress_sink.c @@ -1,12 +1,16 @@ /* - * progress_sink.c — Human-readable progress for --progress CLI flag. + * progress_sink.c — Human-readable progress for one-shot CLI commands. * * Maps structured log events (msg=pass.timing, msg=pipeline.done, etc.) - * to phase labels on stderr. When installed, replaces default log output. + * to phase labels on stderr. Interactive terminals enable it automatically; + * --progress forces it for redirected stderr. When installed, it replaces + * default log output. * - * Thread-safe: fprintf has per-FILE* locking on POSIX. + * Thread-safe: one sink mutex serializes shared summary state and each complete + * output update, not merely the individual stdio calls. */ #include "progress_sink.h" +#include "foundation/compat_thread.h" #include "foundation/constants.h" #include "foundation/log.h" @@ -15,14 +19,147 @@ #include #include -enum { PERCENT = 100, NOT_SET = -1 }; +enum { + PERCENT = 100, + NOT_SET = -1, + LOCK_UNINITIALIZED = 0, + LOCK_INITIALIZING = 1, + LOCK_READY = 2, +}; static FILE *s_out; static atomic_int s_needs_newline; static int s_gbuf_nodes = NOT_SET; static int s_gbuf_edges = NOT_SET; +static cbm_mutex_t s_sink_mutex; +static atomic_int s_sink_mutex_state = ATOMIC_VAR_INIT(LOCK_UNINITIALIZED); + +/* The CLI may install the sink more than once in one process (notably tests), + * so keep one process-lifetime mutex rather than destroying it at fini. */ +static void progress_sink_mutex_ensure(void) { + int expected = LOCK_UNINITIALIZED; + if (atomic_compare_exchange_strong_explicit( + &s_sink_mutex_state, &expected, LOCK_INITIALIZING, + memory_order_acq_rel, memory_order_acquire)) { + cbm_mutex_init(&s_sink_mutex); + atomic_store_explicit(&s_sink_mutex_state, LOCK_READY, + memory_order_release); + return; + } + while (atomic_load_explicit(&s_sink_mutex_state, memory_order_acquire) != + LOCK_READY) { + } +} + +bool cbm_cli_progress_enabled(bool explicitly_requested, bool stderr_is_tty) { + return explicitly_requested || stderr_is_tty; +} + +static void progress_tool_name(const char *tool_name, char out[CBM_SZ_64]) { + size_t offset = 0; + if (tool_name) { + for (; tool_name[offset] && offset + 1 < CBM_SZ_64; offset++) { + unsigned char ch = (unsigned char)tool_name[offset]; + bool safe = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || ch == '_' || ch == '-'; + if (!safe) { + offset = 0; + break; + } + out[offset] = (char)ch; + } + } + if (offset == 0) { + (void)snprintf(out, CBM_SZ_64, "%s", "tool"); + } else { + out[offset] = '\0'; + } +} + +void cbm_cli_progress_start(FILE *out, const char *tool_name) { + FILE *stream = out ? out : stderr; + char safe_name[CBM_SZ_64] = {0}; + progress_tool_name(tool_name, safe_name); + (void)fprintf(stream, "Running %s locally...\n", safe_name); + (void)fflush(stream); +} + +void cbm_cli_progress_finish(FILE *out, const char *tool_name, bool success, + uint64_t elapsed_ms) { + FILE *stream = out ? out : stderr; + char safe_name[CBM_SZ_64] = {0}; + progress_tool_name(tool_name, safe_name); + (void)fprintf(stream, "%s %s (%llu ms)\n", success ? "Completed" : "Failed", + safe_name, (unsigned long long)elapsed_ms); + (void)fflush(stream); +} -/* Extract value of "key=VALUE" from a structured log line. */ +/* Extract one string field from the logger's compact JSON format. Keys are + * accepted only at object boundaries so worker-controlled values cannot spoof + * a progress event by merely containing a key-shaped substring. */ +static const char *extract_json_field(const char *line, const char *key, char *buf, + int buf_len) { + char needle[CBM_SZ_64]; + int needle_len = snprintf(needle, sizeof(needle), "\"%s\":", key); + if (needle_len <= 0 || needle_len >= (int)sizeof(needle)) { + return NULL; + } + const char *candidate = line; + while ((candidate = strstr(candidate, needle)) != NULL) { + const char *before = candidate; + while (before > line && (before[-1] == ' ' || before[-1] == '\t')) { + before--; + } + if (before == line || (before[-1] != '{' && before[-1] != ',')) { + candidate += needle_len; + continue; + } + const char *value = candidate + needle_len; + while (*value == ' ' || *value == '\t') { + value++; + } + if (*value++ != '\"') { + return NULL; + } + int offset = 0; + bool escaped = false; + while (*value && offset < buf_len - 1) { + if (!escaped && *value == '\"') { + buf[offset] = '\0'; + return buf; + } + if (!escaped && *value == '\\') { + escaped = true; + value++; + continue; + } + if (escaped) { + switch (*value) { + case 'n': + buf[offset++] = '\n'; + break; + case 'r': + buf[offset++] = '\r'; + break; + case 't': + buf[offset++] = '\t'; + break; + default: + buf[offset++] = *value; + break; + } + escaped = false; + } else { + buf[offset++] = *value; + } + value++; + } + return NULL; + } + return NULL; +} + +/* Extract a field from either structured text or JSON worker logs. */ static const char *extract_kv(const char *line, const char *key, char *buf, int buf_len) { if (!line || !key || !buf || buf_len <= 0) { return NULL; @@ -42,24 +179,31 @@ static const char *extract_kv(const char *line, const char *key, char *buf, int } p++; } - return NULL; + const char *json_key = strcmp(key, "msg") == 0 ? "event" : key; + return extract_json_field(line, json_key, buf, buf_len); } void cbm_progress_sink_init(FILE *out) { + progress_sink_mutex_ensure(); + cbm_mutex_lock(&s_sink_mutex); s_out = out ? out : stderr; atomic_store(&s_needs_newline, 0); s_gbuf_nodes = NOT_SET; s_gbuf_edges = NOT_SET; cbm_log_set_sink(cbm_progress_sink_fn); + cbm_mutex_unlock(&s_sink_mutex); } void cbm_progress_sink_fini(void) { + progress_sink_mutex_ensure(); + cbm_mutex_lock(&s_sink_mutex); + cbm_log_set_sink(NULL); if (atomic_load(&s_needs_newline) && s_out) { (void)fprintf(s_out, "\n"); (void)fflush(s_out); } - cbm_log_set_sink(NULL); s_out = NULL; + cbm_mutex_unlock(&s_sink_mutex); } /* Phase label table: maps pass names to display labels. */ @@ -201,17 +345,23 @@ static const event_handler_t handlers[] = { enum { HANDLER_COUNT = sizeof(handlers) / sizeof(handlers[0]) }; void cbm_progress_sink_fn(const char *line) { + progress_sink_mutex_ensure(); + cbm_mutex_lock(&s_sink_mutex); if (!line || !s_out) { + cbm_mutex_unlock(&s_sink_mutex); return; } char msg[CBM_SZ_64] = {0}; if (!extract_kv(line, "msg", msg, (int)sizeof(msg))) { + cbm_mutex_unlock(&s_sink_mutex); return; } for (int i = 0; i < HANDLER_COUNT; i++) { if (strcmp(msg, handlers[i].msg) == 0) { handlers[i].handler(line); + cbm_mutex_unlock(&s_sink_mutex); return; } } + cbm_mutex_unlock(&s_sink_mutex); } diff --git a/src/cli/progress_sink.h b/src/cli/progress_sink.h index 356b7049a..f6c1e9157 100644 --- a/src/cli/progress_sink.h +++ b/src/cli/progress_sink.h @@ -1,7 +1,9 @@ /* - * progress_sink.h — Human-readable progress output for --progress CLI flag. + * progress_sink.h — Human-readable progress for one-shot CLI commands. * * Installs a log sink that maps structured pipeline events to phase labels. + * Interactive terminals enable it automatically; --progress forces it when + * stderr is redirected. * Usage: * cbm_progress_sink_init(stderr); * // ... run pipeline ... @@ -10,8 +12,17 @@ #ifndef CBM_PROGRESS_SINK_H #define CBM_PROGRESS_SINK_H +#include +#include #include +/* Interactive terminals get lifecycle feedback automatically. --progress + * forces the same behavior for redirected stderr without touching stdout. */ +bool cbm_cli_progress_enabled(bool explicitly_requested, bool stderr_is_tty); +void cbm_cli_progress_start(FILE *out, const char *tool_name); +void cbm_cli_progress_finish(FILE *out, const char *tool_name, bool success, + uint64_t elapsed_ms); + void cbm_progress_sink_init(FILE *out); void cbm_progress_sink_fini(void); void cbm_progress_sink_fn(const char *line); diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 64e10a328..200d8d166 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -6,6 +6,7 @@ * RETURN with COUNT/ORDER BY/LIMIT/DISTINCT. */ #include "cypher/cypher.h" +#include "foundation/compat.h" #include "store/store.h" #include "foundation/platform.h" #include "foundation/limits.h" @@ -2338,7 +2339,7 @@ static const char *json_extract_prop(const char *json, const char *key, char *bu return buf; } -/* Get edge property by name. Uses rotating static buffers to allow +/* Get edge property by name. Uses rotating thread-local buffers to allow * multiple concurrent calls (e.g. projecting r.url_path, r.confidence * in the same row). */ static const char *edge_prop(const cbm_edge_t *e, const char *prop) { @@ -2348,9 +2349,9 @@ static const char *edge_prop(const cbm_edge_t *e, const char *prop) { if (strcmp(prop, "type") == 0) { return e->type ? e->type : ""; } - /* Rotate through 8 static buffers so multiple props can be accessed per row */ - static char ebufs[CYP_BUF_8][CBM_SZ_512]; - static int ebuf_idx = 0; + /* Rotate through 8 thread-local buffers so multiple props can be accessed per row. */ + static CBM_TLS char ebufs[CYP_BUF_8][CBM_SZ_512]; + static CBM_TLS int ebuf_idx = 0; char *buf = ebufs[ebuf_idx++ & CYP_EBUF_MASK]; json_extract_prop(e->properties_json, prop, buf, CBM_SZ_512); return buf; diff --git a/src/daemon/application.c b/src/daemon/application.c new file mode 100644 index 000000000..eb554141a --- /dev/null +++ b/src/daemon/application.c @@ -0,0 +1,2666 @@ +/* + * application.c — Daemon-owned CBM application sessions and thin-client wire. + */ +#include "daemon/application.h" +#include "daemon/application_internal.h" + +#include "cli/cli.h" +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/compat_thread.h" +#include "foundation/log.h" +#include "foundation/mem.h" +#include "foundation/platform.h" +#include "mcp/index_supervisor.h" +#include "mcp/mcp.h" +#include "mcp/mcp_internal.h" +#include "pipeline/pipeline.h" +#include "ui/config.h" +#include "watcher/watcher.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#define application_close _close +#else +#include +#define application_close close +#endif + +enum { + APPLICATION_CONTEXT_HEADER_SIZE = 19, + APPLICATION_TOOL_HEADER_SIZE = 5, + APPLICATION_UI_CONFIG_REQUEST_SIZE = 7, + APPLICATION_PATH_CAP = 4096, + APPLICATION_JOB_THREAD_STACK = 256 * 1024, + APPLICATION_JOB_POLL_US = 10000, + APPLICATION_COORDINATION_CLEANUP_MS = 500, + APPLICATION_DEFAULT_PHYSICAL_JOB_LIMIT = 4, + APPLICATION_DEFAULT_MAX_RESTARTS = 100, + APPLICATION_MARKER_MAX_BYTES = 64 * 1024 * 1024, + APPLICATION_MAX_SUSPECTS = 65536, +}; + +typedef struct cbm_daemon_application_watch cbm_daemon_application_watch_t; +typedef struct cbm_daemon_application_session cbm_daemon_application_session_t; +typedef struct cbm_daemon_application_job cbm_daemon_application_job_t; +typedef struct cbm_daemon_application_mutation cbm_daemon_application_mutation_t; +typedef struct cbm_daemon_application_watch_job_subscription + cbm_daemon_application_watch_job_subscription_t; + +struct cbm_daemon_application_watch { + char *project; + char *root; + size_t subscribers; + cbm_daemon_application_watch_t *next; +}; + +struct cbm_daemon_application_session { + cbm_daemon_application_t *application; + cbm_mcp_server_t *mcp; + cbm_daemon_client_id_t client_id; + uint64_t authenticated_process_id; + bool context_set; + cbm_mcp_tool_profile_t tool_profile; + char *hook_event; + char *hook_dialect; + bool session_cancelled; + bool request_active; + cbm_daemon_runtime_application_token_t active_request_token; + cbm_daemon_runtime_application_token_t request_cancel_token; + cbm_daemon_application_watch_t *watch; + cbm_daemon_application_job_t *active_job; + bool active_job_subscribed; + cbm_daemon_application_session_t *next; +}; + +struct cbm_daemon_application_job { + cbm_daemon_application_t *application; + char *project_key; + char *root_path; + char *args_json; + char *response; + cbm_daemon_application_worker_t worker; + cbm_thread_t thread; + size_t subscribers; + size_t watcher_waiters; + bool thread_started; + bool thread_done; + bool terminal; + bool successful; + bool cancelled; + bool cancel_requested; + bool supervision_failed; + cbm_daemon_application_job_t *next; +}; + +/* A watcher-triggered physical job is owned by the exact live sessions that + * currently subscribe to its project/root watch. The callback waiting for the + * job is only a storage waiter; it is deliberately not an ownership + * subscription, so the worker is cancelled when the last matching session + * disconnects even while unrelated daemon sessions remain alive. */ +struct cbm_daemon_application_watch_job_subscription { + cbm_daemon_application_session_t *session; + cbm_daemon_application_job_t *job; + cbm_daemon_application_watch_job_subscription_t *next; +}; + +struct cbm_daemon_application_mutation { + char *project_key; + cbm_project_lock_lease_t *project_lock_lease; + bool releasing; + cbm_daemon_application_mutation_t *next; +}; + +struct cbm_daemon_application { + cbm_mutex_t mutex; + struct cbm_watcher *watcher; + struct cbm_config *config; + cbm_daemon_application_session_t *sessions; + cbm_daemon_application_watch_t *watches; + cbm_daemon_application_job_t *jobs; + cbm_daemon_application_watch_job_subscription_t *watch_job_subscriptions; + cbm_daemon_application_mutation_t *mutations; + cbm_daemon_application_worker_ops_t worker_ops; + cbm_project_lock_manager_t *project_locks; + size_t physical_job_limit; + size_t worker_memory_budget_bytes; + size_t active_mutations; + bool stopping; +}; + +static void application_job_unsubscribe_locked( + cbm_daemon_application_job_t *job); +static void application_watch_job_unsubscribe_session_locked( + cbm_daemon_application_session_t *session); + +static bool application_request_cancelled_locked( + const cbm_daemon_application_session_t *session) { + return session && + (session->session_cancelled || + (session->request_active && + session->active_request_token != + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && + session->request_cancel_token == session->active_request_token)); +} + +static uint64_t application_deadline_after(uint32_t timeout_ms) { + uint64_t now = cbm_now_ms(); + return now > UINT64_MAX - timeout_ms ? UINT64_MAX : now + timeout_ms; +} + +static _Noreturn void application_cleanup_force_terminate( + const char *component) { + /* In production this module is daemon-owned and the host log sink flushes + * every record synchronously. Continuing would either lose the only retry + * handle or falsely make a project mutation appear released. */ + cbm_log_error("daemon.forced_shutdown", "component", component); + (void)fflush(stdout); + (void)fflush(stderr); +#ifdef _WIN32 + (void)TerminateProcess(GetCurrentProcess(), EXIT_FAILURE); + abort(); +#else + _exit(EXIT_FAILURE); +#endif +} + +static void application_project_lock_release_fully( + cbm_project_lock_lease_t **lease) { + uint64_t deadline = + application_deadline_after(APPLICATION_COORDINATION_CLEANUP_MS); + while (lease && *lease) { + (void)cbm_project_lock_lease_release(lease); + if (!*lease) { + return; + } + if (cbm_now_ms() >= deadline) { + application_cleanup_force_terminate("project_lock_cleanup"); + } + cbm_usleep(1000); + } +} + +static int application_worker_start_default(void *context, const char *args_json, + size_t memory_budget_bytes, + const char *marker_file, const char *quarantine_file, + cbm_daemon_application_worker_t *worker_out) { + (void)context; + cbm_index_worker_handle_t *worker = NULL; + int result = cbm_index_worker_start(args_json, memory_budget_bytes, false, marker_file, + quarantine_file, &worker); + *worker_out = worker; + return result; +} + +static cbm_index_worker_poll_t application_worker_poll_default( + void *context, cbm_daemon_application_worker_t worker, + const cbm_index_worker_result_t **result_out) { + (void)context; + return cbm_index_worker_poll((cbm_index_worker_handle_t *)worker, result_out); +} + +static bool application_worker_cancel_default(void *context, + cbm_daemon_application_worker_t worker) { + (void)context; + return cbm_index_worker_request_cancel((cbm_index_worker_handle_t *)worker); +} + +static const char *application_worker_log_path_default(void *context, + cbm_daemon_application_worker_t worker) { + (void)context; + return cbm_index_worker_log_path((cbm_index_worker_handle_t *)worker); +} + +static void application_worker_destroy_default(void *context, + cbm_daemon_application_worker_t worker) { + (void)context; + cbm_index_worker_destroy((cbm_index_worker_handle_t *)worker); +} + +static uint32_t application_get_u32(const uint8_t *bytes) { + return ((uint32_t)bytes[0] << 24) | ((uint32_t)bytes[1] << 16) | ((uint32_t)bytes[2] << 8) | + (uint32_t)bytes[3]; +} + +static void application_put_u32(uint8_t *bytes, uint32_t value) { + bytes[0] = (uint8_t)(value >> 24); + bytes[1] = (uint8_t)(value >> 16); + bytes[2] = (uint8_t)(value >> 8); + bytes[3] = (uint8_t)value; +} + +static char *application_text_copy(const uint8_t *bytes, uint32_t length) { + if (!bytes || length == 0 || memchr(bytes, '\0', length) != NULL) { + return NULL; + } + char *copy = malloc((size_t)length + 1U); + if (copy) { + memcpy(copy, bytes, length); + copy[length] = '\0'; + } + return copy; +} + +static bool application_regular_db_exists(const char *project) { + const char *cache = cbm_resolve_cache_dir(); + if (!cache || !project || !project[0]) { + return false; + } + char path[APPLICATION_PATH_CAP]; + int written = snprintf(path, sizeof(path), "%s/%s.db", cache, project); + if (written <= 0 || (size_t)written >= sizeof(path)) { + return false; + } + struct stat status; + return stat(path, &status) == 0 && S_ISREG(status.st_mode); +} + +static cbm_daemon_application_watch_t *application_find_watch_locked( + cbm_daemon_application_t *application, const char *project) { + for (cbm_daemon_application_watch_t *watch = application->watches; watch; watch = watch->next) { + if (strcmp(watch->project, project) == 0) { + return watch; + } + } + return NULL; +} + +static void application_remove_watch_entry_locked(cbm_daemon_application_t *application, + cbm_daemon_application_watch_t *watch, + bool unregister_physical_watch) { + cbm_daemon_application_watch_t **cursor = &application->watches; + while (*cursor && *cursor != watch) { + cursor = &(*cursor)->next; + } + if (*cursor != watch) { + return; + } + *cursor = watch->next; + for (cbm_daemon_application_session_t *session = application->sessions; session; + session = session->next) { + if (session->watch == watch) { + application_watch_job_unsubscribe_session_locked(session); + session->watch = NULL; + } + } + if (unregister_physical_watch && application->watcher) { + cbm_watcher_unwatch(application->watcher, watch->project); + } + free(watch->project); + free(watch->root); + free(watch); +} + +static void application_remove_watch_locked(cbm_daemon_application_t *application, + cbm_daemon_application_watch_t *watch) { + application_remove_watch_entry_locked(application, watch, true); +} + +static void application_release_session_watch_locked(cbm_daemon_application_session_t *session) { + cbm_daemon_application_watch_t *watch = session->watch; + if (!watch) { + return; + } + application_watch_job_unsubscribe_session_locked(session); + session->watch = NULL; + if (watch->subscribers > 0) { + watch->subscribers--; + } + if (watch->subscribers == 0) { + application_remove_watch_locked(session->application, watch); + } +} + +static void application_refresh_watch(cbm_daemon_application_session_t *session) { + cbm_daemon_application_t *application = session->application; + if (!application->watcher || !session->context_set || + session->tool_profile != CBM_MCP_TOOL_PROFILE_ALL) { + return; + } + const char *project = cbm_mcp_server_session_project(session->mcp); + const char *root = cbm_mcp_server_session_root(session->mcp); + if (!project || !project[0] || !root || !root[0]) { + return; + } + bool enabled = !application->config || + cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true); + bool db_exists = application_regular_db_exists(project); + + cbm_mutex_lock(&application->mutex); + /* Disconnect cancellation is the logical ownership boundary. An + * in-flight request may reach this refresh after cancel returned but + * before runtime can join it and call session_close; it must never + * recreate that session's watch. */ + if (application->stopping || + application_request_cancelled_locked(session)) { + cbm_mutex_unlock(&application->mutex); + return; + } + cbm_daemon_application_watch_t *watch = application_find_watch_locked(application, project); + if (!enabled || !db_exists) { + /* A delete/config transition is global for this project. Remove the + * physical watch and clear every logical subscriber in one step. */ + if (watch) { + application_remove_watch_locked(application, watch); + } + cbm_mutex_unlock(&application->mutex); + return; + } + if (session->watch) { + cbm_mutex_unlock(&application->mutex); + return; + } + if (watch) { + if (strcmp(watch->root, root) == 0) { + watch->subscribers++; + session->watch = watch; + } else { + cbm_log_warn("daemon.watch.project_collision", "project", project, "existing_root", + watch->root, "requested_root", root); + } + cbm_mutex_unlock(&application->mutex); + return; + } + + watch = calloc(1, sizeof(*watch)); + if (watch) { + watch->project = strdup(project); + watch->root = strdup(root); + } + if (!watch || !watch->project || !watch->root) { + if (watch) { + free(watch->project); + free(watch->root); + free(watch); + } + cbm_mutex_unlock(&application->mutex); + return; + } + watch->subscribers = 1; + watch->next = application->watches; + application->watches = watch; + session->watch = watch; + cbm_watcher_watch(application->watcher, project, root); + cbm_mutex_unlock(&application->mutex); +} + +static void application_job_free(cbm_daemon_application_job_t *job) { + if (!job) { + return; + } + free(job->project_key); + free(job->root_path); + free(job->args_json); + free(job->response); + free(job); +} + +/* Reap completed job threads only after every logical demand subscription and + * watcher callback storage waiter has released the job. Exactly one caller + * removes a job under the mutex. */ +static void application_jobs_reap_completed(cbm_daemon_application_t *application) { + for (;;) { + cbm_daemon_application_job_t *reap = NULL; + cbm_mutex_lock(&application->mutex); + cbm_daemon_application_job_t **cursor = &application->jobs; + while (*cursor) { + if ((*cursor)->thread_done && (*cursor)->subscribers == 0 && + (*cursor)->watcher_waiters == 0) { + reap = *cursor; + *cursor = reap->next; + reap->next = NULL; + break; + } + cursor = &(*cursor)->next; + } + cbm_mutex_unlock(&application->mutex); + if (!reap) { + return; + } + if (reap->thread_started) { + (void)cbm_thread_join(&reap->thread); + } + application_job_free(reap); + } +} + +typedef enum { + APPLICATION_ATTEMPT_TERMINAL = 0, + APPLICATION_ATTEMPT_START_FAILED, + APPLICATION_ATTEMPT_CANCELLED, +} application_attempt_status_t; + +typedef struct { + cbm_index_worker_result_t result; + bool has_result; + char log_path[APPLICATION_PATH_CAP]; +} application_attempt_t; + +static atomic_flag g_application_tmp_lock = ATOMIC_FLAG_INIT; + +static void application_tmp_lock(void) { + while (atomic_flag_test_and_set_explicit(&g_application_tmp_lock, memory_order_acquire)) { + cbm_usleep(1000); + } +} + +static void application_tmp_unlock(void) { + atomic_flag_clear_explicit(&g_application_tmp_lock, memory_order_release); +} + +static bool application_cache_dir(char out[APPLICATION_PATH_CAP]) { + char configured[APPLICATION_PATH_CAP] = {0}; + if (cbm_safe_getenv("CBM_CACHE_DIR", configured, sizeof(configured), NULL) && configured[0]) { + int written = snprintf(out, APPLICATION_PATH_CAP, "%s", configured); + if (written <= 0 || written >= APPLICATION_PATH_CAP) { + return false; + } + cbm_normalize_path_sep(out); + return true; + } + char home[APPLICATION_PATH_CAP] = {0}; + if (!cbm_safe_getenv("HOME", home, sizeof(home), NULL) || !home[0]) { + (void)cbm_safe_getenv("USERPROFILE", home, sizeof(home), NULL); + } + if (!home[0]) { + return false; + } + int written = snprintf(out, APPLICATION_PATH_CAP, "%s/.cache/codebase-memory-mcp", home); + if (written <= 0 || written >= APPLICATION_PATH_CAP) { + return false; + } + cbm_normalize_path_sep(out); + return true; +} + +static bool application_unique_recovery_file(char out[APPLICATION_PATH_CAP], const char *kind) { + char cache[APPLICATION_PATH_CAP] = {0}; + char directory[APPLICATION_PATH_CAP] = {0}; + int written; + if (application_cache_dir(cache)) { + written = snprintf(directory, sizeof(directory), "%s/logs", cache); + if (written <= 0 || written >= (int)sizeof(directory) || !cbm_mkdir_p(directory, 0700)) { + return false; + } + } else { + written = snprintf(directory, sizeof(directory), "%s", cbm_tmpdir()); + if (written <= 0 || written >= (int)sizeof(directory)) { + return false; + } + } + written = snprintf(out, APPLICATION_PATH_CAP, "%s/.daemon-index-%s-XXXXXX", directory, kind); + if (written <= 0 || written >= APPLICATION_PATH_CAP) { + out[0] = '\0'; + return false; + } + application_tmp_lock(); + int descriptor = cbm_mkstemp(out); + application_tmp_unlock(); + if (descriptor < 0) { + out[0] = '\0'; + return false; + } + (void)application_close(descriptor); + return true; +} + +static bool application_recovery_files_create(char marker_path[APPLICATION_PATH_CAP], + char quarantine_path[APPLICATION_PATH_CAP]) { + if (!application_unique_recovery_file(marker_path, "marker")) { + return false; + } + if (!application_unique_recovery_file(quarantine_path, "quarantine")) { + (void)cbm_unlink(marker_path); + marker_path[0] = '\0'; + return false; + } + return true; +} + +static void application_recovery_files_remove(const char *marker_path, + const char *quarantine_path) { + if (marker_path && marker_path[0]) { + (void)cbm_unlink(marker_path); + } + if (quarantine_path && quarantine_path[0]) { + (void)cbm_unlink(quarantine_path); + } +} + +static bool application_truncate_file(const char *path) { + FILE *file = cbm_fopen(path, "wb"); + return file && fclose(file) == 0; +} + +static bool application_job_cancel_requested(cbm_daemon_application_job_t *job); + +static char **application_read_suspects(cbm_daemon_application_job_t *job, const char *path, + int *count_out, bool *cancelled_out) { + *count_out = 0; + *cancelled_out = false; + int64_t marker_size = cbm_file_size(path); + if (marker_size < 0 || marker_size > APPLICATION_MARKER_MAX_BYTES) { + return NULL; + } + FILE *file = cbm_fopen(path, "rb"); + if (!file) { + return NULL; + } + char **open_paths = NULL; + int open_count = 0; + int open_capacity = 0; + bool allocation_failed = false; + size_t lines_read = 0; + char line[APPLICATION_PATH_CAP]; + while (fgets(line, sizeof(line), file)) { + if ((lines_read++ & 255U) == 0U && application_job_cancel_requested(job)) { + *cancelled_out = true; + allocation_failed = true; + break; + } + size_t length = strlen(line); + if (length == 0 || line[length - 1] != '\n') { + break; + } + line[--length] = '\0'; + if (length > 0 && line[length - 1] == '\r') { + line[--length] = '\0'; + } + if (length < 3 || (line[0] != 'S' && line[0] != 'D') || line[1] != ' ') { + continue; + } + const char *relative = line + 2; + if (line[0] == 'S') { + if (open_count >= APPLICATION_MAX_SUSPECTS) { + allocation_failed = true; + break; + } + bool already_open = false; + for (int i = 0; i < open_count && !already_open; i++) { + already_open = strcmp(open_paths[i], relative) == 0; + } + if (already_open) { + continue; + } + if (open_count == open_capacity) { + int next_capacity = open_capacity ? open_capacity * 2 : 16; + char **next = realloc(open_paths, (size_t)next_capacity * sizeof(*next)); + if (!next) { + allocation_failed = true; + break; + } + open_paths = next; + open_capacity = next_capacity; + } + char *copy = cbm_strdup(relative); + if (!copy) { + allocation_failed = true; + break; + } + open_paths[open_count++] = copy; + } else { + for (int i = 0; i < open_count; i++) { + if (strcmp(open_paths[i], relative) == 0) { + free(open_paths[i]); + memmove(&open_paths[i], &open_paths[i + 1], + (size_t)(open_count - i - 1) * sizeof(*open_paths)); + open_count--; + break; + } + } + } + } + (void)fclose(file); + if (allocation_failed) { + for (int i = 0; i < open_count; i++) { + free(open_paths[i]); + } + free(open_paths); + return NULL; + } + if (open_count == 0) { + free(open_paths); + return NULL; + } + *count_out = open_count; + return open_paths; +} + +static void application_free_suspects(char **suspects, int count) { + if (!suspects) { + return; + } + for (int i = 0; i < count; i++) { + free(suspects[i]); + } + free(suspects); +} + +static bool application_suspect_contains(char **suspects, int count, const char *relative) { + for (int i = 0; i < count; i++) { + if (strcmp(suspects[i], relative) == 0) { + return true; + } + } + return false; +} + +static bool application_append_quarantine(const char *path, const char *relative, + const char *phase) { + if (!relative || !relative[0] || strpbrk(relative, "\r\n\t")) { + return false; + } + FILE *file = cbm_fopen(path, "ab"); + if (!file) { + return false; + } + bool written = fprintf(file, "%s\t%s\n", relative, phase) >= 0; + return fclose(file) == 0 && written; +} + +static int application_max_restarts(void) { + char value[32] = {0}; + if (!cbm_safe_getenv("CBM_INDEX_MAX_RESTARTS", value, sizeof(value), NULL) || !value[0]) { + return APPLICATION_DEFAULT_MAX_RESTARTS; + } + char *end = NULL; + long parsed = strtol(value, &end, 10); + return end && *end == '\0' && parsed > 0 && parsed <= INT_MAX + ? (int)parsed + : APPLICATION_DEFAULT_MAX_RESTARTS; +} + +static void application_attempt_init(application_attempt_t *attempt) { + memset(attempt, 0, sizeof(*attempt)); + attempt->result.outcome = CBM_PROC_SPAWN_FAILED; + attempt->result.exit_code = -1; +} + +static void application_attempt_free(application_attempt_t *attempt) { + free(attempt->result.response); + attempt->result.response = NULL; +} + +static bool application_job_cancel_requested(cbm_daemon_application_job_t *job) { + cbm_daemon_application_t *application = job->application; + cbm_mutex_lock(&application->mutex); + bool cancelled = job->cancel_requested || application->stopping; + cbm_mutex_unlock(&application->mutex); + return cancelled; +} + +static bool application_project_keys_conflict(const char *left, const char *right) { + return left && right && + (strcmp(left, right) == 0 || strcmp(left, "*") == 0 || strcmp(right, "*") == 0); +} + +static bool application_mutation_conflicts_locked(cbm_daemon_application_t *application, + const char *project_key) { + for (cbm_daemon_application_mutation_t *mutation = application->mutations; mutation; + mutation = mutation->next) { + if (application_project_keys_conflict(mutation->project_key, project_key)) { + return true; + } + } + return false; +} + +static bool application_job_reserves_project_locked(cbm_daemon_application_t *application, + const char *project_key) { + for (cbm_daemon_application_job_t *job = application->jobs; job; job = job->next) { + if (!job->terminal && + application_project_keys_conflict(job->project_key, project_key)) { + return true; + } + } + return false; +} + +static bool application_mutation_begin_internal( + cbm_daemon_application_t *application, cbm_daemon_application_session_t *session, + const char *project_key, bool wait) { + if (!application || !project_key || !project_key[0]) { + return false; + } + cbm_daemon_application_mutation_t *reserved = NULL; + for (;;) { + cbm_mutex_lock(&application->mutex); + bool cancelled = application->stopping || + (session && + application_request_cancelled_locked(session)); + bool busy = application_mutation_conflicts_locked(application, project_key) || + application_job_reserves_project_locked(application, project_key); + if (!cancelled && !busy) { + cbm_daemon_application_mutation_t *mutation = calloc(1, sizeof(*mutation)); + if (mutation) { + mutation->project_key = cbm_strdup(project_key); + } + if (!mutation || !mutation->project_key) { + if (mutation) { + free(mutation->project_key); + free(mutation); + } + cbm_mutex_unlock(&application->mutex); + return false; + } + mutation->next = application->mutations; + application->mutations = mutation; + application->active_mutations++; + cbm_mutex_unlock(&application->mutex); + reserved = mutation; + break; + } + cbm_mutex_unlock(&application->mutex); + if (cancelled || !wait) { + return false; + } + cbm_usleep(APPLICATION_JOB_POLL_US); + } + + if (!application->project_locks) { + return true; + } + for (;;) { + uint64_t now = cbm_now_ms(); + uint64_t deadline = + now > UINT64_MAX - 100U ? UINT64_MAX : now + 100U; + cbm_project_lock_lease_t *lease = NULL; + cbm_private_file_lock_status_t status = + wait ? cbm_project_lock_acquire(application->project_locks, + project_key, deadline, NULL, + &lease) + : cbm_project_lock_try_acquire(application->project_locks, + project_key, &lease); + if (status == CBM_PRIVATE_FILE_LOCK_OK && lease) { + cbm_mutex_lock(&application->mutex); + bool cancelled = application->stopping || + (session && + application_request_cancelled_locked(session)); + cbm_mutex_unlock(&application->mutex); + if (cancelled) { + application_project_lock_release_fully(&lease); + status = CBM_PRIVATE_FILE_LOCK_BUSY; + } else { + reserved->project_lock_lease = lease; + return true; + } + } + application_project_lock_release_fully(&lease); + + cbm_mutex_lock(&application->mutex); + bool cancelled = application->stopping || + (session && + application_request_cancelled_locked(session)); + if (status != CBM_PRIVATE_FILE_LOCK_BUSY || cancelled || !wait) { + cbm_daemon_application_mutation_t **cursor = + &application->mutations; + while (*cursor && *cursor != reserved) { + cursor = &(*cursor)->next; + } + if (*cursor == reserved) { + *cursor = reserved->next; + if (application->active_mutations > 0) { + application->active_mutations--; + } + } + cbm_mutex_unlock(&application->mutex); + free(reserved->project_key); + free(reserved); + if (status != CBM_PRIVATE_FILE_LOCK_BUSY) { + cbm_log_error("daemon.project_lock_failed", "project", + project_key, "action", "refuse_mutation"); + } + return false; + } + cbm_mutex_unlock(&application->mutex); + } +} + +bool cbm_daemon_application_project_mutation_try_begin( + cbm_daemon_application_t *application, const char *project) { + return application_mutation_begin_internal(application, NULL, project, false); +} + +void cbm_daemon_application_project_mutation_end( + cbm_daemon_application_t *application, const char *project) { + if (!application || !project || !project[0]) { + return; + } + cbm_mutex_lock(&application->mutex); + cbm_daemon_application_mutation_t *mutation = application->mutations; + while (mutation && strcmp(mutation->project_key, project) != 0) { + mutation = mutation->next; + } + if (!mutation || mutation->releasing) { + cbm_mutex_unlock(&application->mutex); + return; + } + mutation->releasing = true; + cbm_mutex_unlock(&application->mutex); + + /* Keep the logical reservation visible until the native lease is gone. + * Otherwise another in-daemon operation can observe an apparently free + * project and enter the OS-lock wait during the release handoff. */ + application_project_lock_release_fully(&mutation->project_lock_lease); + + cbm_mutex_lock(&application->mutex); + cbm_daemon_application_mutation_t **cursor = &application->mutations; + while (*cursor && *cursor != mutation) { + cursor = &(*cursor)->next; + } + if (*cursor == mutation) { + *cursor = mutation->next; + if (application->active_mutations > 0) { + application->active_mutations--; + } + } + cbm_mutex_unlock(&application->mutex); + free(mutation->project_key); + free(mutation); +} + +static bool application_watcher_mutation_begin(void *context, const char *project) { + return cbm_daemon_application_project_mutation_try_begin(context, project); +} + +static void application_watcher_mutation_end(void *context, const char *project) { + cbm_daemon_application_project_mutation_end(context, project); +} + +static void application_watcher_project_pruned(void *context, const char *project) { + cbm_daemon_application_t *application = context; + if (!application || !project) { + return; + } + cbm_mutex_lock(&application->mutex); + cbm_daemon_application_watch_t *watch = + application_find_watch_locked(application, project); + if (watch) { + /* The watcher already removed its physical entry. Invalidate every + * logical subscriber so a later successful index can re-register it. */ + application_remove_watch_entry_locked(application, watch, false); + } + cbm_mutex_unlock(&application->mutex); +} + +static bool application_session_mutation_begin(void *context, const char *project) { + cbm_daemon_application_session_t *session = context; + return session && application_mutation_begin_internal( + session->application, session, project, true); +} + +static void application_session_mutation_end(void *context, const char *project) { + cbm_daemon_application_session_t *session = context; + if (session) { + cbm_daemon_application_project_mutation_end(session->application, project); + } +} + +static bool application_job_wait_for_mutations(cbm_daemon_application_job_t *job) { + cbm_daemon_application_t *application = job->application; + for (;;) { + cbm_mutex_lock(&application->mutex); + bool cancelled = job->cancel_requested || application->stopping; + bool busy = application_mutation_conflicts_locked(application, job->project_key); + cbm_mutex_unlock(&application->mutex); + if (cancelled) { + return false; + } + if (!busy) { + return true; + } + cbm_usleep(APPLICATION_JOB_POLL_US); + } +} + +static void application_cancelled_result(cbm_index_worker_result_t *result) { + memset(result, 0, sizeof(*result)); + result->outcome = CBM_PROC_KILLED; + result->exit_code = -1; + result->cancellation_requested = true; + result->tree_quiesced = true; +} + +static application_attempt_status_t application_job_run_attempt(cbm_daemon_application_job_t *job, + const char *marker_path, + const char *quarantine_path, + application_attempt_t *attempt) { + application_attempt_init(attempt); + cbm_daemon_application_t *application = job->application; + if (application_job_cancel_requested(job)) { + return APPLICATION_ATTEMPT_CANCELLED; + } + + cbm_daemon_application_worker_t worker = NULL; + application_tmp_lock(); + int start_result = application->worker_ops.start( + application->worker_ops.context, job->args_json, + application->worker_memory_budget_bytes, marker_path, quarantine_path, &worker); + application_tmp_unlock(); + if (start_result != 0 || !worker) { + return application_job_cancel_requested(job) ? APPLICATION_ATTEMPT_CANCELLED + : APPLICATION_ATTEMPT_START_FAILED; + } + + cbm_mutex_lock(&application->mutex); + job->worker = worker; + bool cancel_now = job->cancel_requested || application->stopping; + cbm_mutex_unlock(&application->mutex); + if (cancel_now) { + /* The worker thread owns this handle until destroy below. Invoke the + * external supervisor without the application mutex held. */ + (void)application->worker_ops.cancel(application->worker_ops.context, + worker); + } + + const cbm_index_worker_result_t *borrowed = NULL; + for (;;) { + cbm_index_worker_poll_t state = + application->worker_ops.poll(application->worker_ops.context, worker, &borrowed); + if (state == CBM_INDEX_WORKER_POLL_TERMINAL) { + break; + } + cbm_mutex_lock(&application->mutex); + bool cancel_pending = + (job->cancel_requested || application->stopping) && + job->worker == worker; + cbm_mutex_unlock(&application->mutex); + if (cancel_pending || state == CBM_INDEX_WORKER_POLL_ERROR) { + (void)application->worker_ops.cancel(application->worker_ops.context, worker); + } + cbm_usleep(APPLICATION_JOB_POLL_US); + } + + if (borrowed) { + attempt->result = *borrowed; + attempt->result.response = borrowed->response ? cbm_strdup(borrowed->response) : NULL; + attempt->has_result = true; + } + const char *worker_log = + application->worker_ops.log_path(application->worker_ops.context, worker); + if (worker_log) { + (void)snprintf(attempt->log_path, sizeof(attempt->log_path), "%s", worker_log); + } + + cbm_mutex_lock(&application->mutex); + if (job->worker == worker) { + job->worker = NULL; + } + bool cancelled = job->cancel_requested || application->stopping; + cbm_mutex_unlock(&application->mutex); + application->worker_ops.destroy(application->worker_ops.context, worker); + + if (cancelled) { + if (!attempt->has_result) { + application_cancelled_result(&attempt->result); + attempt->has_result = true; + } else { + attempt->result.cancellation_requested = true; + } + } + return APPLICATION_ATTEMPT_TERMINAL; +} + +static char *application_job_failure_response(const cbm_index_worker_result_t *result, + const char *log_path) { + char message[1024]; + if (result && result->cancellation_requested) { + (void)snprintf(message, sizeof(message), + "index operation cancelled after its final owning session disconnected"); + } else if (result && (result->supervision_failed || !result->tree_quiesced)) { + (void)snprintf(message, sizeof(message), + "index worker containment failed (%s); inspect log: %s", + cbm_proc_outcome_str(result->outcome), log_path ? log_path : "unavailable"); + } else if (result) { + (void)snprintf(message, sizeof(message), + "index worker ended with %s (exit=%d, signal=%d); inspect log: %s", + cbm_proc_outcome_str(result->outcome), result->exit_code, + result->term_signal, log_path ? log_path : "unavailable"); + } else { + (void)snprintf(message, sizeof(message), "index worker could not be started"); + } + return cbm_mcp_text_result(message, true); +} + +static cbm_mcp_supervised_result_disposition_t application_attempt_disposition( + cbm_daemon_application_job_t *job, application_attempt_t *attempt) { + if (application_job_cancel_requested(job)) { + if (!attempt->has_result) { + application_cancelled_result(&attempt->result); + attempt->has_result = true; + } else { + attempt->result.cancellation_requested = true; + } + } + return cbm_mcp_supervised_result_disposition(0, attempt->has_result ? &attempt->result : NULL); +} + +static void application_record_attempt(const application_attempt_t *attempt, + cbm_index_worker_result_t *last_result, + bool *have_last_result, + char last_log[APPLICATION_PATH_CAP]) { + *last_result = attempt->result; + last_result->response = NULL; + *have_last_result = attempt->has_result; + last_log[0] = '\0'; + if (attempt->log_path[0]) { + (void)snprintf(last_log, APPLICATION_PATH_CAP, "%s", attempt->log_path); + } +} + +static void application_record_cancelled(cbm_index_worker_result_t *last_result, + bool *have_last_result, + char last_log[APPLICATION_PATH_CAP]) { + application_cancelled_result(last_result); + *have_last_result = true; + last_log[0] = '\0'; +} + +static bool application_result_is_attributable_failure( + const application_attempt_t *attempt, cbm_mcp_supervised_result_disposition_t disposition) { + return disposition == CBM_MCP_SUPERVISED_RESULT_CONTAINED_FAILURE && attempt->has_result && + (attempt->result.outcome == CBM_PROC_CRASH || attempt->result.outcome == CBM_PROC_HANG); +} + +typedef enum { + APPLICATION_ATTEMPT_DECISION_STOP = 0, + APPLICATION_ATTEMPT_DECISION_SUCCESS, + APPLICATION_ATTEMPT_DECISION_RECOVERABLE, +} application_attempt_decision_t; + +typedef struct { + char *response; + bool successful; + bool unsafe_terminal; + bool supervision_failed; + cbm_index_worker_result_t last_result; + bool have_last_result; + char last_log[APPLICATION_PATH_CAP]; +} application_job_execution_t; + +static void application_job_execution_init(application_job_execution_t *execution) { + memset(execution, 0, sizeof(*execution)); + application_cancelled_result(&execution->last_result); +} + +static void application_job_execution_cancel(application_job_execution_t *execution) { + application_record_cancelled(&execution->last_result, &execution->have_last_result, + execution->last_log); + execution->unsafe_terminal = true; +} + +static application_attempt_decision_t application_consume_attempt( + cbm_daemon_application_job_t *job, application_attempt_t *attempt, + application_job_execution_t *execution, cbm_proc_outcome_t *failure_outcome) { + cbm_mcp_supervised_result_disposition_t disposition = + application_attempt_disposition(job, attempt); + application_record_attempt(attempt, &execution->last_result, &execution->have_last_result, + execution->last_log); + if (disposition == CBM_MCP_SUPERVISED_RESULT_SUCCESS) { + execution->response = attempt->result.response; + attempt->result.response = NULL; + execution->successful = execution->response != NULL; + application_attempt_free(attempt); + return APPLICATION_ATTEMPT_DECISION_SUCCESS; + } + if (disposition == CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL) { + execution->unsafe_terminal = true; + execution->supervision_failed = + attempt->has_result && !attempt->result.cancellation_requested && + (attempt->result.supervision_failed || !attempt->result.tree_quiesced); + application_attempt_free(attempt); + return APPLICATION_ATTEMPT_DECISION_STOP; + } + if (application_result_is_attributable_failure(attempt, disposition)) { + *failure_outcome = attempt->result.outcome; + application_attempt_free(attempt); + return APPLICATION_ATTEMPT_DECISION_RECOVERABLE; + } + application_attempt_free(attempt); + return APPLICATION_ATTEMPT_DECISION_STOP; +} + +static bool application_recovery_record_suspects( + cbm_daemon_application_job_t *job, const char *marker_path, const char *quarantine_path, + cbm_proc_outcome_t outcome, int recovery_index, char ***previous_suspects, int *previous_count, + int *quarantined, application_job_execution_t *execution) { + int suspect_count = 0; + bool cancelled = false; + char **suspects = application_read_suspects(job, marker_path, &suspect_count, &cancelled); + if (cancelled || application_job_cancel_requested(job)) { + application_free_suspects(suspects, suspect_count); + application_job_execution_cancel(execution); + return false; + } + if (!suspects || suspect_count == 0) { + application_free_suspects(suspects, suspect_count); + cbm_log_warn("daemon.index.recovery_unattributable", "action", "stop"); + return false; + } + if (*previous_suspects) { + const char *pick = NULL; + for (int i = 0; i < suspect_count && !pick; i++) { + if (application_suspect_contains(*previous_suspects, *previous_count, suspects[i])) { + pick = suspects[i]; + } + } + if (!pick) { + application_free_suspects(suspects, suspect_count); + cbm_log_warn("daemon.index.recovery_unattributable", "action", "stop"); + return false; + } + const char *phase = outcome == CBM_PROC_HANG ? "hang" : "crash"; + if (!application_append_quarantine(quarantine_path, pick, phase)) { + cbm_log_warn("daemon.index.quarantine_write_fail", "path", pick); + application_free_suspects(suspects, suspect_count); + return false; + } + (*quarantined)++; + char attempt_text[32]; + (void)snprintf(attempt_text, sizeof(attempt_text), "%d", recovery_index + 1); + cbm_log_warn("daemon.index.file_quarantined", "path", pick, "outcome", phase, "attempt", + attempt_text); + } + application_free_suspects(*previous_suspects, *previous_count); + *previous_suspects = suspects; + *previous_count = suspect_count; + return true; +} + +static void application_job_try_partial(cbm_daemon_application_job_t *job, + const char *quarantine_path, int quarantined, + application_job_execution_t *execution) { + if (application_job_cancel_requested(job)) { + application_job_execution_cancel(execution); + return; + } + application_attempt_t attempt; + application_attempt_status_t status = + application_job_run_attempt(job, NULL, quarantine_path, &attempt); + if (status == APPLICATION_ATTEMPT_CANCELLED) { + application_job_execution_cancel(execution); + return; + } + if (status != APPLICATION_ATTEMPT_TERMINAL) { + return; + } + cbm_proc_outcome_t failure_outcome = CBM_PROC_SPAWN_FAILED; + application_attempt_decision_t decision = + application_consume_attempt(job, &attempt, execution, &failure_outcome); + if (decision == APPLICATION_ATTEMPT_DECISION_SUCCESS) { + char quarantined_text[32]; + (void)snprintf(quarantined_text, sizeof(quarantined_text), "%d", quarantined); + cbm_log_warn("daemon.index.recovery_partial", "quarantined", quarantined_text); + } +} + +static void application_job_recover(cbm_daemon_application_job_t *job, + application_job_execution_t *execution) { + if (application_job_cancel_requested(job)) { + application_job_execution_cancel(execution); + return; + } + char marker_path[APPLICATION_PATH_CAP] = {0}; + char quarantine_path[APPLICATION_PATH_CAP] = {0}; + if (!application_recovery_files_create(marker_path, quarantine_path)) { + return; + } + + int quarantined = 0; + char **previous_suspects = NULL; + int previous_count = 0; + int recovery_cap = application_max_restarts(); + for (int recovery_index = 0; recovery_index < recovery_cap; recovery_index++) { + if (application_job_cancel_requested(job)) { + application_job_execution_cancel(execution); + break; + } + if (!application_truncate_file(marker_path)) { + break; + } + application_attempt_t attempt; + application_attempt_status_t status = + application_job_run_attempt(job, marker_path, quarantine_path, &attempt); + if (status == APPLICATION_ATTEMPT_CANCELLED) { + application_job_execution_cancel(execution); + break; + } + if (status != APPLICATION_ATTEMPT_TERMINAL) { + break; + } + cbm_proc_outcome_t failure_outcome = CBM_PROC_SPAWN_FAILED; + application_attempt_decision_t decision = + application_consume_attempt(job, &attempt, execution, &failure_outcome); + if (decision != APPLICATION_ATTEMPT_DECISION_RECOVERABLE || + !application_recovery_record_suspects( + job, marker_path, quarantine_path, failure_outcome, recovery_index, + &previous_suspects, &previous_count, &quarantined, execution)) { + break; + } + } + application_free_suspects(previous_suspects, previous_count); + if (!execution->response && !execution->unsafe_terminal && quarantined > 0) { + application_job_try_partial(job, quarantine_path, quarantined, execution); + } + application_recovery_files_remove(marker_path, quarantine_path); +} + +static void application_job_publish(cbm_daemon_application_job_t *job, + application_job_execution_t *execution) { + if (!execution->response) { + execution->response = application_job_failure_response( + execution->have_last_result ? &execution->last_result : NULL, + execution->last_log[0] ? execution->last_log : NULL); + } + cbm_daemon_application_t *application = job->application; + cbm_mutex_lock(&application->mutex); + job->response = execution->response; + job->successful = execution->successful; + job->cancelled = execution->have_last_result && execution->last_result.cancellation_requested; + job->supervision_failed = execution->supervision_failed || + (execution->unsafe_terminal && execution->have_last_result && + !execution->last_result.cancellation_requested); + job->terminal = true; + job->thread_done = true; + cbm_mutex_unlock(&application->mutex); +} + +static void *application_job_thread(void *opaque) { + cbm_daemon_application_job_t *job = opaque; + application_job_execution_t execution; + application_job_execution_init(&execution); + + /* The linked non-terminal job is the daemon-internal reservation: it + * coalesces identical requests and blocks same-project daemon mutations. + * The physical worker is the sole owner of the cross-process project lock, + * so the daemon must not pre-acquire that same native lease here. */ + if (!application_job_wait_for_mutations(job)) { + application_job_execution_cancel(&execution); + } else { + application_attempt_t attempt; + application_attempt_status_t status = + application_job_run_attempt(job, NULL, NULL, &attempt); + if (status == APPLICATION_ATTEMPT_CANCELLED) { + application_job_execution_cancel(&execution); + } else if (status == APPLICATION_ATTEMPT_TERMINAL) { + cbm_proc_outcome_t failure_outcome = CBM_PROC_SPAWN_FAILED; + application_attempt_decision_t decision = + application_consume_attempt(job, &attempt, &execution, &failure_outcome); + if (decision == APPLICATION_ATTEMPT_DECISION_RECOVERABLE) { + application_job_recover(job, &execution); + } + } + } + application_job_publish(job, &execution); + return NULL; +} + +static cbm_daemon_application_job_t *application_find_job_locked( + cbm_daemon_application_t *application, const char *project_key) { + for (cbm_daemon_application_job_t *job = application->jobs; job; job = job->next) { + if (strcmp(job->project_key, project_key) == 0) { + return job; + } + } + return NULL; +} + +/* Terminal jobs may remain linked while their original waiters copy the + * published response. They are immutable history, not coalescing targets for + * a later request of the same project. */ +static cbm_daemon_application_job_t *application_find_active_job_locked( + cbm_daemon_application_t *application, const char *project_key) { + for (cbm_daemon_application_job_t *job = application->jobs; job; job = job->next) { + if (!job->terminal && strcmp(job->project_key, project_key) == 0) { + return job; + } + } + return NULL; +} + +static char *application_index_project_key(const char *root_path, const char *args_json) { + char *override = cbm_mcp_get_string_arg(args_json, "name"); + char *key = cbm_project_name_from_path(override && override[0] ? override : root_path); + free(override); + return key; +} + +typedef enum { + APPLICATION_JOB_SUBSCRIBE_OK = 0, + APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT, + APPLICATION_JOB_SUBSCRIBE_BUSY, + APPLICATION_JOB_SUBSCRIBE_CANCELLING, + APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE, + APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED, +} application_job_subscribe_status_t; + +static size_t application_active_job_count_locked(cbm_daemon_application_t *application) { + size_t count = 0; + for (cbm_daemon_application_job_t *job = application->jobs; job; job = job->next) { + if (!job->terminal) { + count++; + } + } + return count; +} + +/* Caller holds application->mutex. Keeping watcher ownership validation and + * this admission in the same critical section closes the unwatch race. */ +static cbm_daemon_application_job_t *application_job_subscribe_locked( + cbm_daemon_application_t *application, const char *project_key, const char *root_path, + const char *args_json, application_job_subscribe_status_t *status_out) { + *status_out = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE; + if (application->stopping) { + return NULL; + } + cbm_daemon_application_job_t *job = + application_find_active_job_locked(application, project_key); + if (job) { + if (job->cancel_requested) { + *status_out = APPLICATION_JOB_SUBSCRIBE_CANCELLING; + return NULL; + } + if (strcmp(job->args_json, args_json) != 0) { + *status_out = APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT; + return NULL; + } + job->subscribers++; + *status_out = APPLICATION_JOB_SUBSCRIBE_OK; + return job; + } + + if (application_active_job_count_locked(application) >= application->physical_job_limit) { + char limit[32]; + (void)snprintf(limit, sizeof(limit), "%zu", application->physical_job_limit); + cbm_log_warn("daemon.index.admission_busy", "limit", limit, "project", project_key); + *status_out = APPLICATION_JOB_SUBSCRIBE_BUSY; + return NULL; + } + + job = calloc(1, sizeof(*job)); + if (job) { + job->project_key = strdup(project_key); + job->root_path = strdup(root_path); + job->args_json = strdup(args_json); + } + if (!job || !job->project_key || !job->root_path || !job->args_json) { + application_job_free(job); + *status_out = APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED; + return NULL; + } + job->application = application; + job->subscribers = 1; + job->next = application->jobs; + application->jobs = job; + if (cbm_thread_create(&job->thread, APPLICATION_JOB_THREAD_STACK, application_job_thread, + job) == 0) { + job->thread_started = true; + } else { + job->response = cbm_mcp_text_result("failed to create index supervisor thread", true); + job->terminal = true; + job->thread_done = true; + } + *status_out = APPLICATION_JOB_SUBSCRIBE_OK; + return job; +} + +static cbm_daemon_application_job_t *application_job_subscribe( + cbm_daemon_application_t *application, const char *project_key, const char *root_path, + const char *args_json, application_job_subscribe_status_t *status_out) { + application_jobs_reap_completed(application); + cbm_mutex_lock(&application->mutex); + cbm_daemon_application_job_t *job = application_job_subscribe_locked( + application, project_key, root_path, args_json, status_out); + cbm_mutex_unlock(&application->mutex); + return job; +} + +static void application_job_unsubscribe_locked(cbm_daemon_application_job_t *job) { + if (!job || job->subscribers == 0) { + return; + } + job->subscribers--; + if (job->subscribers == 0 && !job->terminal) { + job->cancel_requested = true; + } +} + +static bool application_watch_job_subscription_exists_locked( + cbm_daemon_application_t *application, + cbm_daemon_application_session_t *session, + cbm_daemon_application_job_t *job) { + for (cbm_daemon_application_watch_job_subscription_t *subscription = + application->watch_job_subscriptions; + subscription; subscription = subscription->next) { + if (subscription->session == session && subscription->job == job) { + return true; + } + } + return false; +} + +/* Caller holds application->mutex. Allocate the complete change before + * publishing any node so an allocation failure never leaves only a subset of + * the exact live watch owners subscribed. */ +static bool application_watch_job_subscribe_sessions_locked( + cbm_daemon_application_t *application, + cbm_daemon_application_watch_t *watch, + cbm_daemon_application_job_t *job, size_t *matched_out) { + *matched_out = 0; + if (!watch || !job || strcmp(watch->project, job->project_key) != 0 || + strcmp(watch->root, job->root_path) != 0) { + return true; + } + + cbm_daemon_application_watch_job_subscription_t *pending = NULL; + for (cbm_daemon_application_session_t *session = application->sessions; + session; session = session->next) { + if (session->session_cancelled || !session->context_set || + session->watch != watch) { + continue; + } + (*matched_out)++; + if (application_watch_job_subscription_exists_locked( + application, session, job)) { + continue; + } + cbm_daemon_application_watch_job_subscription_t *subscription = + calloc(1, sizeof(*subscription)); + if (!subscription) { + while (pending) { + cbm_daemon_application_watch_job_subscription_t *next = + pending->next; + free(pending); + pending = next; + } + return false; + } + subscription->session = session; + subscription->job = job; + subscription->next = pending; + pending = subscription; + } + + while (pending) { + cbm_daemon_application_watch_job_subscription_t *subscription = pending; + pending = pending->next; + subscription->next = application->watch_job_subscriptions; + application->watch_job_subscriptions = subscription; + job->subscribers++; + } + return true; +} + +static void application_watch_job_unsubscribe_session_locked( + cbm_daemon_application_session_t *session) { + if (!session || !session->application) { + return; + } + cbm_daemon_application_watch_job_subscription_t **cursor = + &session->application->watch_job_subscriptions; + while (*cursor) { + cbm_daemon_application_watch_job_subscription_t *subscription = + *cursor; + if (subscription->session != session) { + cursor = &subscription->next; + continue; + } + *cursor = subscription->next; + application_job_unsubscribe_locked(subscription->job); + free(subscription); + } +} + +static void application_watch_job_unsubscribe_job_locked( + cbm_daemon_application_t *application, + cbm_daemon_application_job_t *job) { + cbm_daemon_application_watch_job_subscription_t **cursor = + &application->watch_job_subscriptions; + while (*cursor) { + cbm_daemon_application_watch_job_subscription_t *subscription = + *cursor; + if (subscription->job != job) { + cursor = &subscription->next; + continue; + } + *cursor = subscription->next; + application_job_unsubscribe_locked(job); + free(subscription); + } +} + +static char *application_job_wait_for_session(cbm_daemon_application_session_t *session, + cbm_daemon_application_job_t *job) { + cbm_daemon_application_t *application = session->application; + for (;;) { + cbm_mutex_lock(&application->mutex); + if (session->active_job != job || !session->active_job_subscribed) { + cbm_mutex_unlock(&application->mutex); + return cbm_mcp_text_result("index operation cancelled for this session", true); + } + if (job->terminal) { + char *response = job->response ? strdup(job->response) : NULL; + session->active_job = NULL; + session->active_job_subscribed = false; + application_job_unsubscribe_locked(job); + cbm_mutex_unlock(&application->mutex); + application_jobs_reap_completed(application); + return response ? response + : cbm_mcp_text_result("index coordinator lost its result", true); + } + cbm_mutex_unlock(&application->mutex); + cbm_usleep(APPLICATION_JOB_POLL_US); + } +} + +static char *application_index_execute(void *context, const char *root_path, + const char *args_json) { + cbm_daemon_application_session_t *session = context; + if (!session || !root_path || !args_json) { + return NULL; + } + char *project_key = application_index_project_key(root_path, args_json); + if (!project_key) { + return cbm_mcp_text_result("failed to derive index project identity", true); + } + application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE; + cbm_daemon_application_job_t *job = application_job_subscribe( + session->application, project_key, root_path, args_json, &subscribe_status); + free(project_key); + if (!job) { + const char *message = "daemon index coordinator is stopping or unavailable"; + if (subscribe_status == APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT) { + message = "another index operation for this project is active with different options"; + } else if (subscribe_status == APPLICATION_JOB_SUBSCRIBE_BUSY) { + message = + "daemon index coordinator is busy: physical index job limit reached; retry later"; + } else if (subscribe_status == APPLICATION_JOB_SUBSCRIBE_CANCELLING) { + message = "another index operation for this project is cancelling; retry shortly"; + } else if (subscribe_status == APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED) { + message = "daemon index coordinator could not allocate an index job"; + } + return cbm_mcp_text_result(message, true); + } + cbm_mutex_lock(&session->application->mutex); + if (application_request_cancelled_locked(session)) { + application_job_unsubscribe_locked(job); + cbm_mutex_unlock(&session->application->mutex); + return cbm_mcp_text_result("index operation cancelled for this session", true); + } + if (session->active_job) { + application_job_unsubscribe_locked(job); + cbm_mutex_unlock(&session->application->mutex); + return cbm_mcp_text_result("this session already has an active index operation", true); + } + session->active_job = job; + session->active_job_subscribed = true; + cbm_mutex_unlock(&session->application->mutex); + return application_job_wait_for_session(session, job); +} + +static cbm_daemon_runtime_application_session_t *application_session_open( + void *context, cbm_daemon_client_id_t client_id, uint64_t authenticated_process_id) { + cbm_daemon_application_t *application = context; + if (!application || client_id == CBM_DAEMON_CLIENT_ID_INVALID || + authenticated_process_id == 0) { + return NULL; + } + cbm_daemon_application_session_t *session = calloc(1, sizeof(*session)); + if (!session) { + return NULL; + } + session->mcp = cbm_mcp_server_new(NULL); + if (!session->mcp || + !cbm_mcp_server_release_pristine_memory_store(session->mcp)) { + cbm_mcp_server_free(session->mcp); + free(session); + return NULL; + } + cbm_mcp_server_set_background_tasks(session->mcp, false); + cbm_mcp_server_set_config(session->mcp, application->config); + cbm_mcp_server_set_index_executor(session->mcp, application_index_execute, session); + cbm_mcp_server_set_project_mutation_guard( + session->mcp, application_session_mutation_begin, + application_session_mutation_end, session); + session->tool_profile = CBM_MCP_TOOL_PROFILE_ALL; + session->application = application; + session->client_id = client_id; + session->authenticated_process_id = authenticated_process_id; + + cbm_mutex_lock(&application->mutex); + if (application->stopping) { + cbm_mutex_unlock(&application->mutex); + cbm_mcp_server_free(session->mcp); + free(session); + return NULL; + } + session->next = application->sessions; + application->sessions = session; + cbm_mutex_unlock(&application->mutex); + return (cbm_daemon_runtime_application_session_t *)session; +} + +static cbm_daemon_runtime_application_status_t application_set_context( + cbm_daemon_application_session_t *session, const uint8_t *request, uint32_t request_length) { + if (session->context_set || request_length < APPLICATION_CONTEXT_HEADER_SIZE) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + uint32_t root_length = application_get_u32(request + 1); + bool allowed_present = request[5] == 1; + uint32_t allowed_length = application_get_u32(request + 6); + uint8_t profile_value = request[10]; + uint32_t event_length = application_get_u32(request + 11); + uint32_t dialect_length = application_get_u32(request + 15); + uint64_t expected = (uint64_t)APPLICATION_CONTEXT_HEADER_SIZE + + root_length + allowed_length + event_length + + dialect_length; + if (request[5] > 1 || root_length == 0 || expected != request_length || + (!allowed_present && allowed_length != 0) || + profile_value > (uint8_t)CBM_MCP_TOOL_PROFILE_SCOUT || + (profile_value != (uint8_t)CBM_MCP_TOOL_PROFILE_ALL && + (event_length != 0 || dialect_length != 0))) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + cbm_mcp_tool_profile_t tool_profile = + (cbm_mcp_tool_profile_t)profile_value; + const uint8_t *payload = request + APPLICATION_CONTEXT_HEADER_SIZE; + char *root = application_text_copy(request + APPLICATION_CONTEXT_HEADER_SIZE, root_length); + char *allowed = + allowed_present + ? application_text_copy(payload + root_length, allowed_length) + : NULL; + char *hook_event = + event_length + ? application_text_copy(payload + root_length + allowed_length, + event_length) + : NULL; + char *hook_dialect = + dialect_length + ? application_text_copy(payload + root_length + allowed_length + + event_length, + dialect_length) + : NULL; + if (!root || (allowed_present && !allowed) || + (event_length && !hook_event) || + (dialect_length && !hook_dialect) || + !cbm_hook_augment_invocation_supported(hook_event, hook_dialect)) { + free(root); + free(allowed); + free(hook_event); + free(hook_dialect); + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + char canonical_root[APPLICATION_PATH_CAP] = {0}; + char canonical_allowed[APPLICATION_PATH_CAP] = {0}; + bool canonical = cbm_canonical_path(root, canonical_root, sizeof(canonical_root)); + if (canonical && allowed_present) { + canonical = cbm_canonical_path(allowed, canonical_allowed, sizeof(canonical_allowed)); + } + struct stat root_status; + canonical = + canonical && stat(canonical_root, &root_status) == 0 && S_ISDIR(root_status.st_mode); + bool set = + canonical && cbm_mcp_server_set_session_context(session->mcp, canonical_root, + allowed_present ? canonical_allowed : NULL); + free(root); + free(allowed); + if (!set) { + free(hook_event); + free(hook_dialect); + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + cbm_mcp_server_set_tool_profile(session->mcp, tool_profile); + session->tool_profile = tool_profile; + session->hook_event = hook_event; + session->hook_dialect = hook_dialect; + session->context_set = true; + return CBM_DAEMON_RUNTIME_APPLICATION_OK; +} + +static cbm_daemon_runtime_application_status_t application_mcp_request( + cbm_daemon_application_session_t *session, const uint8_t *request, uint32_t request_length, + uint8_t **response_out, uint32_t *response_length_out) { + if (!session->context_set || request_length <= 1) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + char *message = application_text_copy(request + 1, request_length - 1); + if (!message) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + char *response = cbm_mcp_server_handle(session->mcp, message); + free(message); + if (response) { + size_t response_length = strlen(response); + if (response_length > UINT32_MAX) { + free(response); + return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + *response_out = (uint8_t *)response; + *response_length_out = (uint32_t)response_length; + } + application_refresh_watch(session); + return CBM_DAEMON_RUNTIME_APPLICATION_OK; +} + +static cbm_daemon_runtime_application_status_t application_tool_request( + cbm_daemon_application_session_t *session, const uint8_t *request, uint32_t request_length, + uint8_t **response_out, uint32_t *response_length_out) { + if (!session->context_set || request_length <= APPLICATION_TOOL_HEADER_SIZE) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + uint32_t tool_length = application_get_u32(request + 1); + if (tool_length == 0 || tool_length >= request_length - APPLICATION_TOOL_HEADER_SIZE) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + char *tool = application_text_copy(request + APPLICATION_TOOL_HEADER_SIZE, tool_length); + uint32_t args_length = request_length - APPLICATION_TOOL_HEADER_SIZE - tool_length; + char *args = + application_text_copy(request + APPLICATION_TOOL_HEADER_SIZE + tool_length, args_length); + if (!tool || !args) { + free(tool); + free(args); + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + char *response = cbm_mcp_handle_tool(session->mcp, tool, args); + free(tool); + free(args); + if (!response) { + return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + size_t response_length = strlen(response); + if (response_length > UINT32_MAX) { + free(response); + return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + *response_out = (uint8_t *)response; + *response_length_out = (uint32_t)response_length; + application_refresh_watch(session); + return CBM_DAEMON_RUNTIME_APPLICATION_OK; +} + +static cbm_daemon_runtime_application_status_t application_set_ui_config( + cbm_daemon_application_t *application, + cbm_daemon_application_session_t *session, const uint8_t *request, + uint32_t request_length) { + const uint8_t valid_mask = + CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED | + CBM_DAEMON_APPLICATION_UI_CONFIG_PORT; + if (!session || !session->context_set || + session->tool_profile != CBM_MCP_TOOL_PROFILE_ALL || + request_length != APPLICATION_UI_CONFIG_REQUEST_SIZE) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + uint8_t update_mask = request[1]; + uint8_t enabled = request[2]; + uint32_t port = application_get_u32(request + 3); + bool enabled_present = + (update_mask & CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED) != 0; + bool port_present = + (update_mask & CBM_DAEMON_APPLICATION_UI_CONFIG_PORT) != 0; + if (update_mask == 0 || (update_mask & (uint8_t)~valid_mask) != 0 || + (enabled_present ? enabled > 1U : enabled != 0U) || + (port_present + ? port == 0 || request[3] != 0 || request[4] != 0 + : port != 0U)) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + + cbm_mutex_lock(&application->mutex); + if (application->stopping) { + cbm_mutex_unlock(&application->mutex); + return CBM_DAEMON_RUNTIME_APPLICATION_UNAVAILABLE; + } + cbm_ui_config_t config; + cbm_ui_config_load(&config); + if (enabled_present) { + config.ui_enabled = enabled != 0; + } + if (port_present) { + config.ui_port = (int)port; + } + bool saved = cbm_ui_config_save(&config); + cbm_mutex_unlock(&application->mutex); + return saved ? CBM_DAEMON_RUNTIME_APPLICATION_OK + : CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; +} + +static cbm_daemon_runtime_application_status_t +application_request_dispatch( + cbm_daemon_application_t *application, + cbm_daemon_application_session_t *session, const uint8_t *request, + uint32_t request_length, uint8_t **response_out, + uint32_t *response_length_out) { + switch ((cbm_daemon_application_request_kind_t)request[0]) { + case CBM_DAEMON_APPLICATION_REQUEST_SET_CONTEXT: + return application_set_context(session, request, request_length); + case CBM_DAEMON_APPLICATION_REQUEST_MCP: + return application_mcp_request(session, request, request_length, response_out, + response_length_out); + case CBM_DAEMON_APPLICATION_REQUEST_TOOL: + return application_tool_request(session, request, request_length, response_out, + response_length_out); + case CBM_DAEMON_APPLICATION_REQUEST_SET_UI_CONFIG: + return application_set_ui_config(application, session, request, + request_length); + case CBM_DAEMON_APPLICATION_REQUEST_HOOK_AUGMENT: { + if (!session->context_set || request_length <= 1) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + char *input = application_text_copy(request + 1, request_length - 1); + if (!input) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + char *response = cbm_hook_augment_process_for( + session->mcp, input, session->hook_event, + session->hook_dialect); + free(input); + if (response) { + size_t response_length = strlen(response); + if (response_length > UINT32_MAX) { + free(response); + return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + *response_out = (uint8_t *)response; + *response_length_out = (uint32_t)response_length; + } + return CBM_DAEMON_RUNTIME_APPLICATION_OK; + } + default: + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } +} + +static cbm_daemon_runtime_application_status_t application_request( + void *context, cbm_daemon_runtime_application_session_t *opaque_session, + cbm_daemon_runtime_application_token_t request_token, + const uint8_t *request, uint32_t request_length, uint8_t **response_out, + uint32_t *response_length_out) { + cbm_daemon_application_t *application = context; + cbm_daemon_application_session_t *session = + (cbm_daemon_application_session_t *)opaque_session; + if (response_out) { + *response_out = NULL; + } + if (response_length_out) { + *response_length_out = 0; + } + if (!application || !session || session->application != application || + request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID || + !request || request_length == 0 || !response_out || + !response_length_out) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + + cbm_mutex_lock(&application->mutex); + if (application->stopping) { + cbm_mutex_unlock(&application->mutex); + return CBM_DAEMON_RUNTIME_APPLICATION_UNAVAILABLE; + } + if (session->session_cancelled) { + cbm_mutex_unlock(&application->mutex); + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + if (session->request_active) { + cbm_mutex_unlock(&application->mutex); + return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + if (session->request_cancel_token != request_token) { + session->request_cancel_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + } + session->request_active = true; + session->active_request_token = request_token; + bool cancelled_before_entry = + session->request_cancel_token == request_token; + cbm_mutex_unlock(&application->mutex); + + cbm_daemon_runtime_application_status_t status = + cancelled_before_entry + ? CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED + : application_request_dispatch( + application, session, request, request_length, response_out, + response_length_out); + + /* This mutex boundary is the cancellation/completion linearization point. + * A matching cancel published before it wins; a later cancel is stale and + * cannot affect the next unique request token. */ + cbm_mutex_lock(&application->mutex); + bool cancelled = session->session_cancelled || + session->request_cancel_token == request_token; + session->request_active = false; + session->active_request_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + if (session->request_cancel_token == request_token) { + session->request_cancel_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + } + cbm_mutex_unlock(&application->mutex); + if (cancelled) { + free(*response_out); + *response_out = NULL; + *response_length_out = 0; + return CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED; + } + return status; +} + +static void application_cancel_jobs_locked( + cbm_daemon_application_t *application) { + for (cbm_daemon_application_job_t *job = application->jobs; job; + job = job->next) { + if (!job->terminal) { + job->cancel_requested = true; + } + } +} + +static void application_request_cancel( + void *context, + cbm_daemon_runtime_application_session_t *opaque_session, + cbm_daemon_runtime_application_token_t request_token) { + cbm_daemon_application_t *application = context; + cbm_daemon_application_session_t *session = + (cbm_daemon_application_session_t *)opaque_session; + if (!application || !session || session->application != application || + request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { + return; + } + cbm_mutex_lock(&application->mutex); + if (!session->session_cancelled && + (!session->request_active || + session->active_request_token == request_token)) { + session->request_cancel_token = request_token; + bool active_match = session->request_active; + if (active_match && session->active_job && + session->active_job_subscribed) { + cbm_daemon_application_job_t *job = session->active_job; + session->active_job = NULL; + session->active_job_subscribed = false; + application_job_unsubscribe_locked(job); + } + if (active_match) { + /* Keep the MCP atomic cancel inside the same completion mutex. + * Otherwise this callback could pause after unlocking and set the + * flag on a later request that already reused this session. */ + (void)cbm_mcp_server_cancel_active(session->mcp); + } + } + cbm_mutex_unlock(&application->mutex); +} + +static void application_session_cancel(void *context, + cbm_daemon_runtime_application_session_t *opaque_session) { + cbm_daemon_application_t *application = context; + cbm_daemon_application_session_t *session = (cbm_daemon_application_session_t *)opaque_session; + if (application && session && session->application == application) { + cbm_mutex_lock(&application->mutex); + /* Runtime may need to keep the session allocation alive until its + * request callback joins. Cancellation, not the later close, is the + * ownership boundary for watches and session-scoped index work. */ + bool newly_cancelled = !session->session_cancelled; + session->session_cancelled = true; + if (session->request_active) { + session->request_cancel_token = session->active_request_token; + } + if (session->active_job && session->active_job_subscribed) { + cbm_daemon_application_job_t *job = session->active_job; + session->active_job = NULL; + session->active_job_subscribed = false; + application_job_unsubscribe_locked(job); + } + application_release_session_watch_locked(session); + bool final_live_session = newly_cancelled; + for (cbm_daemon_application_session_t *other = application->sessions; + final_live_session && other; other = other->next) { + if (other != session && !other->session_cancelled) { + final_live_session = false; + } + } + if (final_live_session) { + /* The daemon runtime cannot close this allocation until an + * in-flight callback joins. Stop new admission now and cancel + * watcher/UI jobs that are not owned by session->active_job. */ + application->stopping = true; + application_cancel_jobs_locked(application); + } + cbm_mutex_unlock(&application->mutex); + (void)cbm_mcp_server_cancel_active(session->mcp); + } +} + +static void application_session_close(void *context, + cbm_daemon_runtime_application_session_t *opaque_session) { + cbm_daemon_application_t *application = context; + cbm_daemon_application_session_t *session = (cbm_daemon_application_session_t *)opaque_session; + if (!application || !session || session->application != application) { + return; + } + cbm_mutex_lock(&application->mutex); + cbm_daemon_application_session_t **cursor = &application->sessions; + while (*cursor && *cursor != session) { + cursor = &(*cursor)->next; + } + if (*cursor == session) { + *cursor = session->next; + } + if (session->active_job && session->active_job_subscribed) { + application_job_unsubscribe_locked(session->active_job); + session->active_job = NULL; + session->active_job_subscribed = false; + } + application_release_session_watch_locked(session); + cbm_mutex_unlock(&application->mutex); + cbm_mcp_server_free(session->mcp); + free(session->hook_event); + free(session->hook_dialect); + free(session); +} + +cbm_daemon_application_t *cbm_daemon_application_new( + const cbm_daemon_application_config_t *config) { + cbm_daemon_application_t *application = calloc(1, sizeof(*application)); + if (!application) { + return NULL; + } + cbm_mutex_init(&application->mutex); + application->physical_job_limit = APPLICATION_DEFAULT_PHYSICAL_JOB_LIMIT; + size_t aggregate_memory_budget_bytes = cbm_mem_budget(); + if (config) { + application->watcher = config->watcher; + application->config = config->config; + application->project_locks = config->project_locks; + if (config->physical_job_limit > 0) { + application->physical_job_limit = config->physical_job_limit; + } + if (config->aggregate_memory_budget_bytes > 0) { + aggregate_memory_budget_bytes = config->aggregate_memory_budget_bytes; + } + if (config->worker_ops) { + application->worker_ops = *config->worker_ops; + } + } + /* Equal fixed slices keep admission deterministic: starting fewer jobs does + * not let an early worker claim memory reserved for later concurrent jobs. + * The absurd sub-byte-per-slot case is made safe by reducing effective + * capacity before division; normal daemon budgets are many orders larger. */ + if (aggregate_memory_budget_bytes > 0 && + application->physical_job_limit > aggregate_memory_budget_bytes) { + application->physical_job_limit = aggregate_memory_budget_bytes; + } + if (aggregate_memory_budget_bytes > 0 && application->physical_job_limit > 0) { + application->worker_memory_budget_bytes = + aggregate_memory_budget_bytes / application->physical_job_limit; + } + if (!application->worker_ops.start) { + application->worker_ops = (cbm_daemon_application_worker_ops_t){ + .context = NULL, + .start = application_worker_start_default, + .poll = application_worker_poll_default, + .cancel = application_worker_cancel_default, + .log_path = application_worker_log_path_default, + .destroy = application_worker_destroy_default, + }; + } + if (!application->worker_ops.poll || !application->worker_ops.cancel || + !application->worker_ops.log_path || !application->worker_ops.destroy) { + cbm_mutex_destroy(&application->mutex); + free(application); + return NULL; + } + if (application->watcher) { + cbm_watcher_set_project_mutation_guard( + application->watcher, application_watcher_mutation_begin, + application_watcher_mutation_end, application_watcher_project_pruned, + application); + } + return application; +} + +bool cbm_daemon_application_shutdown(cbm_daemon_application_t *application, uint32_t timeout_ms) { + if (!application) { + return false; + } + uint64_t deadline = application_deadline_after(timeout_ms); + cbm_mutex_lock(&application->mutex); + application->stopping = true; + for (cbm_daemon_application_session_t *session = application->sessions; session; + session = session->next) { + (void)cbm_mcp_server_cancel_active(session->mcp); + } + application_cancel_jobs_locked(application); + cbm_mutex_unlock(&application->mutex); + for (;;) { + bool all_done = true; + cbm_mutex_lock(&application->mutex); + if (application->active_mutations != 0) { + all_done = false; + } + for (cbm_daemon_application_session_t *session = application->sessions; + all_done && session; session = session->next) { + if (session->request_active) { + all_done = false; + } + } + for (cbm_daemon_application_job_t *job = application->jobs; job; job = job->next) { + if (!job->thread_done || job->subscribers != 0 || + job->watcher_waiters != 0) { + all_done = false; + break; + } + } + cbm_mutex_unlock(&application->mutex); + if (all_done) { + application_jobs_reap_completed(application); + return true; + } + if (cbm_now_ms() >= deadline) { + return false; + } + cbm_usleep(APPLICATION_JOB_POLL_US); + } +} + +void cbm_daemon_application_free(cbm_daemon_application_t *application) { + if (!application) { + return; + } + if (!cbm_daemon_application_shutdown(application, 3000)) { + /* Never detach/free live job threads. The caller must retain the + * application and retry shutdown after the containment failure is + * resolved. */ + cbm_log_error("daemon.application.free_busy", "action", "retain"); + return; + } + if (application->watcher) { + /* Waits for any in-flight prune callback before application storage is + * detached, preventing a borrowed callback context from becoming UAF. */ + cbm_watcher_set_project_mutation_guard(application->watcher, NULL, NULL, + NULL, NULL); + } + cbm_mutex_lock(&application->mutex); + cbm_daemon_application_session_t *sessions = application->sessions; + application->sessions = NULL; + cbm_daemon_application_watch_t *watches = application->watches; + application->watches = NULL; + cbm_daemon_application_job_t *jobs = application->jobs; + application->jobs = NULL; + cbm_daemon_application_watch_job_subscription_t *watch_job_subscriptions = + application->watch_job_subscriptions; + application->watch_job_subscriptions = NULL; + cbm_daemon_application_mutation_t *mutations = application->mutations; + application->mutations = NULL; + cbm_mutex_unlock(&application->mutex); + while (sessions) { + cbm_daemon_application_session_t *next = sessions->next; + cbm_mcp_server_free(sessions->mcp); + free(sessions); + sessions = next; + } + while (watches) { + cbm_daemon_application_watch_t *next = watches->next; + if (application->watcher) { + cbm_watcher_unwatch(application->watcher, watches->project); + } + free(watches->project); + free(watches->root); + free(watches); + watches = next; + } + while (watch_job_subscriptions) { + cbm_daemon_application_watch_job_subscription_t *next = + watch_job_subscriptions->next; + free(watch_job_subscriptions); + watch_job_subscriptions = next; + } + while (jobs) { + cbm_daemon_application_job_t *next = jobs->next; + if (jobs->thread_started) { + (void)cbm_thread_join(&jobs->thread); + } + application_job_free(jobs); + jobs = next; + } + while (mutations) { + cbm_daemon_application_mutation_t *next = mutations->next; + free(mutations->project_key); + free(mutations); + mutations = next; + } + cbm_mutex_destroy(&application->mutex); + free(application); +} + +cbm_daemon_runtime_application_callbacks_t cbm_daemon_application_runtime_callbacks( + cbm_daemon_application_t *application) { + cbm_daemon_runtime_application_callbacks_t callbacks = { + .context = application, + .session_open = application_session_open, + .request = application_request, + .request_cancel = application_request_cancel, + .session_cancel = application_session_cancel, + .session_close = application_session_close, + }; + if (!application) { + memset(&callbacks, 0, sizeof(callbacks)); + } + return callbacks; +} + +static cbm_daemon_runtime_application_status_t +application_client_exchange_tagged( + cbm_daemon_runtime_client_t *client, + cbm_daemon_runtime_application_token_t request_token, uint8_t *request, + uint32_t request_length, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms) { + uint8_t *response = NULL; + uint32_t response_length = 0; + if (response_out) { + *response_out = NULL; + } + if (response_length_out) { + *response_length_out = 0; + } + cbm_daemon_runtime_application_status_t status = + request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID + ? cbm_daemon_runtime_client_application_request( + client, request, request_length, &response, + &response_length, timeout_ms) + : cbm_daemon_runtime_client_application_request_tagged( + client, request_token, request, request_length, &response, + &response_length, timeout_ms); + free(request); + if (status != CBM_DAEMON_RUNTIME_APPLICATION_OK) { + free(response); + return status; + } + if (response) { + uint8_t *terminated = realloc(response, (size_t)response_length + 1U); + if (!terminated) { + free(response); + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + terminated[response_length] = '\0'; + response = terminated; + } + if (response_out) { + *response_out = response; + } else { + free(response); + } + if (response_length_out) { + *response_length_out = response_length; + } + return status; +} + +static cbm_daemon_runtime_application_status_t application_client_exchange( + cbm_daemon_runtime_client_t *client, uint8_t *request, + uint32_t request_length, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms) { + return application_client_exchange_tagged( + client, CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID, request, + request_length, response_out, response_length_out, timeout_ms); +} + +cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_context( + cbm_daemon_runtime_client_t *client, const char *session_root, + const char *allowed_root, cbm_mcp_tool_profile_t tool_profile, + const char *hook_event, const char *hook_dialect, + uint32_t timeout_ms) { + if (!client || !session_root || !session_root[0]) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + size_t root_length = strlen(session_root); + size_t allowed_length = allowed_root ? strlen(allowed_root) : 0; + size_t event_length = hook_event ? strlen(hook_event) : 0; + size_t dialect_length = hook_dialect ? strlen(hook_dialect) : 0; + uint64_t total = (uint64_t)APPLICATION_CONTEXT_HEADER_SIZE + root_length + + allowed_length + event_length + dialect_length; + if (tool_profile < CBM_MCP_TOOL_PROFILE_ALL || + tool_profile > CBM_MCP_TOOL_PROFILE_SCOUT || + (tool_profile != CBM_MCP_TOOL_PROFILE_ALL && + (event_length != 0 || dialect_length != 0)) || + !cbm_hook_augment_invocation_supported(hook_event, hook_dialect) || + root_length > UINT32_MAX || allowed_length > UINT32_MAX || + event_length > UINT32_MAX || dialect_length > UINT32_MAX || + total > CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + uint8_t *request = calloc(1, (size_t)total); + if (!request) { + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + request[0] = CBM_DAEMON_APPLICATION_REQUEST_SET_CONTEXT; + application_put_u32(request + 1, (uint32_t)root_length); + request[5] = allowed_root ? 1U : 0U; + application_put_u32(request + 6, (uint32_t)allowed_length); + request[10] = (uint8_t)tool_profile; + application_put_u32(request + 11, (uint32_t)event_length); + application_put_u32(request + 15, (uint32_t)dialect_length); + memcpy(request + APPLICATION_CONTEXT_HEADER_SIZE, session_root, root_length); + if (allowed_root) { + memcpy(request + APPLICATION_CONTEXT_HEADER_SIZE + root_length, allowed_root, + allowed_length); + } + if (hook_event) { + memcpy(request + APPLICATION_CONTEXT_HEADER_SIZE + root_length + + allowed_length, + hook_event, event_length); + } + if (hook_dialect) { + memcpy(request + APPLICATION_CONTEXT_HEADER_SIZE + root_length + + allowed_length + event_length, + hook_dialect, dialect_length); + } + uint8_t *unexpected = NULL; + uint32_t unexpected_length = 0; + cbm_daemon_runtime_application_status_t status = application_client_exchange( + client, request, (uint32_t)total, &unexpected, &unexpected_length, timeout_ms); + if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK && (unexpected || unexpected_length != 0)) { + status = CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + free(unexpected); + return status; +} + +cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_ui_config( + cbm_daemon_runtime_client_t *client, uint8_t update_mask, + bool ui_enabled, int ui_port, uint32_t timeout_ms) { + const uint8_t valid_mask = + CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED | + CBM_DAEMON_APPLICATION_UI_CONFIG_PORT; + bool enabled_present = + (update_mask & CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED) != 0; + bool port_present = + (update_mask & CBM_DAEMON_APPLICATION_UI_CONFIG_PORT) != 0; + if (!client || update_mask == 0 || + (update_mask & (uint8_t)~valid_mask) != 0 || + (!enabled_present && ui_enabled) || + (!port_present && ui_port != 0) || + (port_present && (ui_port <= 0 || ui_port > 65535))) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + uint8_t *request = calloc(1, APPLICATION_UI_CONFIG_REQUEST_SIZE); + if (!request) { + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + request[0] = CBM_DAEMON_APPLICATION_REQUEST_SET_UI_CONFIG; + request[1] = update_mask; + request[2] = enabled_present && ui_enabled ? 1U : 0U; + if (port_present) { + application_put_u32(request + 3, (uint32_t)ui_port); + } + uint8_t *unexpected = NULL; + uint32_t unexpected_length = 0; + cbm_daemon_runtime_application_status_t status = + application_client_exchange( + client, request, APPLICATION_UI_CONFIG_REQUEST_SIZE, &unexpected, + &unexpected_length, timeout_ms); + if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + (unexpected || unexpected_length != 0)) { + status = CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + free(unexpected); + return status; +} + +static cbm_daemon_runtime_application_status_t +application_client_text_request_tagged( + cbm_daemon_runtime_client_t *client, + cbm_daemon_runtime_application_token_t request_token, + cbm_daemon_application_request_kind_t kind, const char *text, + uint8_t **response_out, uint32_t *response_length_out, + uint32_t timeout_ms) { + if (!client || !text || !text[0]) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + size_t text_length = strlen(text); + if (text_length + 1U > CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + uint8_t *request = malloc(text_length + 1U); + if (!request) { + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + request[0] = (uint8_t)kind; + memcpy(request + 1, text, text_length); + return application_client_exchange_tagged( + client, request_token, request, (uint32_t)text_length + 1U, + response_out, response_length_out, timeout_ms); +} + +static cbm_daemon_runtime_application_status_t application_client_text_request( + cbm_daemon_runtime_client_t *client, + cbm_daemon_application_request_kind_t kind, const char *text, + uint8_t **response_out, uint32_t *response_length_out, + uint32_t timeout_ms) { + return application_client_text_request_tagged( + client, CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID, kind, text, + response_out, response_length_out, timeout_ms); +} + +cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp( + cbm_daemon_runtime_client_t *client, const char *message, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms) { + return application_client_text_request(client, CBM_DAEMON_APPLICATION_REQUEST_MCP, message, + response_out, response_length_out, timeout_ms); +} + +cbm_daemon_runtime_application_status_t +cbm_daemon_application_client_mcp_tagged( + cbm_daemon_runtime_client_t *client, + cbm_daemon_runtime_application_token_t request_token, + const char *message, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms) { + return application_client_text_request_tagged( + client, request_token, CBM_DAEMON_APPLICATION_REQUEST_MCP, message, + response_out, response_length_out, timeout_ms); +} + +cbm_daemon_runtime_application_status_t cbm_daemon_application_client_tool( + cbm_daemon_runtime_client_t *client, const char *tool_name, const char *args_json, + uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms) { + if (!client || !tool_name || !tool_name[0] || !args_json || !args_json[0]) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + size_t tool_length = strlen(tool_name); + size_t args_length = strlen(args_json); + uint64_t total = (uint64_t)APPLICATION_TOOL_HEADER_SIZE + tool_length + args_length; + if (tool_length > UINT32_MAX || total > CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX) { + return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + } + uint8_t *request = malloc((size_t)total); + if (!request) { + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + request[0] = CBM_DAEMON_APPLICATION_REQUEST_TOOL; + application_put_u32(request + 1, (uint32_t)tool_length); + memcpy(request + APPLICATION_TOOL_HEADER_SIZE, tool_name, tool_length); + memcpy(request + APPLICATION_TOOL_HEADER_SIZE + tool_length, args_json, args_length); + return application_client_exchange(client, request, (uint32_t)total, response_out, + response_length_out, timeout_ms); +} + +cbm_daemon_runtime_application_status_t cbm_daemon_application_client_hook_augment( + cbm_daemon_runtime_client_t *client, const char *input_json, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms) { + return application_client_text_request(client, CBM_DAEMON_APPLICATION_REQUEST_HOOK_AUGMENT, + input_json, response_out, response_length_out, + timeout_ms); +} + +static int application_background_index(cbm_daemon_application_t *application, + const char *project_name, + const char *root_path, + bool require_live_watch) { + if (!application || !project_name || !root_path) { + return -1; + } + char canonical_root[APPLICATION_PATH_CAP]; + struct stat root_status; + if (!cbm_canonical_path(root_path, canonical_root, sizeof(canonical_root)) || + stat(canonical_root, &root_status) != 0 || !S_ISDIR(root_status.st_mode)) { + return -1; + } + yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; + if (!document || !root) { + yyjson_mut_doc_free(document); + return -1; + } + yyjson_mut_doc_set_root(document, root); + bool encoded = yyjson_mut_obj_add_strcpy(document, root, "repo_path", canonical_root); + char *default_project = cbm_project_name_from_path(canonical_root); + bool custom_project = + project_name[0] && (!default_project || strcmp(default_project, project_name) != 0); + if (encoded && custom_project) { + encoded = yyjson_mut_obj_add_strcpy(document, root, "name", project_name); + } + free(default_project); + char *args = encoded ? yyjson_mut_write(document, 0, NULL) : NULL; + yyjson_mut_doc_free(document); + if (!args) { + return -1; + } + char *project_key = application_index_project_key(canonical_root, args); + if (!project_key) { + free(args); + return -1; + } + + application_job_subscribe_status_t subscribe_status = + APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE; + application_jobs_reap_completed(application); + cbm_mutex_lock(&application->mutex); + cbm_daemon_application_watch_t *watch = + require_live_watch + ? application_find_watch_locked(application, project_name) + : NULL; + bool watch_live = !require_live_watch || + (watch && watch->subscribers > 0 && + strcmp(watch->root, canonical_root) == 0); + size_t watch_owner_count = 0; + bool watch_subscriptions_ok = true; + cbm_daemon_application_job_t *job = + watch_live ? application_job_subscribe_locked( + application, project_key, canonical_root, args, + &subscribe_status) + : NULL; + if (job && require_live_watch) { + watch_subscriptions_ok = + application_watch_job_subscribe_sessions_locked( + application, watch, job, &watch_owner_count); + if (watch_subscriptions_ok && watch_owner_count > 0) { + job->watcher_waiters++; + } else if (!watch_subscriptions_ok) { + subscribe_status = APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED; + } + /* application_job_subscribe_locked() lends the caller one ordinary + * subscriber. A watcher callback is only a storage waiter: exact live + * session subscriptions above own the physical work. */ + application_job_unsubscribe_locked(job); + if (!watch_subscriptions_ok || watch_owner_count == 0) { + job = NULL; + } + } + cbm_mutex_unlock(&application->mutex); + free(project_key); + free(args); + if (!job) { + if (require_live_watch && + (!watch_live || (watch_subscriptions_ok && watch_owner_count == 0))) { + /* The physical watcher can retain a poll snapshot after the last + * owning session unwatches it. Treat that stale callback as a + * harmless skip; no job was admitted. */ + return 1; + } + return subscribe_status == APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT || + subscribe_status == APPLICATION_JOB_SUBSCRIBE_BUSY || + subscribe_status == APPLICATION_JOB_SUBSCRIBE_CANCELLING + ? 1 + : -1; + } + bool successful = false; + bool cancelled = false; + for (;;) { + cbm_mutex_lock(&application->mutex); + if (job->terminal) { + successful = job->successful; + cancelled = job->cancelled; + if (require_live_watch) { + application_watch_job_unsubscribe_job_locked(application, job); + if (job->watcher_waiters > 0) { + job->watcher_waiters--; + } + } else { + application_job_unsubscribe_locked(job); + } + cbm_mutex_unlock(&application->mutex); + break; + } + cbm_mutex_unlock(&application->mutex); + cbm_usleep(APPLICATION_JOB_POLL_US); + } + application_jobs_reap_completed(application); + return successful ? 0 : (cancelled ? 1 : -1); +} + +int cbm_daemon_application_index(cbm_daemon_application_t *application, + const char *project_name, + const char *root_path) { + return application_background_index(application, project_name, root_path, + false); +} + +int cbm_daemon_application_watcher_index(const char *project_name, const char *root_path, + void *context) { + return application_background_index(context, project_name, root_path, true); +} + +size_t cbm_daemon_application_active_jobs(cbm_daemon_application_t *application) { + if (!application) { + return 0; + } + size_t count = 0; + cbm_mutex_lock(&application->mutex); + for (cbm_daemon_application_job_t *job = application->jobs; job; job = job->next) { + if (!job->terminal) { + count++; + } + } + cbm_mutex_unlock(&application->mutex); + return count; +} + +size_t cbm_daemon_application_job_subscribers(cbm_daemon_application_t *application, + const char *project_key) { + if (!application || !project_key) { + return 0; + } + cbm_mutex_lock(&application->mutex); + cbm_daemon_application_job_t *job = application_find_job_locked(application, project_key); + size_t subscribers = job ? job->subscribers : 0; + cbm_mutex_unlock(&application->mutex); + return subscribers; +} + +bool cbm_daemon_application_session_retains_store_for_test( + const cbm_daemon_runtime_application_session_t *opaque_session) { + const cbm_daemon_application_session_t *session = + (const cbm_daemon_application_session_t *)opaque_session; + return session && session->mcp && cbm_mcp_server_store(session->mcp) != NULL; +} diff --git a/src/daemon/application.h b/src/daemon/application.h new file mode 100644 index 000000000..24728c7d3 --- /dev/null +++ b/src/daemon/application.h @@ -0,0 +1,145 @@ +/* + * application.h — Daemon-owned CBM application sessions and thin-client wire. + * + * The runtime authenticates a local process and owns connection lifetime. This + * layer owns everything above that boundary: one isolated MCP session per + * connection, explicit workspace context, shared watcher subscriptions, and + * daemon-owned index jobs. Frontends never construct stores or watchers. + */ +#ifndef CBM_DAEMON_APPLICATION_H +#define CBM_DAEMON_APPLICATION_H + +#include "daemon/runtime.h" +#include "daemon/project_lock.h" +#include "mcp/index_supervisor.h" +#include "mcp/mcp.h" + +#include +#include +#include + +struct cbm_config; +struct cbm_watcher; + +typedef struct cbm_daemon_application cbm_daemon_application_t; +typedef void *cbm_daemon_application_worker_t; + +/* Injectable physical-worker boundary. Production uses index_supervisor; + * tests use this to deterministically hold/release one shared job. */ +typedef struct { + void *context; + int (*start)(void *context, const char *args_json, size_t memory_budget_bytes, + const char *marker_file, const char *quarantine_file, + cbm_daemon_application_worker_t *worker_out); + cbm_index_worker_poll_t (*poll)(void *context, cbm_daemon_application_worker_t worker, + const cbm_index_worker_result_t **result_out); + bool (*cancel)(void *context, cbm_daemon_application_worker_t worker); + const char *(*log_path)(void *context, cbm_daemon_application_worker_t worker); + void (*destroy)(void *context, cbm_daemon_application_worker_t worker); +} cbm_daemon_application_worker_ops_t; + +typedef struct { + struct cbm_watcher *watcher; /* borrowed; daemon lifetime */ + struct cbm_config *config; /* borrowed; daemon lifetime */ + const cbm_daemon_application_worker_ops_t *worker_ops; /* NULL = production */ + /* Maximum distinct, non-terminal physical index jobs. Zero selects the + * conservative daemon default (4). Identical requests still coalesce even + * while the limit is full. */ + size_t physical_job_limit; + /* Aggregate budget shared by every concurrently admitted physical worker. + * The daemon host supplies its already-resolved CBM process budget. */ + size_t aggregate_memory_budget_bytes; + /* Borrowed native-lock manager for daemon-owned mutations (MCP tools, + * watcher/UI operations). Index workers create and own their independent + * process-local manager; the daemon job registry reserves index projects + * in-process and must not hold the worker's OS lease. */ + cbm_project_lock_manager_t *project_locks; +} cbm_daemon_application_config_t; + +typedef enum { + CBM_DAEMON_APPLICATION_REQUEST_SET_CONTEXT = 1, + CBM_DAEMON_APPLICATION_REQUEST_MCP = 2, + CBM_DAEMON_APPLICATION_REQUEST_TOOL = 3, + CBM_DAEMON_APPLICATION_REQUEST_HOOK_AUGMENT = 4, + CBM_DAEMON_APPLICATION_REQUEST_SET_UI_CONFIG = 5, +} cbm_daemon_application_request_kind_t; + +typedef enum { + CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED = 1U << 0, + CBM_DAEMON_APPLICATION_UI_CONFIG_PORT = 1U << 1, +} cbm_daemon_application_ui_config_mask_t; + +cbm_daemon_application_t *cbm_daemon_application_new(const cbm_daemon_application_config_t *config); + +/* Cancel and reap all daemon-owned operations within timeout_ms. Normal final + * client shutdown calls this before watcher/store teardown. Idempotent. */ +bool cbm_daemon_application_shutdown(cbm_daemon_application_t *application, uint32_t timeout_ms); +void cbm_daemon_application_free(cbm_daemon_application_t *application); + +/* Callbacks are borrowed from application and remain valid until free. */ +cbm_daemon_runtime_application_callbacks_t cbm_daemon_application_runtime_callbacks( + cbm_daemon_application_t *application); + +/* Thin-client request helpers. Every helper performs one bounded runtime + * exchange. MCP notifications succeed with a NULL/zero response. Returned + * response bytes are malloc-owned and include one trailing NUL for text use; + * response_length excludes that terminator. */ +cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_context( + cbm_daemon_runtime_client_t *client, const char *session_root, + const char *allowed_root, cbm_mcp_tool_profile_t tool_profile, + const char *hook_event, const char *hook_dialect, uint32_t timeout_ms); + +/* Persist a masked UI configuration mutation in the daemon. A zero/unknown + * mask, an invalid port, or a non-canonical unused field is rejected before + * transport. */ +cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_ui_config( + cbm_daemon_runtime_client_t *client, uint8_t update_mask, bool ui_enabled, + int ui_port, uint32_t timeout_ms); + +cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp( + cbm_daemon_runtime_client_t *client, const char *message, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms); + +/* Cancellable frontend variant using a token reserved on the runtime client. */ +cbm_daemon_runtime_application_status_t +cbm_daemon_application_client_mcp_tagged( + cbm_daemon_runtime_client_t *client, + cbm_daemon_runtime_application_token_t request_token, + const char *message, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms); + +cbm_daemon_runtime_application_status_t cbm_daemon_application_client_tool( + cbm_daemon_runtime_client_t *client, const char *tool_name, const char *args_json, + uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms); + +cbm_daemon_runtime_application_status_t cbm_daemon_application_client_hook_augment( + cbm_daemon_runtime_client_t *client, const char *input_json, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms); + +/* Watcher callback: atomically validates project/root ownership and subscribes + * the shared physical job to every exact live session watch. The callback is + * only a result waiter, so disconnecting the final matching owner cancels the + * job even while unrelated daemon sessions remain. Returns 0 on success, + * positive when stale, cancelled, or busy (retry), and negative on a terminal + * worker error. */ +int cbm_daemon_application_watcher_index(const char *project_name, const char *root_path, + void *context); + +/* Shared daemon/UI entry point for a default full index operation. */ +int cbm_daemon_application_index(cbm_daemon_application_t *application, const char *project_name, + const char *root_path); + +/* Non-blocking mutation lease for daemon-owned UI endpoints. A successful + * begin must be paired with end. Returns false while the project (or global + * project set "*") is reserved by indexing, another mutation, or shutdown. */ +bool cbm_daemon_application_project_mutation_try_begin( + cbm_daemon_application_t *application, const char *project); +void cbm_daemon_application_project_mutation_end( + cbm_daemon_application_t *application, const char *project); + +/* Read-only coordination metrics used by daemon diagnostics/tests. */ +size_t cbm_daemon_application_active_jobs(cbm_daemon_application_t *application); +size_t cbm_daemon_application_job_subscribers(cbm_daemon_application_t *application, + const char *project_key); + +#endif /* CBM_DAEMON_APPLICATION_H */ diff --git a/src/daemon/application_internal.h b/src/daemon/application_internal.h new file mode 100644 index 000000000..22c65f378 --- /dev/null +++ b/src/daemon/application_internal.h @@ -0,0 +1,16 @@ +/* + * application_internal.h — Private daemon application test seams. + */ +#ifndef CBM_DAEMON_APPLICATION_INTERNAL_H +#define CBM_DAEMON_APPLICATION_INTERNAL_H + +#include "daemon/application.h" + +#include + +/* Read-only test diagnostic for a live session. The caller must serialize + * this check with request, cancellation, and close callbacks. */ +bool cbm_daemon_application_session_retains_store_for_test( + const cbm_daemon_runtime_application_session_t *session); + +#endif /* CBM_DAEMON_APPLICATION_INTERNAL_H */ diff --git a/src/daemon/bootstrap.c b/src/daemon/bootstrap.c new file mode 100644 index 000000000..c2399ec65 --- /dev/null +++ b/src/daemon/bootstrap.c @@ -0,0 +1,811 @@ +/* + * bootstrap.c — Mandatory per-account daemon startup policy. + */ +#include "daemon/bootstrap.h" + +#include "daemon/ipc.h" +#include "daemon/service.h" +#include "foundation/compat.h" +#include "foundation/platform.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include "foundation/subprocess.h" +#include "foundation/win_utf8.h" +#include +#else +#include +#include +#include +#include +#include +#include +#include +#endif + +enum { + BOOTSTRAP_RETRY_NS = 1000000, + BOOTSTRAP_COORDINATION_CLEANUP_MS = 500, + BOOTSTRAP_PATH_CAP = 4096, +}; + +static bool bootstrap_arg_is(const char *arg, const char *expected) { + return arg && expected && strcmp(arg, expected) == 0; +} + +static int bootstrap_find_arg(int argc, char *const argv[], const char *expected) { + for (int i = 1; argv && i < argc; i++) { + if (bootstrap_arg_is(argv[i], expected)) { + return i; + } + } + return -1; +} + +static bool bootstrap_has_help_after(int argc, char *const argv[], int start) { + for (int i = start; argv && i < argc; i++) { + if (bootstrap_arg_is(argv[i], "--help") || bootstrap_arg_is(argv[i], "-h")) { + return true; + } + } + return false; +} + +static bool bootstrap_worker_fingerprint_valid(const char *fingerprint) { + if (!fingerprint || strlen(fingerprint) != 64U) { + return false; + } + for (size_t i = 0; i < 64U; i++) { + char ch = fingerprint[i]; + if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) { + return false; + } + } + return true; +} + +static bool bootstrap_worker_budget_valid(const char *text) { + if (!text || !text[0]) { + return false; + } + size_t value = 0; + for (const unsigned char *cursor = (const unsigned char *)text; *cursor; cursor++) { + if (*cursor < '0' || *cursor > '9') { + return false; + } + size_t digit = (size_t)(*cursor - '0'); + if (value > (SIZE_MAX - digit) / 10U) { + return false; + } + value = value * 10U + digit; + } + return value > 0; +} + +/* Keep the bootstrap role boundary exact and fail closed before any client or + * worker state is initialized. index_supervisor owns the matching builder and + * performs the captured-build comparison after this syntax-only classification. */ +static bool bootstrap_worker_argv_exact(int argc, char *const argv[]) { + if (argc < 9 || !argv || !bootstrap_arg_is(argv[1], "cli") || + !bootstrap_arg_is(argv[2], "--index-worker") || + !bootstrap_arg_is(argv[3], "--index-worker-build") || + !bootstrap_worker_fingerprint_valid(argv[4]) || + !bootstrap_arg_is(argv[5], "index_repository") || !argv[6] || !argv[6][0] || + !bootstrap_arg_is(argv[7], "--response-out") || !argv[8] || !argv[8][0]) { + return false; + } + int next = 9; + if (next < argc && + bootstrap_arg_is(argv[next], "--index-worker-memory-budget-bytes")) { + if (next + 1 >= argc || !bootstrap_worker_budget_valid(argv[next + 1])) { + return false; + } + next += 2; + } + if (next < argc && bootstrap_arg_is(argv[next], "--index-worker-single-thread")) { + next++; + } + if (next < argc && bootstrap_arg_is(argv[next], "--index-worker-marker")) { + if (next + 1 >= argc || !argv[next + 1] || !argv[next + 1][0]) { + return false; + } + next += 2; + } + if (next < argc && bootstrap_arg_is(argv[next], "--index-worker-quarantine")) { + if (next + 1 >= argc || !argv[next + 1] || !argv[next + 1][0]) { + return false; + } + next += 2; + } + return next == argc; +} + +cbm_daemon_process_role_t cbm_daemon_process_role(int argc, char *const argv[]) { + if (argc <= 0 || !argv || !argv[0] || argv[0][0] == '\0') { + return CBM_DAEMON_PROCESS_INVALID; + } + + int daemon_arg = bootstrap_find_arg(argc, argv, CBM_DAEMON_INTERNAL_ARG); + if (daemon_arg >= 0) { + return argc == 2 && daemon_arg == 1 ? CBM_DAEMON_PROCESS_DAEMON + : CBM_DAEMON_PROCESS_INVALID; + } + + int worker_arg = bootstrap_find_arg(argc, argv, "--index-worker"); + if (worker_arg >= 0) { + return bootstrap_worker_argv_exact(argc, argv) ? CBM_DAEMON_PROCESS_WORKER + : CBM_DAEMON_PROCESS_INVALID; + } + + static const char *const stateless_commands[] = { + "install", + "uninstall", + "update", + }; + /* Stop at the first top-level mode token. Tool names, flag values, and JSON + * following `cli` are opaque user input: a search query named "install" + * or containing "--version" must never bypass the mandatory daemon. */ + for (int arg = 1; arg < argc; arg++) { + if (bootstrap_arg_is(argv[arg], "cli")) { + if (bootstrap_has_help_after(argc, argv, arg + 1)) { + return CBM_DAEMON_PROCESS_STATELESS; + } + return CBM_DAEMON_PROCESS_LOCAL_CLI; + } + if (bootstrap_arg_is(argv[arg], "hook-augment")) { + return CBM_DAEMON_PROCESS_HOOK_CLIENT; + } + if (bootstrap_arg_is(argv[arg], "config")) { + return bootstrap_has_help_after(argc, argv, arg + 1) + ? CBM_DAEMON_PROCESS_STATELESS + : CBM_DAEMON_PROCESS_LOCAL_CLI; + } + if (bootstrap_arg_is(argv[arg], "--version") || + bootstrap_arg_is(argv[arg], "--help") || + bootstrap_arg_is(argv[arg], "-h")) { + return CBM_DAEMON_PROCESS_STATELESS; + } + for (size_t command = 0; + command < sizeof(stateless_commands) / sizeof(stateless_commands[0]); + command++) { + if (bootstrap_arg_is(argv[arg], stateless_commands[command])) { + return CBM_DAEMON_PROCESS_STATELESS; + } + } + } + return CBM_DAEMON_PROCESS_MCP_CLIENT; +} + +bool cbm_daemon_process_role_requires_client(cbm_daemon_process_role_t role) { + return role == CBM_DAEMON_PROCESS_MCP_CLIENT || role == CBM_DAEMON_PROCESS_HOOK_CLIENT; +} + +cbm_daemon_ipc_endpoint_t *cbm_daemon_bootstrap_endpoint_new(const char *runtime_parent) { + char key[CBM_DAEMON_KEY_SIZE]; + if (!cbm_daemon_rendezvous_key(key)) { + return NULL; + } + return cbm_daemon_ipc_endpoint_new(key, runtime_parent); +} + +bool cbm_daemon_bootstrap_launch_spec_init(const char *executable_path, + cbm_daemon_bootstrap_launch_spec_t *spec_out) { + if (!executable_path || executable_path[0] == '\0' || !spec_out) { + return false; + } + memset(spec_out, 0, sizeof(*spec_out)); + spec_out->executable_path = executable_path; + spec_out->argv[0] = executable_path; + spec_out->argv[1] = CBM_DAEMON_INTERNAL_ARG; + spec_out->argc = CBM_DAEMON_BOOTSTRAP_LAUNCH_ARGC; + spec_out->detached = true; + spec_out->inherit_standard_handles = false; + spec_out->use_shell = false; + return true; +} + +static uint64_t bootstrap_deadline_after(uint32_t timeout_ms) { + uint64_t now = cbm_now_ms(); + return UINT64_MAX - now < timeout_ms ? UINT64_MAX : now + (uint64_t)timeout_ms; +} + +static _Noreturn void bootstrap_cleanup_fail_stop(const char *component) { + (void)fprintf(stderr, + "codebase-memory-mcp: coordination cleanup failed (%s); " + "terminating so the OS releases retained claims\n", + component ? component : "unknown"); + (void)fflush(stderr); +#ifdef _WIN32 + (void)TerminateProcess(GetCurrentProcess(), EXIT_FAILURE); + abort(); +#else + _exit(EXIT_FAILURE); +#endif +} + +static void bootstrap_pause(uint64_t deadline) { + uint64_t now = cbm_now_ms(); + if (now >= deadline) { + return; + } + uint64_t remaining_ms = deadline - now; + struct timespec pause = { + .tv_sec = 0, + .tv_nsec = remaining_ms > 1 + ? BOOTSTRAP_RETRY_NS + : (long)(remaining_ms * 1000000ULL), + }; + (void)cbm_nanosleep(&pause, NULL); +} + +static void bootstrap_startup_lock_release_complete( + const cbm_daemon_bootstrap_ops_t *ops, + cbm_daemon_bootstrap_lock_t *lock_io) { + uint64_t deadline = + bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS); + while (ops && ops->startup_lock_release && lock_io && *lock_io) { + (void)ops->startup_lock_release(ops->context, lock_io); + if (!*lock_io) { + return; + } + if (cbm_now_ms() >= deadline) { + bootstrap_cleanup_fail_stop("startup_lock_cleanup"); + } + cbm_usleep(1000); + } +} + +static void bootstrap_result_reset(cbm_daemon_bootstrap_result_t *result, + cbm_daemon_process_role_t role) { + memset(result, 0, sizeof(*result)); + result->status = cbm_daemon_process_role_requires_client(role) ? CBM_DAEMON_BOOTSTRAP_FAILED + : CBM_DAEMON_BOOTSTRAP_BYPASSED; +} + +static cbm_daemon_bootstrap_status_t bootstrap_finish_probe( + cbm_daemon_bootstrap_probe_status_t probe, cbm_daemon_runtime_client_t *client, + const cbm_daemon_runtime_connect_result_t *connect_result, + const cbm_daemon_bootstrap_ops_t *ops, cbm_daemon_bootstrap_result_t *result) { + if (connect_result) { + result->connect_result = *connect_result; + } + result->client = client; + if (probe == CBM_DAEMON_BOOTSTRAP_PROBE_CONNECTED && client) { + result->status = CBM_DAEMON_BOOTSTRAP_CONNECTED; + return result->status; + } + result->client = NULL; + if (probe == CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT) { + result->status = CBM_DAEMON_BOOTSTRAP_CONFLICT; + (void)snprintf(result->message, sizeof(result->message), "%s", + connect_result && connect_result->message[0] + ? connect_result->message + : "CBM could not start because a conflicting CBM process is active; close all CBM sessions and commands, then retry"); + if (ops->visible_diagnostic) { + ops->visible_diagnostic(ops->context, result->message); + } + return result->status; + } + return CBM_DAEMON_BOOTSTRAP_FAILED; +} + +static cbm_daemon_bootstrap_probe_status_t bootstrap_probe( + const cbm_daemon_bootstrap_config_t *config, const cbm_daemon_bootstrap_ops_t *ops, + cbm_daemon_runtime_client_t **client_out, cbm_daemon_runtime_connect_result_t *connect_result) { + memset(connect_result, 0, sizeof(*connect_result)); + *client_out = NULL; + return ops->probe(ops->context, config->endpoint, config->identity, config->connect_timeout_ms, + client_out, connect_result); +} + +static bool bootstrap_probe_is_finishable(cbm_daemon_bootstrap_probe_status_t probe) { + return probe == CBM_DAEMON_BOOTSTRAP_PROBE_CONNECTED || + probe == CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT; +} + +static bool bootstrap_probe_is_waitable(cbm_daemon_bootstrap_probe_status_t probe) { + return probe == CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE || + probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED || + probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL; +} + +static bool bootstrap_config_valid(const cbm_daemon_bootstrap_config_t *config, + const cbm_daemon_bootstrap_ops_t *ops) { + return config && ops && config->endpoint && config->identity && config->executable_path && + config->executable_path[0] && config->connect_timeout_ms > 0 && + config->startup_timeout_ms > 0 && ops->cohort_acquire && + ops->cohort_release && ops->probe && ops->startup_lock_try_acquire && + ops->startup_lock_prepare_handoff && ops->startup_lock_release && + ops->spawn_daemon; +} + +cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops( + const cbm_daemon_bootstrap_config_t *config, const cbm_daemon_bootstrap_ops_t *ops, + cbm_daemon_bootstrap_result_t *result_out) { + if (!result_out) { + return CBM_DAEMON_BOOTSTRAP_FAILED; + } + cbm_daemon_process_role_t role = config ? config->role : CBM_DAEMON_PROCESS_INVALID; + bootstrap_result_reset(result_out, role); + if (!cbm_daemon_process_role_requires_client(role)) { + return role == CBM_DAEMON_PROCESS_INVALID ? CBM_DAEMON_BOOTSTRAP_FAILED + : CBM_DAEMON_BOOTSTRAP_BYPASSED; + } + if (!bootstrap_config_valid(config, ops)) { + return CBM_DAEMON_BOOTSTRAP_FAILED; + } + + uint64_t deadline = bootstrap_deadline_after(config->startup_timeout_ms); + cbm_daemon_bootstrap_cohort_t cohort = NULL; + cbm_daemon_conflict_t cohort_conflict; + cbm_version_cohort_status_t cohort_status = ops->cohort_acquire( + ops->context, config->endpoint, config->identity, deadline, &cohort, + &cohort_conflict); + if (cohort_status != CBM_VERSION_COHORT_OK) { + result_out->status = cohort_status == CBM_VERSION_COHORT_CONFLICT + ? CBM_DAEMON_BOOTSTRAP_CONFLICT + : CBM_DAEMON_BOOTSTRAP_FAILED; + bool formatted = cohort_status == CBM_VERSION_COHORT_CONFLICT && + cbm_daemon_conflict_format( + &cohort_conflict, result_out->message, + sizeof(result_out->message)); + if (!formatted) { + const char *reason = + cohort_status == CBM_VERSION_COHORT_BUSY + ? "another CBM activation is in progress" + : "exact-build admission could not be verified"; + (void)snprintf(result_out->message, sizeof(result_out->message), + "CBM daemon could not start: %s", reason); + } + if (ops->visible_diagnostic) { + ops->visible_diagnostic(ops->context, result_out->message); + } + if (cohort) { + ops->cohort_release(ops->context, cohort); + } + return result_out->status; + } + + cbm_daemon_runtime_client_t *client = NULL; + cbm_daemon_runtime_connect_result_t connect_result; + cbm_daemon_bootstrap_probe_status_t probe = + bootstrap_probe(config, ops, &client, &connect_result); + if (bootstrap_probe_is_finishable(probe)) { + cbm_daemon_bootstrap_status_t status = + bootstrap_finish_probe(probe, client, &connect_result, ops, + result_out); + ops->cohort_release(ops->context, cohort); + return status; + } + if (!bootstrap_probe_is_waitable(probe)) { + probe = CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; + } + + cbm_daemon_bootstrap_lock_t startup_lock = NULL; + bool lock_acquired = false; + bool generation_observed = + probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED || + probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL; + while (cbm_now_ms() < deadline) { + if (!bootstrap_probe_is_waitable(probe)) { + break; + } + if (probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED || + probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL) { + /* A live or stopping generation owns the transition for now. Its + * disappearance is not sticky: after observing true absence, the + * same bootstrap attempt may serialize and become the next first + * client. The startup lock and the re-probe below prevent two + * replacements from being launched. */ + generation_observed = true; + bootstrap_pause(deadline); + probe = bootstrap_probe(config, ops, &client, &connect_result); + continue; + } + + int lock_status = + ops->startup_lock_try_acquire(ops->context, config->endpoint, &startup_lock); + if (lock_status < 0) { + probe = CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; + break; + } + if (lock_status == 0) { + bootstrap_pause(deadline); + probe = bootstrap_probe(config, ops, &client, &connect_result); + continue; + } + if (lock_status != 1 || !startup_lock) { + probe = CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; + break; + } + + lock_acquired = true; + probe = bootstrap_probe(config, ops, &client, &connect_result); + if (probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED || + probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL) { + generation_observed = true; + bootstrap_startup_lock_release_complete(ops, &startup_lock); + lock_acquired = false; + continue; + } + if (bootstrap_probe_is_finishable(probe) || + probe == CBM_DAEMON_BOOTSTRAP_PROBE_ERROR) { + break; + } + if (probe != CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE) { + probe = CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; + break; + } + + cbm_daemon_bootstrap_launch_spec_t spec; + if (!cbm_daemon_bootstrap_launch_spec_init(config->executable_path, &spec) || + !ops->startup_lock_prepare_handoff(ops->context, startup_lock) || + !ops->spawn_daemon(ops->context, &spec)) { + probe = CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; + break; + } + result_out->daemon_spawned = true; + + /* Keep startup ownership until the child becomes observable. A + * RESERVED response here belongs to the generation we just launched, + * so it remains a wait state rather than a reason to release/spawn. */ + do { + bootstrap_pause(deadline); + probe = bootstrap_probe(config, ops, &client, &connect_result); + if (!bootstrap_probe_is_waitable(probe)) { + break; + } + } while (cbm_now_ms() < deadline); + break; + } + + if (lock_acquired) { + bootstrap_startup_lock_release_complete(ops, &startup_lock); + } + if (bootstrap_probe_is_finishable(probe)) { + cbm_daemon_bootstrap_status_t status = + bootstrap_finish_probe(probe, client, &connect_result, ops, + result_out); + ops->cohort_release(ops->context, cohort); + return status; + } + + result_out->status = CBM_DAEMON_BOOTSTRAP_FAILED; + if (generation_observed) { + (void)snprintf(result_out->message, sizeof(result_out->message), + "CBM daemon is active or starting but could not accept this client " + "within %u ms", + config->startup_timeout_ms); + } else { + (void)snprintf(result_out->message, sizeof(result_out->message), + "CBM daemon could not start within %u ms", config->startup_timeout_ms); + } + if (ops->visible_diagnostic) { + ops->visible_diagnostic(ops->context, result_out->message); + } + ops->cohort_release(ops->context, cohort); + return result_out->status; +} + +cbm_daemon_bootstrap_probe_status_t cbm_daemon_bootstrap_classify_failed_connect( + const cbm_daemon_runtime_connect_result_t *connect_result, int lifetime_status) { + if (!connect_result) { + return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; + } + if (connect_result->status == CBM_DAEMON_RUNTIME_CONNECT_CONFLICT) { + return CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT; + } + if (connect_result->status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED) { + if (strstr(connect_result->message, "stopping") || + strstr(connect_result->message, "shutting down")) { + return CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL; + } + /* Capacity, admission, and other protocol-level rejections prove an + * existing generation answered. Never reinterpret them as absence. */ + return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED; + } + if (lifetime_status == 1) { + return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED; + } + if (lifetime_status != 0) { + return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; + } + return connect_result->status == CBM_DAEMON_RUNTIME_CONNECT_ERROR + ? CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE + : CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; +} + +typedef struct bootstrap_production_cohort { + cbm_version_cohort_manager_t *manager; + cbm_version_cohort_lease_t *lease; +} bootstrap_production_cohort_t; + +typedef struct { + bootstrap_production_cohort_t *cohort; +} bootstrap_production_context_t; + +static cbm_daemon_bootstrap_probe_status_t bootstrap_production_probe( + void *context, const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, uint32_t timeout_ms, + cbm_daemon_runtime_client_t **client_out, cbm_daemon_runtime_connect_result_t *result_out) { + bootstrap_production_context_t *production = context; + if (!production || !production->cohort || + !production->cohort->manager) { + return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; + } + cbm_version_cohort_daemon_presence_t claim = + cbm_version_cohort_daemon_claim_presence( + production->cohort->manager); + if (claim == CBM_VERSION_COHORT_DAEMON_ABSENT) { + int lifetime = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + if (lifetime == 0) { + /* Do not spend the per-connect timeout polling a generation that + * both independent ownership signals prove absent. The startup + * lock and its mandatory re-probe serialize a concurrent launch. */ + return CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE; + } + if (lifetime != 1) { + return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; + } + } else if (claim != CBM_VERSION_COHORT_DAEMON_COORDINATED) { + return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; + } + + *client_out = cbm_daemon_runtime_client_connect(endpoint, identity, + timeout_ms, result_out); + if (*client_out) { + return CBM_DAEMON_BOOTSTRAP_PROBE_CONNECTED; + } + + /* Ownership may turn over while the connection attempt is in flight. + * Re-observe both signals so disappearance is not sticky and a live or + * cleaning-up generation is never mistaken for absence. */ + claim = cbm_version_cohort_daemon_claim_presence( + production->cohort->manager); + if (claim == CBM_VERSION_COHORT_DAEMON_COORDINATED) { + return cbm_daemon_bootstrap_classify_failed_connect(result_out, 1); + } + if (claim != CBM_VERSION_COHORT_DAEMON_ABSENT) { + return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; + } + int lifetime_status = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + return cbm_daemon_bootstrap_classify_failed_connect(result_out, lifetime_status); +} + +static cbm_version_cohort_status_t bootstrap_production_cohort_acquire( + void *context, const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms, + cbm_daemon_bootstrap_cohort_t *cohort_out, + cbm_daemon_conflict_t *conflict_out) { + *cohort_out = NULL; + bootstrap_production_cohort_t *cohort = calloc(1, sizeof(*cohort)); + if (cohort) { + cohort->manager = cbm_version_cohort_manager_new(endpoint); + } + if (!cohort || !cohort->manager) { + free(cohort); + return CBM_VERSION_COHORT_IO; + } + cbm_version_cohort_status_t status = cbm_version_cohort_acquire( + cohort->manager, identity, deadline_ms, &cohort->lease, conflict_out); + if (status == CBM_VERSION_COHORT_CONFLICT) { + (void)cbm_version_cohort_log_conflict(conflict_out); + } + if (status == CBM_VERSION_COHORT_OK || cohort->lease) { + *cohort_out = cohort; + if (context) { + bootstrap_production_context_t *production = context; + production->cohort = cohort; + } + return status; + } + uint64_t cleanup_deadline = + bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS); + cbm_private_file_lock_status_t cleanup = CBM_PRIVATE_FILE_LOCK_OK; + while (cohort->manager) { + cleanup = cbm_version_cohort_manager_free(&cohort->manager); + if (!cohort->manager) { + break; + } + if (cbm_now_ms() >= cleanup_deadline) { + bootstrap_cleanup_fail_stop("cohort_manager_cleanup"); + } + cbm_usleep(1000); + } + free(cohort); + return cleanup == CBM_PRIVATE_FILE_LOCK_OK ? status + : CBM_VERSION_COHORT_IO; +} + +static void bootstrap_production_cohort_release( + void *context, cbm_daemon_bootstrap_cohort_t opaque) { + bootstrap_production_context_t *production = context; + bootstrap_production_cohort_t *cohort = opaque; + if (!cohort) { + return; + } + uint64_t cleanup_deadline = + bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS); + while (cohort->lease) { + (void)cbm_version_cohort_lease_release(&cohort->lease); + if (!cohort->lease) { + break; + } + if (cbm_now_ms() >= cleanup_deadline) { + bootstrap_cleanup_fail_stop("cohort_lease_cleanup"); + } + cbm_usleep(1000); + } + cleanup_deadline = + bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS); + while (cohort->manager) { + (void)cbm_version_cohort_manager_free(&cohort->manager); + if (!cohort->manager) { + break; + } + if (cbm_now_ms() >= cleanup_deadline) { + bootstrap_cleanup_fail_stop("cohort_manager_cleanup"); + } + cbm_usleep(1000); + } + if (production && production->cohort == cohort) { + production->cohort = NULL; + } + free(cohort); +} + +static int bootstrap_production_lock(void *context, const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_bootstrap_lock_t *lock_out) { + (void)context; + cbm_daemon_ipc_startup_lock_t *lock = NULL; + int status = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &lock); + *lock_out = lock; + return status; +} + +static bool bootstrap_production_unlock( + void *context, cbm_daemon_bootstrap_lock_t *lock_io) { + (void)context; + if (!lock_io) { + return false; + } + cbm_daemon_ipc_startup_lock_t *lock = *lock_io; + bool released = cbm_daemon_ipc_startup_lock_release(&lock); + *lock_io = lock; + return released; +} + +static bool bootstrap_production_handoff(void *context, + cbm_daemon_bootstrap_lock_t lock) { + (void)context; + return cbm_daemon_ipc_startup_lock_prepare_handoff( + (cbm_daemon_ipc_startup_lock_t *)lock); +} + +#ifdef _WIN32 +static bool bootstrap_production_spawn(void *context, + const cbm_daemon_bootstrap_launch_spec_t *spec) { + (void)context; + if (!spec || !spec->detached || spec->inherit_standard_handles || spec->use_shell) { + return false; + } + char command_line[BOOTSTRAP_PATH_CAP * 2]; + if (!cbm_build_win_cmdline(command_line, sizeof(command_line), spec->argv)) { + return false; + } + wchar_t *application = cbm_utf8_to_wide(spec->executable_path); + wchar_t *command = cbm_utf8_to_wide(command_line); + if (!application || !command) { + free(application); + free(command); + return false; + } + STARTUPINFOW startup; + PROCESS_INFORMATION child; + ZeroMemory(&startup, sizeof(startup)); + ZeroMemory(&child, sizeof(child)); + startup.cb = sizeof(startup); + DWORD flags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW; + BOOL created = CreateProcessW(application, command, NULL, NULL, FALSE, flags, NULL, NULL, + &startup, &child); + free(application); + free(command); + if (!created) { + return false; + } + (void)CloseHandle(child.hThread); + (void)CloseHandle(child.hProcess); + return true; +} +#else +static void bootstrap_child_close_fds(void) { + long open_max = sysconf(_SC_OPEN_MAX); + if (open_max < 0 || open_max > 1048576L) { + open_max = 65536L; + } + for (int fd = 3; fd < open_max; fd++) { + (void)close(fd); + } +} + +static void bootstrap_daemon_grandchild(const cbm_daemon_bootstrap_launch_spec_t *spec) { + (void)umask(077); + sigset_t empty; + (void)sigemptyset(&empty); + (void)sigprocmask(SIG_SETMASK, &empty, NULL); + int null_fd = open("/dev/null", O_RDWR); + if (null_fd < 0 || dup2(null_fd, STDIN_FILENO) < 0 || dup2(null_fd, STDOUT_FILENO) < 0 || + dup2(null_fd, STDERR_FILENO) < 0) { + _exit(127); + } + if (null_fd > STDERR_FILENO) { + (void)close(null_fd); + } + bootstrap_child_close_fds(); + execv(spec->executable_path, (char *const *)spec->argv); + _exit(127); +} + +static bool bootstrap_production_spawn(void *context, + const cbm_daemon_bootstrap_launch_spec_t *spec) { + (void)context; + if (!spec || !spec->detached || spec->inherit_standard_handles || spec->use_shell) { + return false; + } + pid_t first = fork(); + if (first < 0) { + return false; + } + if (first == 0) { + if (setsid() < 0) { + _exit(127); + } + pid_t daemon = fork(); + if (daemon < 0) { + _exit(127); + } + if (daemon > 0) { + _exit(0); + } + bootstrap_daemon_grandchild(spec); + } + + int status = 0; + pid_t waited; + do { + waited = waitpid(first, &status, 0); + } while (waited < 0 && errno == EINTR); + return waited == first && WIFEXITED(status) && WEXITSTATUS(status) == 0; +} +#endif + +static void bootstrap_production_diagnostic(void *context, const char *message) { + (void)context; + (void)fprintf(stderr, "codebase-memory-mcp: %s\n", message ? message : "daemon startup failed"); + (void)fflush(stderr); +} + +cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute( + const cbm_daemon_bootstrap_config_t *config, cbm_daemon_bootstrap_result_t *result_out) { + bootstrap_production_context_t context = {0}; + const cbm_daemon_bootstrap_ops_t ops = { + .context = &context, + .cohort_acquire = bootstrap_production_cohort_acquire, + .cohort_release = bootstrap_production_cohort_release, + .probe = bootstrap_production_probe, + .startup_lock_try_acquire = bootstrap_production_lock, + .startup_lock_prepare_handoff = bootstrap_production_handoff, + .startup_lock_release = bootstrap_production_unlock, + .spawn_daemon = bootstrap_production_spawn, + .visible_diagnostic = bootstrap_production_diagnostic, + }; + return cbm_daemon_bootstrap_execute_with_ops(config, &ops, result_out); +} diff --git a/src/daemon/bootstrap.h b/src/daemon/bootstrap.h new file mode 100644 index 000000000..3094dc76b --- /dev/null +++ b/src/daemon/bootstrap.h @@ -0,0 +1,153 @@ +/* + * bootstrap.h — Process-role policy and mandatory daemon bootstrap. + * + * Role classification must happen before any stateful initialization in + * main(): no store, watcher, UI, diagnostics, or index supervisor may be + * constructed until the process is known to be the daemon, one of its + * internal workers, a thin client, or an explicitly stateless command. + */ +#ifndef CBM_DAEMON_BOOTSTRAP_H +#define CBM_DAEMON_BOOTSTRAP_H + +#include "daemon/runtime.h" +#include "daemon/version_cohort.h" + +#include +#include +#include + +#define CBM_DAEMON_INTERNAL_ARG "--cbm-daemon-internal" +#define CBM_DAEMON_BOOTSTRAP_LAUNCH_ARGC 2U + +typedef enum { + CBM_DAEMON_PROCESS_INVALID = 0, + CBM_DAEMON_PROCESS_STATELESS, + CBM_DAEMON_PROCESS_DAEMON, + CBM_DAEMON_PROCESS_WORKER, + CBM_DAEMON_PROCESS_MCP_CLIENT, + CBM_DAEMON_PROCESS_LOCAL_CLI, + CBM_DAEMON_PROCESS_HOOK_CLIENT, +} cbm_daemon_process_role_t; + +/* Pure argv classifier. argv[0] is the executable. The hidden daemon role is + * accepted only as the sole argument; index workers must use the exact + * build-bound `cli --index-worker ...` grammar. */ +cbm_daemon_process_role_t cbm_daemon_process_role(int argc, char *const argv[]); + +/* True only for externally launched long-lived frontends. Internal daemon and + * worker processes plus one-shot local CLI calls never count as client leases. */ +bool cbm_daemon_process_role_requires_client(cbm_daemon_process_role_t role); + +/* Construct the one stable per-account endpoint. The endpoint identity is the + * product rendezvous key: executable path, semantic version, build hash, and + * ABI values must never create parallel daemon namespaces. */ +cbm_daemon_ipc_endpoint_t *cbm_daemon_bootstrap_endpoint_new(const char *runtime_parent); + +/* Cross-platform launch policy for the daemon child. The child is invoked + * directly (never through a shell), with exactly argv[0] plus the one hidden + * internal argument. It is detached from the launching client's lifetime and + * inherits no standard handles; logical client leases govern its lifetime. */ +typedef struct { + const char *executable_path; + const char *argv[CBM_DAEMON_BOOTSTRAP_LAUNCH_ARGC + 1U]; + size_t argc; + bool detached; + bool inherit_standard_handles; + bool use_shell; +} cbm_daemon_bootstrap_launch_spec_t; + +bool cbm_daemon_bootstrap_launch_spec_init(const char *executable_path, + cbm_daemon_bootstrap_launch_spec_t *spec_out); + +typedef enum { + CBM_DAEMON_BOOTSTRAP_FAILED = 0, + CBM_DAEMON_BOOTSTRAP_BYPASSED, + CBM_DAEMON_BOOTSTRAP_CONNECTED, + CBM_DAEMON_BOOTSTRAP_CONFLICT, +} cbm_daemon_bootstrap_status_t; + +typedef struct { + cbm_daemon_process_role_t role; + const cbm_daemon_ipc_endpoint_t *endpoint; + const cbm_daemon_build_identity_t *identity; + const char *executable_path; + uint32_t connect_timeout_ms; + uint32_t startup_timeout_ms; +} cbm_daemon_bootstrap_config_t; + +typedef struct { + cbm_daemon_bootstrap_status_t status; + cbm_daemon_runtime_client_t *client; + cbm_daemon_runtime_connect_result_t connect_result; + bool daemon_spawned; + char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; +} cbm_daemon_bootstrap_result_t; + +/* A probe distinguishes an absent endpoint from a reserved endpoint whose + * listener is STARTING, saturated, or otherwise temporarily unable to admit + * this client. RESERVED and TERMINAL are wait states for the observed + * generation. If it later becomes truly absent, this same attempt re-enters + * startup serialization and may launch exactly one replacement. */ +typedef enum { + CBM_DAEMON_BOOTSTRAP_PROBE_ERROR = 0, + CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE, + CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED, + CBM_DAEMON_BOOTSTRAP_PROBE_CONNECTED, + CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT, + CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL, +} cbm_daemon_bootstrap_probe_status_t; + +/* Deterministic policy seam shared by production probing and unit contracts. + * lifetime_status follows the IPC reservation tri-state (1 held, 0 free, + * -1 error). Every protocol-level REJECTED response is RESERVED unless its + * message explicitly reports terminal shutdown; it is never UNAVAILABLE. */ +cbm_daemon_bootstrap_probe_status_t cbm_daemon_bootstrap_classify_failed_connect( + const cbm_daemon_runtime_connect_result_t *connect_result, int lifetime_status); + +typedef void *cbm_daemon_bootstrap_lock_t; +typedef void *cbm_daemon_bootstrap_cohort_t; + +/* Injectable OS/runtime boundary used by the deterministic unit contract. + * Production callers use cbm_daemon_bootstrap_execute(), whose built-in + * operations delegate to daemon IPC/runtime and write visible diagnostics to + * stderr. Lock acquisition uses the IPC tri-state convention: 1 acquired, + * 0 held by another starter, -1 error. */ +typedef struct { + void *context; + cbm_version_cohort_status_t (*cohort_acquire)( + void *context, const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms, + cbm_daemon_bootstrap_cohort_t *cohort_out, + cbm_daemon_conflict_t *conflict_out); + void (*cohort_release)(void *context, + cbm_daemon_bootstrap_cohort_t cohort); + cbm_daemon_bootstrap_probe_status_t (*probe)(void *context, + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, + uint32_t timeout_ms, + cbm_daemon_runtime_client_t **client_out, + cbm_daemon_runtime_connect_result_t *result_out); + int (*startup_lock_try_acquire)(void *context, const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_bootstrap_lock_t *lock_out); + /* Called with startup serialization still held immediately before spawn. + * It releases only migration-era compatibility ownership that the child + * must reacquire for its lifetime; the ordinary startup lock stays held. */ + bool (*startup_lock_prepare_handoff)( + void *context, cbm_daemon_bootstrap_lock_t lock); + /* Retry-safe: success consumes and clears *lock_io; false retains it. */ + bool (*startup_lock_release)( + void *context, cbm_daemon_bootstrap_lock_t *lock_io); + bool (*spawn_daemon)(void *context, const cbm_daemon_bootstrap_launch_spec_t *spec); + void (*visible_diagnostic)(void *context, const char *message); +} cbm_daemon_bootstrap_ops_t; + +cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute( + const cbm_daemon_bootstrap_config_t *config, cbm_daemon_bootstrap_result_t *result_out); + +/* Test seam for the same state machine. All callbacks are synchronous and + * borrowed for the duration of the call. */ +cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops( + const cbm_daemon_bootstrap_config_t *config, const cbm_daemon_bootstrap_ops_t *ops, + cbm_daemon_bootstrap_result_t *result_out); + +#endif /* CBM_DAEMON_BOOTSTRAP_H */ diff --git a/src/daemon/daemon.c b/src/daemon/daemon.c new file mode 100644 index 000000000..3bb01d10f --- /dev/null +++ b/src/daemon/daemon.c @@ -0,0 +1,845 @@ +/* + * daemon.c — Process-local coordination and wire framing for the CBM daemon. + */ +#include "daemon/daemon.h" + +#include "foundation/compat_thread.h" + +#include +#include + +typedef struct cbm_daemon_subscription { + cbm_daemon_subscription_id_t id; + cbm_daemon_client_id_t client_id; + struct cbm_daemon_subscription *next; +} cbm_daemon_subscription_t; + +typedef struct cbm_daemon_client { + cbm_daemon_client_id_t id; + uint64_t last_heartbeat_ms; + struct cbm_daemon_client *next; +} cbm_daemon_client_t; + +typedef struct cbm_daemon_job { + char *project_key; + cbm_daemon_job_state_t state; + cbm_daemon_subscription_t *subscriptions; + size_t subscription_count; + bool cancel_callback_inflight; + bool detached; + struct cbm_daemon_job *next; + struct cbm_daemon_job *action_next; +} cbm_daemon_job_t; + +typedef struct cbm_daemon_watch { + char *project_key; + cbm_daemon_subscription_t *subscriptions; + size_t subscription_count; + struct cbm_daemon_watch *next; + struct cbm_daemon_watch *action_next; +} cbm_daemon_watch_t; + +struct cbm_daemon_coordinator { + cbm_mutex_t mutex; + cbm_daemon_client_t *clients; + cbm_daemon_job_t *jobs; + cbm_daemon_watch_t *watches; + size_t client_count; + size_t job_count; + size_t watch_count; + size_t callback_count; + uint64_t lease_timeout_ms; + cbm_daemon_client_id_t last_client_id; + cbm_daemon_subscription_id_t last_subscription_id; + cbm_daemon_coordinator_state_t state; + cbm_daemon_coordinator_hooks_t hooks; +}; + +typedef struct { + cbm_daemon_job_t *jobs; + cbm_daemon_watch_t *watches; + cbm_daemon_job_cancel_fn cancel_job; + cbm_daemon_watch_release_fn release_watch; + void *context; +} cbm_daemon_callback_batch_t; + +enum { + FRAME_MAGIC_0 = 0, + FRAME_MAGIC_1 = 1, + FRAME_MAGIC_2 = 2, + FRAME_MAGIC_3 = 3, + FRAME_VERSION = 4, + FRAME_TYPE = 5, + FRAME_FLAGS_HI = 6, + FRAME_FLAGS_LO = 7, + FRAME_LENGTH_3 = 8, + FRAME_LENGTH_2 = 9, + FRAME_LENGTH_1 = 10, + FRAME_LENGTH_0 = 11, +}; + +static bool frame_type_valid(cbm_daemon_frame_type_t type) { + return type == CBM_DAEMON_FRAME_REQUEST || type == CBM_DAEMON_FRAME_RESPONSE; +} + +static char *daemon_string_dup(const char *value) { + size_t length = strlen(value); + char *copy = malloc(length + 1); + if (copy) { + memcpy(copy, value, length + 1); + } + return copy; +} + +static void free_subscriptions(cbm_daemon_subscription_t *subscription) { + while (subscription) { + cbm_daemon_subscription_t *next = subscription->next; + free(subscription); + subscription = next; + } +} + +static void free_job(cbm_daemon_job_t *job) { + if (job) { + free_subscriptions(job->subscriptions); + free(job->project_key); + free(job); + } +} + +static void free_watch(cbm_daemon_watch_t *watch) { + if (watch) { + free_subscriptions(watch->subscriptions); + free(watch->project_key); + free(watch); + } +} + +static cbm_daemon_client_id_t issue_client_id_locked(cbm_daemon_coordinator_t *coordinator) { + if (coordinator->last_client_id == UINT64_MAX) { + return CBM_DAEMON_CLIENT_ID_INVALID; + } + coordinator->last_client_id++; + return coordinator->last_client_id; +} + +static cbm_daemon_subscription_id_t +issue_subscription_id_locked(cbm_daemon_coordinator_t *coordinator) { + if (coordinator->last_subscription_id == UINT64_MAX) { + return CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + } + coordinator->last_subscription_id++; + return coordinator->last_subscription_id; +} + +static cbm_daemon_client_t *find_client_locked(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id) { + for (cbm_daemon_client_t *client = coordinator->clients; client; client = client->next) { + if (client->id == client_id) { + return client; + } + } + return NULL; +} + +static cbm_daemon_job_t *find_job_locked(cbm_daemon_coordinator_t *coordinator, + const char *project_key) { + for (cbm_daemon_job_t *job = coordinator->jobs; job; job = job->next) { + if (strcmp(job->project_key, project_key) == 0) { + return job; + } + } + return NULL; +} + +static cbm_daemon_watch_t *find_watch_locked(cbm_daemon_coordinator_t *coordinator, + const char *project_key) { + for (cbm_daemon_watch_t *watch = coordinator->watches; watch; watch = watch->next) { + if (strcmp(watch->project_key, project_key) == 0) { + return watch; + } + } + return NULL; +} + +static bool remove_subscription_locked(cbm_daemon_subscription_t **subscriptions, + size_t *subscription_count, + cbm_daemon_client_id_t client_id, + cbm_daemon_subscription_id_t subscription_id) { + cbm_daemon_subscription_t **cursor = subscriptions; + while (*cursor) { + cbm_daemon_subscription_t *subscription = *cursor; + if (subscription->id == subscription_id && subscription->client_id == client_id) { + *cursor = subscription->next; + free(subscription); + (*subscription_count)--; + return true; + } + cursor = &subscription->next; + } + return false; +} + +static void remove_client_subscriptions_locked(cbm_daemon_subscription_t **subscriptions, + size_t *subscription_count, + cbm_daemon_client_id_t client_id) { + cbm_daemon_subscription_t **cursor = subscriptions; + while (*cursor) { + cbm_daemon_subscription_t *subscription = *cursor; + if (subscription->client_id == client_id) { + *cursor = subscription->next; + free(subscription); + (*subscription_count)--; + } else { + cursor = &subscription->next; + } + } +} + +static void callback_batch_init_locked(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_callback_batch_t *batch) { + memset(batch, 0, sizeof(*batch)); + batch->cancel_job = coordinator->hooks.cancel_job; + batch->release_watch = coordinator->hooks.release_watch; + batch->context = coordinator->hooks.context; +} + +static void request_job_cancel_locked(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_job_t *job, + cbm_daemon_callback_batch_t *batch) { + if (job->subscription_count != 0 || job->state != CBM_DAEMON_JOB_RUNNING) { + return; + } + job->state = CBM_DAEMON_JOB_CANCEL_REQUESTED; + if (batch->cancel_job) { + job->cancel_callback_inflight = true; + job->action_next = batch->jobs; + batch->jobs = job; + coordinator->callback_count++; + } +} + +static void queue_watch_release_locked(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_watch_t *watch, + cbm_daemon_callback_batch_t *batch) { + watch->action_next = batch->watches; + batch->watches = watch; + if (batch->release_watch) { + coordinator->callback_count++; + } +} + +static void callback_batch_run(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_callback_batch_t *batch) { + cbm_daemon_job_t *job = batch->jobs; + while (job) { + cbm_daemon_job_t *next = job->action_next; + batch->cancel_job(job->project_key, batch->context); + + cbm_mutex_lock(&coordinator->mutex); + coordinator->callback_count--; + job->cancel_callback_inflight = false; + bool detached = job->detached; + cbm_mutex_unlock(&coordinator->mutex); + if (detached) { + free_job(job); + } + job = next; + } + + cbm_daemon_watch_t *watch = batch->watches; + while (watch) { + cbm_daemon_watch_t *next = watch->action_next; + if (batch->release_watch) { + batch->release_watch(watch->project_key, batch->context); + cbm_mutex_lock(&coordinator->mutex); + coordinator->callback_count--; + cbm_mutex_unlock(&coordinator->mutex); + } + free_watch(watch); + watch = next; + } +} + +static void release_client_resources_locked(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, + cbm_daemon_callback_batch_t *batch) { + for (cbm_daemon_job_t *job = coordinator->jobs; job; job = job->next) { + remove_client_subscriptions_locked(&job->subscriptions, &job->subscription_count, + client_id); + request_job_cancel_locked(coordinator, job, batch); + } + + cbm_daemon_watch_t **watch_cursor = &coordinator->watches; + while (*watch_cursor) { + cbm_daemon_watch_t *watch = *watch_cursor; + remove_client_subscriptions_locked(&watch->subscriptions, &watch->subscription_count, + client_id); + if (watch->subscription_count == 0) { + *watch_cursor = watch->next; + watch->next = NULL; + coordinator->watch_count--; + queue_watch_release_locked(coordinator, watch, batch); + } else { + watch_cursor = &watch->next; + } + } +} + +static void release_client_locked(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_t *client, + cbm_daemon_callback_batch_t *batch) { + release_client_resources_locked(coordinator, client->id, batch); + free(client); + coordinator->client_count--; + if (coordinator->client_count == 0) { + coordinator->state = CBM_DAEMON_COORDINATOR_STOPPING; + } +} + +static bool terminal_job_locked(cbm_daemon_coordinator_t *coordinator, + const char *project_key, bool require_cancellation, + cbm_daemon_job_t **free_after_unlock) { + cbm_daemon_job_t **cursor = &coordinator->jobs; + while (*cursor && strcmp((*cursor)->project_key, project_key) != 0) { + cursor = &(*cursor)->next; + } + if (!*cursor || (require_cancellation && (*cursor)->state == CBM_DAEMON_JOB_RUNNING)) { + return false; + } + + cbm_daemon_job_t *job = *cursor; + *cursor = job->next; + job->next = NULL; + job->detached = true; + coordinator->job_count--; + free_subscriptions(job->subscriptions); + job->subscriptions = NULL; + job->subscription_count = 0; + if (!job->cancel_callback_inflight) { + *free_after_unlock = job; + } + return true; +} + +cbm_daemon_coordinator_t *cbm_daemon_coordinator_new(uint64_t lease_timeout_ms) { + cbm_daemon_coordinator_t *coordinator = calloc(1, sizeof(*coordinator)); + if (!coordinator) { + return NULL; + } + cbm_mutex_init(&coordinator->mutex); + coordinator->lease_timeout_ms = lease_timeout_ms; + coordinator->state = CBM_DAEMON_COORDINATOR_RUNNING; + return coordinator; +} + +void cbm_daemon_coordinator_free(cbm_daemon_coordinator_t *coordinator) { + if (!coordinator) { + return; + } + + cbm_daemon_client_t *client = coordinator->clients; + while (client) { + cbm_daemon_client_t *next = client->next; + free(client); + client = next; + } + + cbm_daemon_job_t *job = coordinator->jobs; + while (job) { + cbm_daemon_job_t *next = job->next; + free_job(job); + job = next; + } + + cbm_daemon_watch_t *watch = coordinator->watches; + while (watch) { + cbm_daemon_watch_t *next = watch->next; + free_watch(watch); + watch = next; + } + cbm_mutex_destroy(&coordinator->mutex); + free(coordinator); +} + +bool cbm_daemon_coordinator_set_hooks(cbm_daemon_coordinator_t *coordinator, + const cbm_daemon_coordinator_hooks_t *hooks) { + if (!coordinator || !hooks) { + return false; + } + cbm_mutex_lock(&coordinator->mutex); + coordinator->hooks = *hooks; + cbm_mutex_unlock(&coordinator->mutex); + return true; +} + +cbm_daemon_coordinator_state_t +cbm_daemon_coordinator_state(cbm_daemon_coordinator_t *coordinator) { + if (!coordinator) { + return CBM_DAEMON_COORDINATOR_STOPPING; + } + cbm_mutex_lock(&coordinator->mutex); + cbm_daemon_coordinator_state_t state = coordinator->state; + cbm_mutex_unlock(&coordinator->mutex); + return state; +} + +cbm_daemon_client_id_t cbm_daemon_client_connected(cbm_daemon_coordinator_t *coordinator, + uint64_t now_ms) { + if (!coordinator) { + return CBM_DAEMON_CLIENT_ID_INVALID; + } + + cbm_daemon_client_t *client = malloc(sizeof(*client)); + if (!client) { + return CBM_DAEMON_CLIENT_ID_INVALID; + } + + cbm_mutex_lock(&coordinator->mutex); + if (coordinator->state != CBM_DAEMON_COORDINATOR_RUNNING) { + cbm_mutex_unlock(&coordinator->mutex); + free(client); + return CBM_DAEMON_CLIENT_ID_INVALID; + } + cbm_daemon_client_id_t client_id = issue_client_id_locked(coordinator); + if (client_id == CBM_DAEMON_CLIENT_ID_INVALID) { + cbm_mutex_unlock(&coordinator->mutex); + free(client); + return CBM_DAEMON_CLIENT_ID_INVALID; + } + client->id = client_id; + client->last_heartbeat_ms = now_ms; + client->next = coordinator->clients; + coordinator->clients = client; + coordinator->client_count++; + cbm_mutex_unlock(&coordinator->mutex); + return client_id; +} + +bool cbm_daemon_client_disconnected(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, uint64_t now_ms) { + (void)now_ms; + if (!coordinator || client_id == CBM_DAEMON_CLIENT_ID_INVALID) { + return false; + } + + cbm_daemon_callback_batch_t batch; + cbm_mutex_lock(&coordinator->mutex); + callback_batch_init_locked(coordinator, &batch); + cbm_daemon_client_t **cursor = &coordinator->clients; + while (*cursor && (*cursor)->id != client_id) { + cursor = &(*cursor)->next; + } + if (!*cursor) { + cbm_mutex_unlock(&coordinator->mutex); + return false; + } + cbm_daemon_client_t *client = *cursor; + *cursor = client->next; + release_client_locked(coordinator, client, &batch); + cbm_mutex_unlock(&coordinator->mutex); + + callback_batch_run(coordinator, &batch); + return true; +} + +bool cbm_daemon_client_heartbeat(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, uint64_t now_ms) { + if (!coordinator || client_id == CBM_DAEMON_CLIENT_ID_INVALID) { + return false; + } + cbm_mutex_lock(&coordinator->mutex); + cbm_daemon_client_t *client = find_client_locked(coordinator, client_id); + bool found = client != NULL; + if (client && now_ms > client->last_heartbeat_ms) { + client->last_heartbeat_ms = now_ms; + } + cbm_mutex_unlock(&coordinator->mutex); + return found; +} + +size_t cbm_daemon_expire_leases(cbm_daemon_coordinator_t *coordinator, uint64_t now_ms) { + if (!coordinator) { + return 0; + } + + size_t expired_count = 0; + cbm_daemon_callback_batch_t batch; + cbm_mutex_lock(&coordinator->mutex); + callback_batch_init_locked(coordinator, &batch); + cbm_daemon_client_t **cursor = &coordinator->clients; + while (*cursor) { + cbm_daemon_client_t *client = *cursor; + bool expired = now_ms >= client->last_heartbeat_ms && + now_ms - client->last_heartbeat_ms >= coordinator->lease_timeout_ms; + if (!expired) { + cursor = &client->next; + continue; + } + *cursor = client->next; + release_client_locked(coordinator, client, &batch); + expired_count++; + } + cbm_mutex_unlock(&coordinator->mutex); + + callback_batch_run(coordinator, &batch); + return expired_count; +} + +size_t cbm_daemon_active_clients(cbm_daemon_coordinator_t *coordinator) { + if (!coordinator) { + return 0; + } + cbm_mutex_lock(&coordinator->mutex); + size_t count = coordinator->client_count; + cbm_mutex_unlock(&coordinator->mutex); + return count; +} + +cbm_daemon_subscription_result_t +cbm_daemon_job_subscribe(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, const char *project_key, + cbm_daemon_subscription_id_t *subscription_id) { + if (subscription_id) { + *subscription_id = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + } + if (!coordinator || !subscription_id || !project_key || project_key[0] == '\0' || + client_id == CBM_DAEMON_CLIENT_ID_INVALID) { + return CBM_DAEMON_SUBSCRIPTION_REJECTED; + } + + cbm_mutex_lock(&coordinator->mutex); + if (coordinator->state != CBM_DAEMON_COORDINATOR_RUNNING || + !find_client_locked(coordinator, client_id)) { + cbm_mutex_unlock(&coordinator->mutex); + return CBM_DAEMON_SUBSCRIPTION_REJECTED; + } + cbm_daemon_job_t *job = find_job_locked(coordinator, project_key); + if (job && job->state != CBM_DAEMON_JOB_RUNNING) { + cbm_mutex_unlock(&coordinator->mutex); + return CBM_DAEMON_SUBSCRIPTION_REJECTED; + } + + bool started = job == NULL; + cbm_daemon_job_t *new_job = NULL; + char *key_copy = NULL; + cbm_daemon_subscription_t *subscription = malloc(sizeof(*subscription)); + if (started) { + new_job = calloc(1, sizeof(*new_job)); + key_copy = daemon_string_dup(project_key); + } + if (!subscription || (started && (!new_job || !key_copy))) { + free(subscription); + free(new_job); + free(key_copy); + cbm_mutex_unlock(&coordinator->mutex); + return CBM_DAEMON_SUBSCRIPTION_REJECTED; + } + + cbm_daemon_subscription_id_t id = issue_subscription_id_locked(coordinator); + if (id == CBM_DAEMON_SUBSCRIPTION_ID_INVALID) { + free(subscription); + free(new_job); + free(key_copy); + cbm_mutex_unlock(&coordinator->mutex); + return CBM_DAEMON_SUBSCRIPTION_REJECTED; + } + if (started) { + new_job->project_key = key_copy; + new_job->state = CBM_DAEMON_JOB_RUNNING; + new_job->next = coordinator->jobs; + coordinator->jobs = new_job; + coordinator->job_count++; + job = new_job; + } + subscription->id = id; + subscription->client_id = client_id; + subscription->next = job->subscriptions; + job->subscriptions = subscription; + job->subscription_count++; + *subscription_id = id; + cbm_mutex_unlock(&coordinator->mutex); + return started ? CBM_DAEMON_SUBSCRIPTION_STARTED : CBM_DAEMON_SUBSCRIPTION_JOINED; +} + +cbm_daemon_subscription_result_t +cbm_daemon_watch_subscribe(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, const char *project_key, + cbm_daemon_subscription_id_t *subscription_id) { + if (subscription_id) { + *subscription_id = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + } + if (!coordinator || !subscription_id || !project_key || project_key[0] == '\0' || + client_id == CBM_DAEMON_CLIENT_ID_INVALID) { + return CBM_DAEMON_SUBSCRIPTION_REJECTED; + } + + cbm_mutex_lock(&coordinator->mutex); + if (coordinator->state != CBM_DAEMON_COORDINATOR_RUNNING || + !find_client_locked(coordinator, client_id)) { + cbm_mutex_unlock(&coordinator->mutex); + return CBM_DAEMON_SUBSCRIPTION_REJECTED; + } + cbm_daemon_watch_t *watch = find_watch_locked(coordinator, project_key); + bool started = watch == NULL; + cbm_daemon_watch_t *new_watch = NULL; + char *key_copy = NULL; + cbm_daemon_subscription_t *subscription = malloc(sizeof(*subscription)); + if (started) { + new_watch = calloc(1, sizeof(*new_watch)); + key_copy = daemon_string_dup(project_key); + } + if (!subscription || (started && (!new_watch || !key_copy))) { + free(subscription); + free(new_watch); + free(key_copy); + cbm_mutex_unlock(&coordinator->mutex); + return CBM_DAEMON_SUBSCRIPTION_REJECTED; + } + + cbm_daemon_subscription_id_t id = issue_subscription_id_locked(coordinator); + if (id == CBM_DAEMON_SUBSCRIPTION_ID_INVALID) { + free(subscription); + free(new_watch); + free(key_copy); + cbm_mutex_unlock(&coordinator->mutex); + return CBM_DAEMON_SUBSCRIPTION_REJECTED; + } + if (started) { + new_watch->project_key = key_copy; + new_watch->next = coordinator->watches; + coordinator->watches = new_watch; + coordinator->watch_count++; + watch = new_watch; + } + subscription->id = id; + subscription->client_id = client_id; + subscription->next = watch->subscriptions; + watch->subscriptions = subscription; + watch->subscription_count++; + *subscription_id = id; + cbm_mutex_unlock(&coordinator->mutex); + return started ? CBM_DAEMON_SUBSCRIPTION_STARTED : CBM_DAEMON_SUBSCRIPTION_JOINED; +} + +bool cbm_daemon_job_unsubscribe(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, + cbm_daemon_subscription_id_t subscription_id) { + if (!coordinator || client_id == CBM_DAEMON_CLIENT_ID_INVALID || + subscription_id == CBM_DAEMON_SUBSCRIPTION_ID_INVALID) { + return false; + } + + cbm_daemon_callback_batch_t batch; + cbm_mutex_lock(&coordinator->mutex); + callback_batch_init_locked(coordinator, &batch); + if (!find_client_locked(coordinator, client_id)) { + cbm_mutex_unlock(&coordinator->mutex); + return false; + } + bool removed = false; + for (cbm_daemon_job_t *job = coordinator->jobs; job; job = job->next) { + removed = remove_subscription_locked(&job->subscriptions, &job->subscription_count, + client_id, subscription_id); + if (removed) { + request_job_cancel_locked(coordinator, job, &batch); + break; + } + } + cbm_mutex_unlock(&coordinator->mutex); + callback_batch_run(coordinator, &batch); + return removed; +} + +bool cbm_daemon_watch_unsubscribe(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, + cbm_daemon_subscription_id_t subscription_id) { + if (!coordinator || client_id == CBM_DAEMON_CLIENT_ID_INVALID || + subscription_id == CBM_DAEMON_SUBSCRIPTION_ID_INVALID) { + return false; + } + + cbm_daemon_callback_batch_t batch; + cbm_mutex_lock(&coordinator->mutex); + callback_batch_init_locked(coordinator, &batch); + if (!find_client_locked(coordinator, client_id)) { + cbm_mutex_unlock(&coordinator->mutex); + return false; + } + bool removed = false; + cbm_daemon_watch_t **cursor = &coordinator->watches; + while (*cursor) { + cbm_daemon_watch_t *watch = *cursor; + removed = remove_subscription_locked(&watch->subscriptions, &watch->subscription_count, + client_id, subscription_id); + if (!removed) { + cursor = &watch->next; + continue; + } + if (watch->subscription_count == 0) { + *cursor = watch->next; + watch->next = NULL; + coordinator->watch_count--; + queue_watch_release_locked(coordinator, watch, &batch); + } + break; + } + cbm_mutex_unlock(&coordinator->mutex); + callback_batch_run(coordinator, &batch); + return removed; +} + +size_t cbm_daemon_job_subscribers(cbm_daemon_coordinator_t *coordinator, + const char *project_key) { + if (!coordinator || !project_key) { + return 0; + } + cbm_mutex_lock(&coordinator->mutex); + cbm_daemon_job_t *job = find_job_locked(coordinator, project_key); + size_t count = job ? job->subscription_count : 0; + cbm_mutex_unlock(&coordinator->mutex); + return count; +} + +size_t cbm_daemon_watch_subscribers(cbm_daemon_coordinator_t *coordinator, + const char *project_key) { + if (!coordinator || !project_key) { + return 0; + } + cbm_mutex_lock(&coordinator->mutex); + cbm_daemon_watch_t *watch = find_watch_locked(coordinator, project_key); + size_t count = watch ? watch->subscription_count : 0; + cbm_mutex_unlock(&coordinator->mutex); + return count; +} + +size_t cbm_daemon_active_jobs(cbm_daemon_coordinator_t *coordinator) { + if (!coordinator) { + return 0; + } + cbm_mutex_lock(&coordinator->mutex); + size_t count = coordinator->job_count; + cbm_mutex_unlock(&coordinator->mutex); + return count; +} + +size_t cbm_daemon_active_watches(cbm_daemon_coordinator_t *coordinator) { + if (!coordinator) { + return 0; + } + cbm_mutex_lock(&coordinator->mutex); + size_t count = coordinator->watch_count; + cbm_mutex_unlock(&coordinator->mutex); + return count; +} + +cbm_daemon_job_state_t cbm_daemon_job_state(cbm_daemon_coordinator_t *coordinator, + const char *project_key) { + if (!coordinator || !project_key) { + return CBM_DAEMON_JOB_NONE; + } + cbm_mutex_lock(&coordinator->mutex); + cbm_daemon_job_t *job = find_job_locked(coordinator, project_key); + cbm_daemon_job_state_t state = job ? job->state : CBM_DAEMON_JOB_NONE; + cbm_mutex_unlock(&coordinator->mutex); + return state; +} + +bool cbm_daemon_job_reaping(cbm_daemon_coordinator_t *coordinator, + const char *project_key) { + if (!coordinator || !project_key) { + return false; + } + cbm_mutex_lock(&coordinator->mutex); + cbm_daemon_job_t *job = find_job_locked(coordinator, project_key); + bool transitioned = job && job->state == CBM_DAEMON_JOB_CANCEL_REQUESTED; + if (transitioned) { + job->state = CBM_DAEMON_JOB_REAPING; + } + cbm_mutex_unlock(&coordinator->mutex); + return transitioned; +} + +bool cbm_daemon_job_reaped(cbm_daemon_coordinator_t *coordinator, + const char *project_key, uint64_t now_ms) { + (void)now_ms; + if (!coordinator || !project_key) { + return false; + } + cbm_daemon_job_t *free_after_unlock = NULL; + cbm_mutex_lock(&coordinator->mutex); + bool removed = terminal_job_locked(coordinator, project_key, true, &free_after_unlock); + cbm_mutex_unlock(&coordinator->mutex); + free_job(free_after_unlock); + return removed; +} + +bool cbm_daemon_job_completed(cbm_daemon_coordinator_t *coordinator, + const char *project_key, uint64_t now_ms) { + (void)now_ms; + if (!coordinator || !project_key) { + return false; + } + cbm_daemon_job_t *free_after_unlock = NULL; + cbm_mutex_lock(&coordinator->mutex); + bool removed = terminal_job_locked(coordinator, project_key, false, &free_after_unlock); + cbm_mutex_unlock(&coordinator->mutex); + free_job(free_after_unlock); + return removed; +} + +bool cbm_daemon_should_exit(cbm_daemon_coordinator_t *coordinator, uint64_t now_ms) { + (void)now_ms; + if (!coordinator) { + return false; + } + cbm_mutex_lock(&coordinator->mutex); + bool should_exit = coordinator->state == CBM_DAEMON_COORDINATOR_STOPPING && + coordinator->client_count == 0 && coordinator->job_count == 0 && + coordinator->watch_count == 0 && coordinator->callback_count == 0; + cbm_mutex_unlock(&coordinator->mutex); + return should_exit; +} + +bool cbm_daemon_frame_header_encode(uint8_t header[CBM_DAEMON_FRAME_HEADER_SIZE], + cbm_daemon_frame_type_t type, uint16_t flags, uint32_t length) { + if (!header || !frame_type_valid(type) || length > CBM_DAEMON_MAX_FRAME_SIZE) { + return false; + } + header[FRAME_MAGIC_0] = 'C'; + header[FRAME_MAGIC_1] = 'B'; + header[FRAME_MAGIC_2] = 'M'; + header[FRAME_MAGIC_3] = 'D'; + header[FRAME_VERSION] = CBM_DAEMON_RENDEZVOUS_FRAME_VERSION; + header[FRAME_TYPE] = (uint8_t)type; + header[FRAME_FLAGS_HI] = (uint8_t)(flags >> 8); + header[FRAME_FLAGS_LO] = (uint8_t)flags; + header[FRAME_LENGTH_3] = (uint8_t)(length >> 24); + header[FRAME_LENGTH_2] = (uint8_t)(length >> 16); + header[FRAME_LENGTH_1] = (uint8_t)(length >> 8); + header[FRAME_LENGTH_0] = (uint8_t)length; + return true; +} + +bool cbm_daemon_frame_header_decode(const uint8_t header[CBM_DAEMON_FRAME_HEADER_SIZE], + cbm_daemon_frame_t *frame) { + if (!header || !frame || header[FRAME_MAGIC_0] != 'C' || header[FRAME_MAGIC_1] != 'B' || + header[FRAME_MAGIC_2] != 'M' || header[FRAME_MAGIC_3] != 'D' || + header[FRAME_VERSION] != CBM_DAEMON_RENDEZVOUS_FRAME_VERSION) { + return false; + } + + cbm_daemon_frame_type_t type = (cbm_daemon_frame_type_t)header[FRAME_TYPE]; + uint32_t length = ((uint32_t)header[FRAME_LENGTH_3] << 24) | + ((uint32_t)header[FRAME_LENGTH_2] << 16) | + ((uint32_t)header[FRAME_LENGTH_1] << 8) | (uint32_t)header[FRAME_LENGTH_0]; + if (!frame_type_valid(type) || length > CBM_DAEMON_MAX_FRAME_SIZE) { + return false; + } + + frame->type = type; + frame->flags = + (uint16_t)(((uint16_t)header[FRAME_FLAGS_HI] << 8) | (uint16_t)header[FRAME_FLAGS_LO]); + frame->length = length; + return true; +} diff --git a/src/daemon/daemon.h b/src/daemon/daemon.h new file mode 100644 index 000000000..6a363a1d4 --- /dev/null +++ b/src/daemon/daemon.h @@ -0,0 +1,137 @@ +/* + * daemon.h — Process-local coordination and wire framing for the CBM daemon. + * + * Transport and worker supervision live outside this module. The coordinator + * binds clients and resource subscriptions to transport connections, coalesces + * shared work, and defines the daemon's terminal shutdown transition. + */ +#ifndef CBM_DAEMON_H +#define CBM_DAEMON_H + +#include +#include +#include + +/* Permanent framing version for the account-wide rendezvous endpoint. Never + * bump this for detailed runtime payload changes: incompatible executable + * generations must still exchange the stable HELLO conflict envelope. */ +#define CBM_DAEMON_RENDEZVOUS_FRAME_VERSION 1U +#define CBM_DAEMON_FRAME_HEADER_SIZE 12U +#define CBM_DAEMON_MAX_FRAME_SIZE (10U * 1024U * 1024U) +#define CBM_DAEMON_KEY_SIZE 17U + +typedef enum { + CBM_DAEMON_FRAME_REQUEST = 1, + CBM_DAEMON_FRAME_RESPONSE = 2, +} cbm_daemon_frame_type_t; + +typedef struct { + cbm_daemon_frame_type_t type; + uint16_t flags; + uint32_t length; +} cbm_daemon_frame_t; + +typedef struct cbm_daemon_coordinator cbm_daemon_coordinator_t; + +typedef uint64_t cbm_daemon_client_id_t; +typedef uint64_t cbm_daemon_subscription_id_t; + +#define CBM_DAEMON_CLIENT_ID_INVALID ((cbm_daemon_client_id_t)0) +#define CBM_DAEMON_SUBSCRIPTION_ID_INVALID ((cbm_daemon_subscription_id_t)0) + +typedef enum { + CBM_DAEMON_COORDINATOR_RUNNING = 1, + CBM_DAEMON_COORDINATOR_STOPPING = 2, +} cbm_daemon_coordinator_state_t; + +typedef enum { + CBM_DAEMON_SUBSCRIPTION_REJECTED = 0, + CBM_DAEMON_SUBSCRIPTION_STARTED = 1, + CBM_DAEMON_SUBSCRIPTION_JOINED = 2, +} cbm_daemon_subscription_result_t; + +typedef enum { + CBM_DAEMON_JOB_NONE = 0, + CBM_DAEMON_JOB_RUNNING = 1, + CBM_DAEMON_JOB_CANCEL_REQUESTED = 2, + CBM_DAEMON_JOB_REAPING = 3, +} cbm_daemon_job_state_t; + +typedef void (*cbm_daemon_job_cancel_fn)(const char *project_key, void *context); +typedef void (*cbm_daemon_watch_release_fn)(const char *project_key, void *context); + +typedef struct { + cbm_daemon_job_cancel_fn cancel_job; + cbm_daemon_watch_release_fn release_watch; + void *context; +} cbm_daemon_coordinator_hooks_t; + +/* lease_timeout_ms is fixed for the coordinator lifetime. All timestamps must + * come from the same monotonic clock domain. */ +cbm_daemon_coordinator_t *cbm_daemon_coordinator_new(uint64_t lease_timeout_ms); +/* The caller must first quiesce coordinator calls and hook invocations. */ +void cbm_daemon_coordinator_free(cbm_daemon_coordinator_t *coordinator); + +/* Hooks are copied. Their context must remain valid until the coordinator is + * quiescent. Hooks are always invoked after releasing the coordinator mutex. */ +bool cbm_daemon_coordinator_set_hooks(cbm_daemon_coordinator_t *coordinator, + const cbm_daemon_coordinator_hooks_t *hooks); +cbm_daemon_coordinator_state_t +cbm_daemon_coordinator_state(cbm_daemon_coordinator_t *coordinator); + +/* Client IDs are daemon-issued, nonzero, monotonic, and never recycled. */ +cbm_daemon_client_id_t cbm_daemon_client_connected(cbm_daemon_coordinator_t *coordinator, + uint64_t now_ms); +bool cbm_daemon_client_disconnected(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, uint64_t now_ms); +bool cbm_daemon_client_heartbeat(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, uint64_t now_ms); +size_t cbm_daemon_expire_leases(cbm_daemon_coordinator_t *coordinator, uint64_t now_ms); +size_t cbm_daemon_active_clients(cbm_daemon_coordinator_t *coordinator); + +/* Every accepted subscription receives a unique daemon-issued handle. The + * first subscriber starts the physical resource; later subscribers join it. */ +cbm_daemon_subscription_result_t +cbm_daemon_job_subscribe(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, const char *project_key, + cbm_daemon_subscription_id_t *subscription_id); +cbm_daemon_subscription_result_t +cbm_daemon_watch_subscribe(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, const char *project_key, + cbm_daemon_subscription_id_t *subscription_id); +bool cbm_daemon_job_unsubscribe(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, + cbm_daemon_subscription_id_t subscription_id); +bool cbm_daemon_watch_unsubscribe(cbm_daemon_coordinator_t *coordinator, + cbm_daemon_client_id_t client_id, + cbm_daemon_subscription_id_t subscription_id); + +size_t cbm_daemon_job_subscribers(cbm_daemon_coordinator_t *coordinator, + const char *project_key); +size_t cbm_daemon_watch_subscribers(cbm_daemon_coordinator_t *coordinator, + const char *project_key); +size_t cbm_daemon_active_jobs(cbm_daemon_coordinator_t *coordinator); +size_t cbm_daemon_active_watches(cbm_daemon_coordinator_t *coordinator); +cbm_daemon_job_state_t cbm_daemon_job_state(cbm_daemon_coordinator_t *coordinator, + const char *project_key); + +/* Cancellation is two phase. Losing the final subscriber requests cancel; + * the job remains active until its supervisor reports completion/reaping. */ +bool cbm_daemon_job_reaping(cbm_daemon_coordinator_t *coordinator, + const char *project_key); +bool cbm_daemon_job_reaped(cbm_daemon_coordinator_t *coordinator, + const char *project_key, uint64_t now_ms); +bool cbm_daemon_job_completed(cbm_daemon_coordinator_t *coordinator, + const char *project_key, uint64_t now_ms); + +/* STOPPING is terminal. Exit is ready only after every job/watch is gone. */ +bool cbm_daemon_should_exit(cbm_daemon_coordinator_t *coordinator, uint64_t now_ms); + +/* Encode/decode the permanently stable 12-byte "CBMD" rendezvous frame header + * in network byte order. Detailed operation ABIs live above this framing. */ +bool cbm_daemon_frame_header_encode(uint8_t header[CBM_DAEMON_FRAME_HEADER_SIZE], + cbm_daemon_frame_type_t type, uint16_t flags, uint32_t length); +bool cbm_daemon_frame_header_decode(const uint8_t header[CBM_DAEMON_FRAME_HEADER_SIZE], + cbm_daemon_frame_t *frame); + +#endif /* CBM_DAEMON_H */ diff --git a/src/daemon/frontend.c b/src/daemon/frontend.c new file mode 100644 index 000000000..82cfd429f --- /dev/null +++ b/src/daemon/frontend.c @@ -0,0 +1,624 @@ +/* + * frontend.c — Stateless stdio bridge for a daemon-backed MCP session. + */ +#include "daemon/frontend.h" + +#include "daemon/application.h" +#include "foundation/compat.h" +#include "foundation/compat_thread.h" +#include "foundation/log.h" +#include "foundation/platform.h" +#include "foundation/subprocess.h" +#include "mcp/mcp.h" + +#include +#include +#include +#include +#include +#include + +enum { + FRONTEND_QUEUE_CAPACITY = 8, + FRONTEND_QUEUE_BYTES_MAX = 12 * 1024 * 1024, + FRONTEND_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000, + FRONTEND_CLOSE_TIMEOUT_MS = 5000, + FRONTEND_WAIT_US = 1000, + FRONTEND_MAINTENANCE_POLL_MS = 10, + /* The owner thread may be draining a supervised process tree. Preserve the + * supervisor's complete graceful + forced-settle window before the monitor + * fail-stops the process, plus scheduling/teardown margin. */ + FRONTEND_MAINTENANCE_GRACE_MS = + CBM_SUBPROCESS_MAX_CANCEL_GRACE_MS + + CBM_SUBPROCESS_FORCE_SETTLE_MS + 1000, + FRONTEND_PARTICIPANT_NAME_CAP = 64, +}; + +typedef struct { + char *message; + size_t length; + bool content_length_framed; + bool has_id; + int64_t id; + char *id_str; + cbm_daemon_runtime_application_token_t request_token; + bool cancelled; +} frontend_item_t; + +typedef struct { + cbm_mutex_t mutex; + cbm_daemon_runtime_client_t *client; + cbm_version_cohort_manager_t *cohort_manager; + FILE *out; + frontend_item_t queue[FRONTEND_QUEUE_CAPACITY]; + size_t head; + size_t count; + size_t queued_bytes; + bool stopping; + bool in_request; + bool active_has_id; + int64_t active_id; + const char *active_id_str; + cbm_daemon_runtime_application_token_t active_request_token; + bool failed; +} frontend_state_t; + +struct cbm_daemon_maintenance_monitor { + cbm_thread_t thread; + cbm_version_cohort_manager_t *manager; + cbm_daemon_maintenance_cancel_fn cancel; + void *cancel_context; + atomic_bool stopping; + int exit_code; + char participant[FRONTEND_PARTICIPANT_NAME_CAP]; +}; + +static void frontend_flush_process_outputs(FILE *out) { + if (out) { + (void)fflush(out); + } + (void)fflush(stderr); +} + +static const char *frontend_maintenance_reason( + cbm_version_cohort_maintenance_presence_t presence) { + switch (presence) { + case CBM_VERSION_COHORT_MAINTENANCE_REQUESTED: + return "requested"; + case CBM_VERSION_COHORT_MAINTENANCE_UNSAFE: + return "unsafe"; + case CBM_VERSION_COHORT_MAINTENANCE_IO: + return "io"; + case CBM_VERSION_COHORT_MAINTENANCE_ABSENT: + default: + return "absent"; + } +} + +static void *frontend_maintenance_monitor_worker(void *opaque) { + cbm_daemon_maintenance_monitor_t *monitor = opaque; + while (!atomic_load_explicit(&monitor->stopping, + memory_order_acquire)) { + cbm_version_cohort_maintenance_presence_t presence = + cbm_version_cohort_maintenance_presence(monitor->manager); + if (presence == CBM_VERSION_COHORT_MAINTENANCE_ABSENT) { + cbm_usleep(FRONTEND_MAINTENANCE_POLL_MS * 1000U); + continue; + } + + if (presence == CBM_VERSION_COHORT_MAINTENANCE_REQUESTED) { + bool cancellation_requested = + monitor->cancel && monitor->cancel(monitor->cancel_context); + cbm_log_info("participant.maintenance_requested", "participant", + monitor->participant, "cooperative_cancel", + cancellation_requested ? "requested" : "not_active"); + (void)fprintf( + stderr, + "codebase-memory-mcp: active %s is stopping for install/update/uninstall\n", + monitor->participant); + frontend_flush_process_outputs(stdout); + + uint64_t now = cbm_now_ms(); + uint64_t deadline = + now > UINT64_MAX - FRONTEND_MAINTENANCE_GRACE_MS + ? UINT64_MAX + : now + FRONTEND_MAINTENANCE_GRACE_MS; + while (!atomic_load_explicit(&monitor->stopping, + memory_order_acquire) && + cbm_now_ms() < deadline) { + cbm_usleep(FRONTEND_MAINTENANCE_POLL_MS * 1000U); + } + if (atomic_load_explicit(&monitor->stopping, + memory_order_acquire)) { + return NULL; + } + cbm_log_info("participant.maintenance_forced_exit", "participant", + monitor->participant, "action", "process_exit"); + frontend_flush_process_outputs(stdout); + _Exit(monitor->exit_code); + } + + /* An observer that cannot prove absence must not let local work + * survive into a binary mutation window. Fail closed and let native + * process teardown release SQLite and cohort ownership. */ + cbm_log_error("participant.maintenance_observation_failed", + "participant", monitor->participant, "reason", + frontend_maintenance_reason(presence)); + (void)fprintf( + stderr, + "codebase-memory-mcp: %s stopped because maintenance coordination could not be verified\n", + monitor->participant); + frontend_flush_process_outputs(stdout); + _Exit(EXIT_FAILURE); + } + return NULL; +} + +cbm_daemon_maintenance_monitor_t *cbm_daemon_maintenance_monitor_start( + cbm_version_cohort_manager_t *manager, + cbm_daemon_maintenance_cancel_fn cancel, void *cancel_context, + int exit_code, const char *participant) { + if (!manager || exit_code < 0 || !participant || !participant[0]) { + return NULL; + } + cbm_daemon_maintenance_monitor_t *monitor = + calloc(1, sizeof(*monitor)); + if (!monitor) { + return NULL; + } + monitor->manager = manager; + monitor->cancel = cancel; + monitor->cancel_context = cancel_context; + monitor->exit_code = exit_code; + atomic_init(&monitor->stopping, false); + int written = snprintf(monitor->participant, + sizeof(monitor->participant), "%s", participant); + if (written <= 0 || written >= (int)sizeof(monitor->participant) || + cbm_thread_create(&monitor->thread, 0, + frontend_maintenance_monitor_worker, + monitor) != 0) { + free(monitor); + return NULL; + } + return monitor; +} + +bool cbm_daemon_maintenance_monitor_stop( + cbm_daemon_maintenance_monitor_t **monitor_io) { + if (!monitor_io || !*monitor_io) { + return false; + } + cbm_daemon_maintenance_monitor_t *monitor = *monitor_io; + atomic_store_explicit(&monitor->stopping, true, memory_order_release); + if (cbm_thread_join(&monitor->thread) != 0) { + return false; + } + free(monitor); + *monitor_io = NULL; + return true; +} + +static void frontend_exit_for_maintenance(frontend_state_t *state) { + cbm_version_cohort_maintenance_presence_t presence = + cbm_version_cohort_maintenance_presence(state->cohort_manager); + if (presence == CBM_VERSION_COHORT_MAINTENANCE_ABSENT) { + return; + } + bool requested = + presence == CBM_VERSION_COHORT_MAINTENANCE_REQUESTED; + if (requested) { + cbm_log_info("daemon.frontend.maintenance_requested", "action", + "exit_thin_frontend"); + (void)fputs( + "codebase-memory-mcp: MCP session is stopping for install/update/uninstall; restart the coding-agent session afterwards\n", + stderr); + } else { + cbm_log_error("daemon.frontend.maintenance_observation_failed", + "reason", frontend_maintenance_reason(presence), + "action", "exit_thin_frontend"); + } + /* Do not fclose stdin across threads. Process exit closes the authenticated + * kernel IPC handle, and daemon ownership then cancels only this session. */ + frontend_flush_process_outputs(state->out); + _Exit(requested ? EXIT_SUCCESS : EXIT_FAILURE); +} + +static void frontend_item_free(frontend_item_t *item) { + if (item) { + free(item->message); + free(item->id_str); + memset(item, 0, sizeof(*item)); + } +} + +static bool frontend_should_stop(frontend_state_t *state) { + cbm_mutex_lock(&state->mutex); + bool stopping = state->stopping; + cbm_mutex_unlock(&state->mutex); + return stopping; +} + +/* Pop and publish the active request identity under one mutex acquisition, so + * a cancellation reader can never observe the item as neither queued nor + * active. */ +static bool frontend_pop_begin(frontend_state_t *state, + frontend_item_t *item) { + bool popped = false; + cbm_mutex_lock(&state->mutex); + if (!state->stopping && state->count > 0) { + frontend_item_t *queued = &state->queue[state->head]; + cbm_daemon_runtime_application_token_t request_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + bool token_ready = queued->cancelled || + cbm_daemon_runtime_client_application_token_reserve( + state->client, &request_token); + if (!token_ready) { + state->failed = true; + state->stopping = true; + cbm_mutex_unlock(&state->mutex); + return false; + } + *item = state->queue[state->head]; + memset(&state->queue[state->head], 0, sizeof(state->queue[state->head])); + state->head = (state->head + 1U) % FRONTEND_QUEUE_CAPACITY; + state->count--; + state->queued_bytes -= item->length; + state->in_request = true; + state->active_has_id = item->has_id; + state->active_id = item->id; + state->active_id_str = item->id_str; + item->request_token = request_token; + state->active_request_token = request_token; + popped = true; + } + cbm_mutex_unlock(&state->mutex); + return popped; +} + +static void frontend_end_request(frontend_state_t *state, bool failed) { + cbm_mutex_lock(&state->mutex); + state->in_request = false; + state->active_has_id = false; + state->active_id = 0; + state->active_id_str = NULL; + state->active_request_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + state->failed = state->failed || failed; + cbm_mutex_unlock(&state->mutex); +} + +static bool frontend_write_response(FILE *out, const uint8_t *response, + uint32_t response_length, + bool content_length_framed) { + bool written = false; + if (content_length_framed) { + written = fprintf(out, "Content-Length: %u\r\n\r\n", + response_length) >= 0 && + fwrite(response, 1, response_length, out) == response_length; + } else { + written = fwrite(response, 1, response_length, out) == response_length && + fputc('\n', out) != EOF; + } + return fflush(out) == 0 && written; +} + +static bool frontend_write_cancelled_response(FILE *out, + const frontend_item_t *item) { + static const char cancelled_error[] = + "{\"code\":-32800,\"message\":\"Request cancelled\"}"; + cbm_jsonrpc_response_t response = { + .id = item->id, + .id_str = item->id_str, + .error_json = cancelled_error, + .error_code = -32800, + }; + char *encoded = cbm_jsonrpc_format_response(&response); + bool written = encoded && + frontend_write_response( + out, (const uint8_t *)encoded, + (uint32_t)strlen(encoded), + item->content_length_framed); + free(encoded); + return written; +} + +static bool frontend_parse_cancellation(const char *message, + cbm_jsonrpc_request_t *request_out) { + if (!message || !request_out) { + return false; + } + memset(request_out, 0, sizeof(*request_out)); + if (cbm_jsonrpc_parse(message, request_out) != 0) { + return false; + } + bool cancellation = !request_out->has_id && request_out->method && + strcmp(request_out->method, + "notifications/cancelled") == 0; + if (!cancellation) { + cbm_jsonrpc_request_free(request_out); + } + return cancellation; +} + +bool cbm_daemon_frontend_is_cancellation_notification(const char *message) { + cbm_jsonrpc_request_t request = {0}; + bool cancellation = frontend_parse_cancellation(message, &request); + if (cancellation) { + cbm_jsonrpc_request_free(&request); + } + return cancellation; +} + +bool cbm_daemon_frontend_cancellation_matches_request( + const char *message, int64_t active_id, const char *active_id_str) { + cbm_jsonrpc_request_t request = {0}; + bool cancellation = frontend_parse_cancellation(message, &request); + bool matches = cancellation && + cbm_mcp_cancel_request_matches( + request.params_raw, active_id, active_id_str); + if (cancellation) { + cbm_jsonrpc_request_free(&request); + } + return matches; +} + +typedef enum { + FRONTEND_CANCELLATION_NONE = 0, + FRONTEND_CANCELLATION_STALE = 1, + FRONTEND_CANCELLATION_QUEUED = 2, + FRONTEND_CANCELLATION_ACTIVE = 3, +} frontend_cancellation_route_t; + +/* Cancellation is an out-of-band frontend control message. A matching active + * request routes its exact runtime token without closing the authenticated + * session. A queued request is marked and receives a cancellation error + * without ever reaching the daemon. Stale/invalid targets are ignored. */ +static frontend_cancellation_route_t frontend_route_cancellation( + frontend_state_t *state, const char *message, + cbm_daemon_runtime_application_token_t *request_token_out) { + if (request_token_out) { + *request_token_out = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + } + cbm_jsonrpc_request_t request = {0}; + if (!frontend_parse_cancellation(message, &request)) { + return FRONTEND_CANCELLATION_NONE; + } + + frontend_cancellation_route_t route = FRONTEND_CANCELLATION_STALE; + cbm_mutex_lock(&state->mutex); + if (state->in_request && state->active_has_id && + state->active_request_token != + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && + cbm_mcp_cancel_request_matches( + request.params_raw, state->active_id, state->active_id_str)) { + if (request_token_out) { + *request_token_out = state->active_request_token; + } + route = FRONTEND_CANCELLATION_ACTIVE; + } else { + for (size_t offset = 0; offset < state->count; offset++) { + size_t index = + (state->head + offset) % FRONTEND_QUEUE_CAPACITY; + frontend_item_t *item = &state->queue[index]; + if (item->has_id && + cbm_mcp_cancel_request_matches( + request.params_raw, item->id, item->id_str)) { + item->cancelled = true; + route = FRONTEND_CANCELLATION_QUEUED; + break; + } + } + } + cbm_mutex_unlock(&state->mutex); + cbm_jsonrpc_request_free(&request); + return route; +} + +static void *frontend_worker(void *opaque) { + frontend_state_t *state = opaque; + uint64_t next_maintenance_check_ms = 0; + for (;;) { + uint64_t now_ms = cbm_now_ms(); + if (now_ms >= next_maintenance_check_ms) { + frontend_exit_for_maintenance(state); + next_maintenance_check_ms = + now_ms > UINT64_MAX - FRONTEND_MAINTENANCE_POLL_MS + ? UINT64_MAX + : now_ms + FRONTEND_MAINTENANCE_POLL_MS; + } + frontend_item_t item = {0}; + if (!frontend_pop_begin(state, &item)) { + if (frontend_should_stop(state)) { + break; + } + cbm_usleep(FRONTEND_WAIT_US); + continue; + } + uint8_t *response = NULL; + uint32_t response_length = 0; + bool output_failed = false; + bool failed = false; + if (item.cancelled) { + output_failed = + !frontend_write_cancelled_response(state->out, &item); + failed = output_failed; + } else { + cbm_daemon_runtime_application_status_t status = + cbm_daemon_application_client_mcp_tagged( + state->client, item.request_token, item.message, &response, + &response_length, FRONTEND_REQUEST_TIMEOUT_MS); + if (status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED) { + output_failed = + !frontend_write_cancelled_response(state->out, &item); + failed = output_failed; + } else { + failed = status != CBM_DAEMON_RUNTIME_APPLICATION_OK; + } + if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK && !failed && + response && response_length > 0) { + output_failed = !frontend_write_response( + state->out, response, response_length, + item.content_length_framed); + failed = output_failed; + } + } + free(response); + bool expected_stop = failed && frontend_should_stop(state); + frontend_end_request(state, failed && !expected_stop); + frontend_item_free(&item); + if (failed) { + if (!expected_stop) { + /* A failed daemon transport cannot wake a thread blocked in + * stdio portably. This frontend owns no state: immediate + * process exit closes the kernel IPC handle, which cancels + * daemon session ownership without a detached reader or an + * unsafe cross-thread fclose. */ + cbm_log_error("daemon.frontend.request_failed", "reason", + output_failed ? "output" : "daemon_transport", + "action", "exit_thin_frontend"); + frontend_flush_process_outputs(state->out); + _Exit(EXIT_FAILURE); + } + break; + } + } + return NULL; +} + +static bool frontend_enqueue(frontend_state_t *state, char *message, + bool content_length_framed) { + size_t length = strlen(message); + bool has_id = false; + int64_t id = 0; + char *id_str = NULL; + cbm_jsonrpc_request_t request = {0}; + if (cbm_jsonrpc_parse(message, &request) == 0) { + has_id = request.has_id; + id = request.id; + if (request.id_str) { + id_str = cbm_strdup(request.id_str); + } + bool identity_copied = !request.id_str || id_str; + cbm_jsonrpc_request_free(&request); + if (!identity_copied) { + return false; + } + } + for (;;) { + cbm_mutex_lock(&state->mutex); + bool stopped = state->stopping || state->failed; + bool capacity = state->count < FRONTEND_QUEUE_CAPACITY && + (state->count == 0 || + state->queued_bytes + length <= FRONTEND_QUEUE_BYTES_MAX); + if (!stopped && capacity) { + size_t tail = (state->head + state->count) % FRONTEND_QUEUE_CAPACITY; + state->queue[tail] = (frontend_item_t){ + .message = message, + .length = length, + .content_length_framed = content_length_framed, + .has_id = has_id, + .id = id, + .id_str = id_str, + }; + state->count++; + state->queued_bytes += length; + cbm_mutex_unlock(&state->mutex); + return true; + } + cbm_mutex_unlock(&state->mutex); + if (stopped) { + free(id_str); + return false; + } + cbm_usleep(FRONTEND_WAIT_US); + } +} + +static bool frontend_stop_begin(frontend_state_t *state) { + cbm_mutex_lock(&state->mutex); + state->stopping = true; + cbm_mutex_unlock(&state->mutex); + /* Retain the client allocation until the worker is joined. This covers the + * boundary where the worker has claimed an item but has not yet entered the + * runtime exchange: a late call observes closing instead of freed memory. */ + return cbm_daemon_runtime_client_close_begin(state->client); +} + +int cbm_daemon_frontend_mcp_run( + cbm_daemon_runtime_client_t *client, + cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out) { + if (!client || !cohort_manager || !in || !out) { + return -1; + } + frontend_state_t state = { + .client = client, + .cohort_manager = cohort_manager, + .out = out, + }; + cbm_mutex_init(&state.mutex); + cbm_thread_t worker; + if (cbm_thread_create(&worker, 0, frontend_worker, &state) != 0) { + cbm_mutex_destroy(&state.mutex); + (void)cbm_daemon_runtime_client_close(client, + FRONTEND_CLOSE_TIMEOUT_MS); + return -1; + } + + int result = 0; + bool close_begun = false; + for (;;) { + char *message = NULL; + bool content_length_framed = false; + int read_status = + cbm_mcp_read_message(in, &message, &content_length_framed); + if (read_status <= 0) { + result = read_status < 0 ? -1 : 0; + free(message); + break; + } + cbm_daemon_runtime_application_token_t cancel_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + frontend_cancellation_route_t cancellation = + frontend_route_cancellation(&state, message, &cancel_token); + if (cancellation != FRONTEND_CANCELLATION_NONE) { + free(message); + if (cancellation == FRONTEND_CANCELLATION_ACTIVE) { + cbm_daemon_runtime_cancel_result_t cancelled = + cbm_daemon_runtime_client_application_cancel( + state.client, cancel_token); + if (cancelled == CBM_DAEMON_RUNTIME_CANCEL_ERROR) { + result = -1; + close_begun = frontend_stop_begin(&state); + break; + } + } + /* A queued cancellation is represented on that queue item; stale + * and malformed targets are intentionally side-effect free. */ + continue; + } + if (!frontend_enqueue(&state, message, content_length_framed)) { + free(message); + result = -1; + break; + } + } + + if (!close_begun) { + close_begun = frontend_stop_begin(&state); + } + (void)cbm_thread_join(&worker); + if (close_begun) { + (void)cbm_daemon_runtime_client_close_finish( + state.client, FRONTEND_CLOSE_TIMEOUT_MS); + } else { + result = -1; + } + for (size_t i = 0; i < FRONTEND_QUEUE_CAPACITY; i++) { + frontend_item_free(&state.queue[i]); + } + if (state.failed) { + result = -1; + } + cbm_mutex_destroy(&state.mutex); + return result; +} diff --git a/src/daemon/frontend.h b/src/daemon/frontend.h new file mode 100644 index 000000000..86a4d6d28 --- /dev/null +++ b/src/daemon/frontend.h @@ -0,0 +1,60 @@ +/* + * frontend.h — Stateless stdio bridge for a daemon-backed MCP session. + */ +#ifndef CBM_DAEMON_FRONTEND_H +#define CBM_DAEMON_FRONTEND_H + +#include "daemon/runtime.h" +#include "daemon/version_cohort.h" + +#include +#include + +typedef struct cbm_daemon_maintenance_monitor + cbm_daemon_maintenance_monitor_t; + +/* Called once when install/update/uninstall requests an active local command + * to stop cooperatively. Returning false does not authorize the command to + * outlive the bounded grace period. */ +typedef bool (*cbm_daemon_maintenance_cancel_fn)(void *context); + +/* Parse one JSON-RPC message and recognize only the exact cancellation + * notification method. String contents, nested fields, method prefixes, and + * requests carrying an id are not cancellation notifications. */ +bool cbm_daemon_frontend_is_cancellation_notification(const char *message); + +/* Return true only when message is an exact cancellation notification whose + * params.requestId has the same JSON type and value as the active request. + * Empty, stale, and numeric-vs-string targets never authorize request + * cancellation. */ +bool cbm_daemon_frontend_cancellation_matches_request( + const char *message, int64_t active_id, const char *active_id_str); + +/* Start a temporary observer for a one-shot local CLI command or physical + * supervised worker. manager and cancel_context are borrowed until stop. On + * maintenance intent the observer invokes cancel once, permits a fixed bounded + * grace large enough for supervised worker-tree containment, then + * _Exit(exit_code) so OS/SQLite cleanup releases the participant's locks. The + * caller must stop and join before freeing either borrowed object; a false stop + * result means process-level exit is the only safe alternative to freeing + * memory still visible to the observer. */ +cbm_daemon_maintenance_monitor_t *cbm_daemon_maintenance_monitor_start( + cbm_version_cohort_manager_t *manager, + cbm_daemon_maintenance_cancel_fn cancel, void *cancel_context, + int exit_code, const char *participant); +bool cbm_daemon_maintenance_monitor_stop( + cbm_daemon_maintenance_monitor_t **monitor_io); + +/* Takes ownership of client and borrows cohort_manager for the complete call. + * A dedicated reader keeps observing stdin while one joinable worker performs + * daemon requests. The existing worker also observes maintenance while idle, + * so install/update/uninstall terminates this stateless process even when the + * reader remains blocked in stdio. Kernel IPC close then cancels only this + * session's daemon work. EOF/parse failure closes the authenticated session. + * An unexpected daemon transport failure likewise terminates the process so + * an agent waiting with stdin still open observes server EOF promptly. */ +int cbm_daemon_frontend_mcp_run( + cbm_daemon_runtime_client_t *client, + cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out); + +#endif /* CBM_DAEMON_FRONTEND_H */ diff --git a/src/daemon/host.c b/src/daemon/host.c new file mode 100644 index 000000000..fb75898d5 --- /dev/null +++ b/src/daemon/host.c @@ -0,0 +1,671 @@ +/* + * host.c — Lifecycle owner for the mandatory per-account CBM daemon. + */ +#include "daemon/host.h" +#include "daemon/host_internal.h" + +#include "daemon/application.h" +#include "daemon/runtime.h" +#include "daemon/project_lock.h" +#include "daemon/version_cohort.h" +#include "cli/cli.h" +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/compat_thread.h" +#include "foundation/diagnostics.h" +#include "foundation/log.h" +#include "foundation/mem.h" +#include "foundation/platform.h" +#include "mcp/index_supervisor.h" +#include "store/store.h" +#include "ui/config.h" +#include "ui/embedded_assets.h" +#include "ui/http_server.h" +#include "watcher/watcher.h" + +#include +#include +#include +#include +#include + +enum { + HOST_MAX_CLIENTS = 64, + HOST_LEASE_TIMEOUT_MS = 30000, + HOST_REQUEST_TIMEOUT_MS = 5000, + HOST_RUNTIME_SHUTDOWN_MS = 10000, + HOST_APPLICATION_SHUTDOWN_MS = 10000, + HOST_COORDINATION_CLEANUP_MS = 500, + HOST_INITIAL_CLIENT_TIMEOUT_MS = 10000, + HOST_WAIT_TICK_MS = 100, + HOST_WATCH_INTERVAL_MS = 5000, + HOST_CONFLICT_LOG_CAP = 1024 * 1024, + HOST_OPERATION_LOG_CAP = 5 * 1024 * 1024, + HOST_PATH_CAP = 4096, +#ifdef _WIN32 + /* LockFileEx has no non-owning F_GETLK equivalent. A lifetime probe must + * momentarily take and release the private file range. The child can also + * briefly lose the legacy-mutex handoff to another waiting starter, so it + * retries both bounded observation/compatibility windows. */ + HOST_WINDOWS_LIFETIME_ACQUIRE_MS = 250, +#endif +}; + +typedef struct { + cbm_daemon_application_t *application; + cbm_watcher_t *watcher; + cbm_store_t *watch_store; + cbm_config_t *runtime_config; + cbm_project_lock_manager_t *project_locks; + cbm_http_server_t *http; + cbm_thread_t watcher_thread; + cbm_thread_t http_thread; + bool watcher_started; + bool http_started; + bool http_config_enabled; + int http_config_port; +} host_state_t; + +static FILE *g_host_log_file = NULL; +static cbm_mutex_t g_host_log_mutex; +static bool g_host_log_mutex_initialized = false; + +static _Noreturn void host_force_terminate(const char *component); + +static void host_log_sink(const char *line) { + cbm_ui_log_append(line); + if (!g_host_log_file || !g_host_log_mutex_initialized) { + return; + } + cbm_mutex_lock(&g_host_log_mutex); + (void)fprintf(g_host_log_file, "%s\n", line ? line : ""); + (void)fflush(g_host_log_file); + cbm_mutex_unlock(&g_host_log_mutex); +} + +static bool host_log_open(char conflict_log_out[HOST_PATH_CAP]) { + const char *cache = cbm_resolve_cache_dir(); + if (!cache || !cache[0] || !conflict_log_out) { + return false; + } + char logs[HOST_PATH_CAP]; + int written = snprintf(logs, sizeof(logs), "%s/logs", cache); + if (written <= 0 || written >= (int)sizeof(logs)) { + return false; + } + written = snprintf(conflict_log_out, HOST_PATH_CAP, + "%s/daemon-conflicts.ndjson", logs); + if (written <= 0 || written >= HOST_PATH_CAP) { + return false; + } + g_host_log_file = cbm_daemon_ipc_private_log_open( + logs, "cbm-daemon.log", HOST_OPERATION_LOG_CAP); + if (!g_host_log_file) { + conflict_log_out[0] = '\0'; + return false; + } + cbm_mutex_init(&g_host_log_mutex); + g_host_log_mutex_initialized = true; + cbm_log_set_sink(host_log_sink); + return true; +} + +static void host_log_close(void) { + cbm_log_set_sink(NULL); + if (g_host_log_mutex_initialized) { + cbm_mutex_lock(&g_host_log_mutex); + } + FILE *file = g_host_log_file; + g_host_log_file = NULL; + if (file) { + (void)fclose(file); + } + if (g_host_log_mutex_initialized) { + cbm_mutex_unlock(&g_host_log_mutex); + cbm_mutex_destroy(&g_host_log_mutex); + g_host_log_mutex_initialized = false; + } +} + +static uint64_t host_deadline_after(uint32_t timeout_ms) { + uint64_t now = cbm_now_ms(); + return now > UINT64_MAX - timeout_ms ? UINT64_MAX : now + timeout_ms; +} + +static _Noreturn void host_cleanup_force_terminate(const char *component) { + /* Early startup failures can precede the ordinary operation-log setup. + * Make one best-effort attempt to establish that same owner-only durable + * sink before the process-level escape releases every native claim. */ + if (!g_host_log_file) { + char conflict_log[HOST_PATH_CAP]; + cbm_ui_log_init(); + (void)host_log_open(conflict_log); + } + host_force_terminate(component); +} + +static void host_cleanup_release_until_complete( + cbm_daemon_host_cleanup_release_for_test_fn release, void *context, + const char *component) { + if (!release) { + return; + } + uint64_t deadline = host_deadline_after(HOST_COORDINATION_CLEANUP_MS); + while (!release(context)) { + if (cbm_now_ms() >= deadline) { + host_cleanup_force_terminate(component); + } + cbm_usleep(1000); + } +} + +void cbm_daemon_host_cleanup_release_until_complete_for_test( + cbm_daemon_host_cleanup_release_for_test_fn release, void *context) { + host_cleanup_release_until_complete(release, context, + "coordination_cleanup"); +} + +static bool host_cohort_lease_release_once(void *context) { + cbm_version_cohort_lease_t **lease = context; + if (!lease || !*lease) { + return true; + } + (void)cbm_version_cohort_lease_release(lease); + return *lease == NULL; +} + +static bool host_cohort_manager_free_once(void *context) { + cbm_version_cohort_manager_t **manager = context; + if (!manager || !*manager) { + return true; + } + (void)cbm_version_cohort_manager_free(manager); + return *manager == NULL; +} + +static void host_cohort_close(cbm_version_cohort_lease_t **lease, + cbm_version_cohort_manager_t **manager) { + host_cleanup_release_until_complete(host_cohort_lease_release_once, lease, + "cohort_lease_cleanup"); + host_cleanup_release_until_complete(host_cohort_manager_free_once, manager, + "cohort_manager_cleanup"); +} + +static bool host_daemon_claim_release_once(void *context) { + cbm_version_cohort_daemon_claim_t **claim = context; + if (!claim || !*claim) { + return true; + } + (void)cbm_version_cohort_daemon_claim_release(claim); + return *claim == NULL; +} + +static void host_daemon_claim_close( + cbm_version_cohort_daemon_claim_t **claim) { + host_cleanup_release_until_complete(host_daemon_claim_release_once, + claim, "daemon_claim_cleanup"); +} + +static bool host_participant_guard_release_once(void *context) { + cbm_daemon_ipc_participant_guard_t **guard = context; + if (!guard || !*guard) { + return true; + } + (void)cbm_daemon_ipc_participant_guard_release(guard); + return *guard == NULL; +} + +static void host_participant_guard_close( + cbm_daemon_ipc_participant_guard_t **guard) { + host_cleanup_release_until_complete(host_participant_guard_release_once, + guard, "participant_guard_cleanup"); +} + +static int host_lifetime_reservation_acquire( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_lifetime_reservation_t **reservation_out) { +#ifdef _WIN32 + uint64_t now = cbm_now_ms(); + uint64_t deadline = + now > UINT64_MAX - HOST_WINDOWS_LIFETIME_ACQUIRE_MS + ? UINT64_MAX + : now + HOST_WINDOWS_LIFETIME_ACQUIRE_MS; + do { + int status = cbm_daemon_ipc_lifetime_reservation_try_acquire( + endpoint, reservation_out); + if (status != 0) { + return status; + } + cbm_usleep(1000); + } while (cbm_now_ms() < deadline); + return 0; +#else + return cbm_daemon_ipc_lifetime_reservation_try_acquire( + endpoint, reservation_out); +#endif +} + +static void *host_watcher_thread(void *opaque) { + cbm_watcher_run(opaque, HOST_WATCH_INTERVAL_MS); + return NULL; +} + +static void *host_http_thread(void *opaque) { + cbm_http_server_run(opaque); + return NULL; +} + +static int host_watcher_index(const char *project_name, const char *root_path, + void *opaque) { + host_state_t *host = opaque; + return host && host->application + ? cbm_daemon_application_watcher_index(project_name, root_path, + host->application) + : -1; +} + +static int host_ui_index(void *opaque, const char *root_path, + const char *project_name) { + host_state_t *host = opaque; + return host && host->application + ? cbm_daemon_application_index(host->application, + project_name ? project_name : "", + root_path) + : -1; +} + +static bool host_ui_mutation_begin(void *opaque, const char *project) { + host_state_t *host = opaque; + return host && host->application && + cbm_daemon_application_project_mutation_try_begin(host->application, + project); +} + +static void host_ui_mutation_end(void *opaque, const char *project) { + host_state_t *host = opaque; + if (host && host->application) { + cbm_daemon_application_project_mutation_end(host->application, project); + } +} + +static void host_http_stop_join_free(host_state_t *host) { + if (host->http) { + cbm_http_server_stop(host->http); + } + if (host->http_started) { + (void)cbm_thread_join(&host->http_thread); + host->http_started = false; + } + cbm_http_server_free(host->http); + host->http = NULL; +} + +static void host_http_reconcile(host_state_t *host) { + cbm_ui_config_t desired; + cbm_ui_config_load(&desired); + if (desired.ui_enabled == host->http_config_enabled && + desired.ui_port == host->http_config_port) { + return; + } + host_http_stop_join_free(host); + host->http_config_enabled = desired.ui_enabled; + host->http_config_port = desired.ui_port; + if (!desired.ui_enabled) { + return; + } + if (CBM_EMBEDDED_FILE_COUNT == 0) { + cbm_log_warn("ui.no_assets", "hint", + "rebuild with: make -f Makefile.cbm cbm-with-ui"); + return; + } + host->http = cbm_http_server_new(desired.ui_port); + if (!host->http) { + return; + } + cbm_http_server_set_watcher(host->http, host->watcher); + cbm_http_server_set_index_executor(host->http, host_ui_index, host); + cbm_http_server_set_project_mutation_guard( + host->http, host_ui_mutation_begin, host_ui_mutation_end, host); + if (cbm_thread_create(&host->http_thread, 0, host_http_thread, + host->http) == 0) { + host->http_started = true; + } else { + cbm_http_server_free(host->http); + host->http = NULL; + } +} + +static void host_background_stop(host_state_t *host) { + if (host->http) { + cbm_http_server_stop(host->http); + } + if (host->watcher) { + cbm_watcher_stop(host->watcher); + } +} + +static void host_background_join(host_state_t *host) { + if (host->http_started) { + (void)cbm_thread_join(&host->http_thread); + host->http_started = false; + } + if (host->watcher_started) { + (void)cbm_thread_join(&host->watcher_thread); + host->watcher_started = false; + } +} + +static bool host_project_lock_manager_free_once(void *context) { + cbm_project_lock_manager_t **manager = context; + if (!manager || !*manager) { + return true; + } + (void)cbm_project_lock_manager_free(manager); + return *manager == NULL; +} + +static void host_state_free(host_state_t *host) { + host_http_stop_join_free(host); + cbm_daemon_application_free(host->application); + host->application = NULL; + cbm_watcher_free(host->watcher); + host->watcher = NULL; + cbm_store_close(host->watch_store); + host->watch_store = NULL; + cbm_config_close(host->runtime_config); + host->runtime_config = NULL; + if (host->project_locks) { + host_cleanup_release_until_complete( + host_project_lock_manager_free_once, &host->project_locks, + "project_lock_manager_cleanup"); + } +} + +static bool host_state_prepare(host_state_t *host, + const cbm_daemon_ipc_endpoint_t *endpoint) { + size_t aggregate_memory_budget_bytes = cbm_mem_budget(); + const char *cache = cbm_resolve_cache_dir(); + host->runtime_config = cache ? cbm_config_open(cache) : NULL; + host->watch_store = cbm_store_open_memory(); + host->project_locks = cbm_project_lock_manager_new(endpoint); + host->watcher = cbm_watcher_new(host->watch_store, host_watcher_index, host); + cbm_daemon_application_config_t application_config = { + .watcher = host->watcher, + .config = host->runtime_config, + .aggregate_memory_budget_bytes = aggregate_memory_budget_bytes, + .project_locks = host->project_locks, + }; + host->application = cbm_daemon_application_new(&application_config); + if (!host->watch_store || !host->watcher || !host->project_locks || + !host->application) { + return false; + } + return true; +} + +static bool host_background_start(host_state_t *host) { + if (cbm_thread_create(&host->watcher_thread, 0, host_watcher_thread, + host->watcher) != 0) { + return false; + } + host->watcher_started = true; + + /* Force the first reconciliation even when defaults are false/9749. */ + host->http_config_port = -1; + host_http_reconcile(host); + return true; +} + +static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service, + atomic_int *stop_requested, + host_state_t *host) { + uint64_t initial_deadline = cbm_now_ms() + HOST_INITIAL_CLIENT_TIMEOUT_MS; + bool admitted = false; + uint64_t stopping_deadline = 0; + for (;;) { + cbm_daemon_runtime_service_state_t state = + cbm_daemon_runtime_service_state(service); + if (state == CBM_DAEMON_RUNTIME_SERVICE_EXITED) { + return true; + } + if (state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING) { + if (stopping_deadline == 0) { + stopping_deadline = + cbm_now_ms() + HOST_RUNTIME_SHUTDOWN_MS; + } + if (cbm_now_ms() >= stopping_deadline) { + return false; + } + (void)cbm_daemon_runtime_service_wait_exited( + service, HOST_WAIT_TICK_MS); + continue; + } + if (cbm_daemon_runtime_service_active_clients(service) > 0) { + admitted = true; + } + bool stop = stop_requested && atomic_load(stop_requested); + if (stop || (!admitted && cbm_now_ms() >= initial_deadline)) { + return cbm_daemon_runtime_service_stop( + service, HOST_RUNTIME_SHUTDOWN_MS); + } + host_http_reconcile(host); + (void)cbm_daemon_runtime_service_wait_exited(service, + HOST_WAIT_TICK_MS); + } +} + +static bool host_application_shutdown(host_state_t *host) { + if (cbm_daemon_application_shutdown(host->application, + HOST_APPLICATION_SHUTDOWN_MS)) { + return true; + } + cbm_log_error("daemon.shutdown_timeout", "component", "operations"); + return false; +} + +static bool host_runtime_stop_free(cbm_daemon_runtime_service_t *service) { + if (cbm_daemon_runtime_service_state(service) != + CBM_DAEMON_RUNTIME_SERVICE_EXITED) { + if (!cbm_daemon_runtime_service_stop( + service, HOST_RUNTIME_SHUTDOWN_MS)) { + cbm_log_error("daemon.shutdown_timeout", "component", "runtime"); + return false; + } + } + if (!cbm_daemon_runtime_service_free(service)) { + cbm_log_error("daemon.cleanup_timeout", "component", "runtime"); + return false; + } + return true; +} + +static _Noreturn void host_force_terminate(const char *component) { + /* The operation-log sink flushes every record before returning. Do not run + * ordinary teardown after the cooperative deadline: a callback thread may + * still own application storage. Process termination releases native + * locks, and supervised worker groups observe their daemon parent's death. */ + cbm_log_error("daemon.forced_shutdown", "component", component); +#ifdef _WIN32 + (void)TerminateProcess(GetCurrentProcess(), EXIT_FAILURE); + abort(); +#else + _exit(EXIT_FAILURE); +#endif +} + +_Noreturn void cbm_daemon_host_force_terminate_for_test( + const char *component) { + char conflict_log[HOST_PATH_CAP]; + cbm_ui_log_init(); + if (host_log_open(conflict_log)) { + host_force_terminate(component ? component : "test"); + } +#ifdef _WIN32 + (void)TerminateProcess(GetCurrentProcess(), EXIT_FAILURE); + abort(); +#else + _exit(EXIT_FAILURE); +#endif +} + +int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { + if (!config || !config->endpoint || !config->executable_path || + !config->identity.semantic_version || + !config->identity.build_fingerprint) { + return -1; + } + cbm_version_cohort_manager_t *cohort_manager = + cbm_version_cohort_manager_new(config->endpoint); + cbm_version_cohort_lease_t *cohort_lease = NULL; + cbm_daemon_conflict_t cohort_conflict; + uint64_t now_ms = cbm_now_ms(); + uint64_t cohort_deadline = + now_ms > UINT64_MAX - HOST_INITIAL_CLIENT_TIMEOUT_MS + ? UINT64_MAX + : now_ms + HOST_INITIAL_CLIENT_TIMEOUT_MS; + cbm_version_cohort_status_t cohort_status = + cohort_manager + ? cbm_version_cohort_acquire( + cohort_manager, &config->identity, cohort_deadline, + &cohort_lease, &cohort_conflict) + : CBM_VERSION_COHORT_IO; + if (cohort_status != CBM_VERSION_COHORT_OK) { + char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; + bool formatted = cohort_status == CBM_VERSION_COHORT_CONFLICT && + cbm_daemon_conflict_format( + &cohort_conflict, message, sizeof(message)); + if (cohort_status == CBM_VERSION_COHORT_CONFLICT) { + (void)cbm_version_cohort_log_conflict(&cohort_conflict); + } + (void)fprintf(stderr, "codebase-memory: %s\n", + formatted ? message + : "daemon exact-build admission failed"); + host_cohort_close(&cohort_lease, &cohort_manager); + return -1; + } + cbm_daemon_ipc_participant_guard_t *participant_guard = NULL; + if (cbm_daemon_ipc_participant_guard_try_join( + config->endpoint, &participant_guard) != 1) { + (void)fprintf(stderr, + "codebase-memory: daemon participant admission failed\n"); + host_participant_guard_close(&participant_guard); + host_cohort_close(&cohort_lease, &cohort_manager); + return -1; + } + cbm_daemon_ipc_lifetime_reservation_t *lifetime_reservation = NULL; + if (host_lifetime_reservation_acquire( + config->endpoint, &lifetime_reservation) != 1) { + host_participant_guard_close(&participant_guard); + host_cohort_close(&cohort_lease, &cohort_manager); + return -1; + } + cbm_version_cohort_daemon_claim_t *daemon_claim = NULL; + if (cbm_version_cohort_daemon_claim_acquire( + cohort_manager, &daemon_claim) != CBM_VERSION_COHORT_OK) { + cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation); + host_daemon_claim_close(&daemon_claim); + host_participant_guard_close(&participant_guard); + host_cohort_close(&cohort_lease, &cohort_manager); + return -1; + } + cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram)); + cbm_ui_log_init(); + char conflict_log[HOST_PATH_CAP]; + if (!host_log_open(conflict_log)) { + (void)fprintf(stderr, + "codebase-memory: daemon log path is not private or safe\n"); + cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation); + host_daemon_claim_close(&daemon_claim); + host_participant_guard_close(&participant_guard); + host_cohort_close(&cohort_lease, &cohort_manager); + return -1; + } + cbm_http_server_set_binary_path(config->executable_path); + cbm_index_supervisor_mark_host(); + + host_state_t host = {0}; + if (!host_state_prepare(&host, config->endpoint)) { + cbm_log_error("daemon.start_failed", "component", "application"); + host_state_free(&host); + host_log_close(); + cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation); + host_daemon_claim_close(&daemon_claim); + host_participant_guard_close(&participant_guard); + host_cohort_close(&cohort_lease, &cohort_manager); + return -1; + } + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(host.application); + cbm_daemon_runtime_service_config_t runtime_config = { + .endpoint = config->endpoint, + .identity = config->identity, + .conflict_log_path = conflict_log, + .conflict_log_cap_bytes = HOST_CONFLICT_LOG_CAP, + .max_clients = HOST_MAX_CLIENTS, + .lease_timeout_ms = HOST_LEASE_TIMEOUT_MS, + .request_timeout_ms = HOST_REQUEST_TIMEOUT_MS, + .shutdown_timeout_ms = HOST_RUNTIME_SHUTDOWN_MS, + .application = callbacks, + }; + cbm_daemon_runtime_service_t *service = + cbm_daemon_runtime_service_start_reserved( + &runtime_config, &lifetime_reservation); + cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation); + lifetime_reservation = NULL; + if (!service) { + cbm_log_error("daemon.start_failed", "component", "runtime"); + host_state_free(&host); + host_log_close(); + host_daemon_claim_close(&daemon_claim); + host_participant_guard_close(&participant_guard); + host_cohort_close(&cohort_lease, &cohort_manager); + return -1; + } + if (!host_background_start(&host)) { + cbm_log_error("daemon.start_failed", "component", "background"); + if (!host_application_shutdown(&host)) { + host_force_terminate("operations"); + } + host_background_stop(&host); + host_background_join(&host); + if (!host_runtime_stop_free(service)) { + host_force_terminate("runtime"); + } + host_state_free(&host); + host_log_close(); + host_daemon_claim_close(&daemon_claim); + host_participant_guard_close(&participant_guard); + host_cohort_close(&cohort_lease, &cohort_manager); + return -1; + } + cbm_diag_start(); + + cbm_log_info("daemon.start", "version", config->identity.semantic_version); + if (!host_wait_for_lifetime(service, config->stop_requested, &host)) { + host_force_terminate("runtime"); + } + + /* Prevent any new UI/watcher operation, then cancel/reap every physical + * job before those background loops and the daemon process disappear. */ + if (!host_application_shutdown(&host)) { + host_force_terminate("operations"); + } + host_background_stop(&host); + host_background_join(&host); + if (!host_runtime_stop_free(service)) { + host_force_terminate("runtime"); + } + host_state_free(&host); + cbm_diag_stop(); + cbm_log_info("daemon.stop"); + host_log_close(); + /* Keep the exact-build lifetime lease through listener/lifetime teardown, + * application destruction, diagnostics shutdown, the durable stop record, + * daemon-claim release, and participant release. An activation transaction + * may treat exclusive acquisition as proof that coordinated cleanup for + * this generation has completed. */ + host_daemon_claim_close(&daemon_claim); + host_participant_guard_close(&participant_guard); + host_cohort_close(&cohort_lease, &cohort_manager); + return 0; +} diff --git a/src/daemon/host.h b/src/daemon/host.h new file mode 100644 index 000000000..da760417d --- /dev/null +++ b/src/daemon/host.h @@ -0,0 +1,24 @@ +/* + * host.h — Lifecycle owner for the mandatory per-account CBM daemon. + */ +#ifndef CBM_DAEMON_HOST_H +#define CBM_DAEMON_HOST_H + +#include "daemon/ipc.h" +#include "daemon/service.h" + +#include + +typedef struct { + const cbm_daemon_ipc_endpoint_t *endpoint; + cbm_daemon_build_identity_t identity; + const char *executable_path; + atomic_int *stop_requested; +} cbm_daemon_host_config_t; + +/* Blocks for the complete daemon generation. The first admitted frontend owns + * startup; the final disconnect triggers terminal shutdown of operations, + * watcher, UI, and runtime in that order. */ +int cbm_daemon_host_run(const cbm_daemon_host_config_t *config); + +#endif /* CBM_DAEMON_HOST_H */ diff --git a/src/daemon/host_internal.h b/src/daemon/host_internal.h new file mode 100644 index 000000000..24570af47 --- /dev/null +++ b/src/daemon/host_internal.h @@ -0,0 +1,23 @@ +/* + * host_internal.h — Private daemon-host test seams. + */ +#ifndef CBM_DAEMON_HOST_INTERNAL_H +#define CBM_DAEMON_HOST_INTERNAL_H + +#include + +typedef bool (*cbm_daemon_host_cleanup_release_for_test_fn)(void *context); + +/* Runs the same finite retry/fail-stop driver used by daemon-claim shutdown + * around an injected release operation. The hook is process-local and inert + * unless a focused subprocess test calls this seam. */ +void cbm_daemon_host_cleanup_release_until_complete_for_test( + cbm_daemon_host_cleanup_release_for_test_fn release, void *context); + +/* Opens the ordinary private daemon operation log, records the same forced + * shutdown event used by production, flushes it, and terminates the process. + * This exists only so an isolated child can verify the hard escape path. */ +_Noreturn void cbm_daemon_host_force_terminate_for_test( + const char *component); + +#endif /* CBM_DAEMON_HOST_INTERNAL_H */ diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c new file mode 100644 index 000000000..46f4d064f --- /dev/null +++ b/src/daemon/ipc.c @@ -0,0 +1,6179 @@ +/* + * ipc.c — Authenticated, owner-scoped local transport for the CBM daemon. + */ +#include "daemon/ipc.h" +#include "daemon/ipc_internal.h" + +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/compat_thread.h" +#include "foundation/log.h" +#include "foundation/macos_acl.h" +#include "foundation/private_file_lock_internal.h" +#include "foundation/sha256.h" + +#include +#include +#include +#include +#include +#include +#include + +enum { + CBM_DAEMON_IPC_SEND_TIMEOUT_MS = 5000, + CBM_DAEMON_IPC_PATH_CAP = 4096, + CBM_DAEMON_IPC_RETRY_INTERVAL_MS = 10, + CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS = 500, + CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RETRY_MS = 250, +}; + +static bool instance_key_valid(const char *key) { + if (!key) { + return false; + } + for (size_t i = 0; i < 16; i++) { + char ch = key[i]; + bool decimal = ch >= '0' && ch <= '9'; + bool lower = ch >= 'a' && ch <= 'f'; + if (!decimal && !lower) { + return false; + } + } + return key[16] == '\0'; +} + +static char *string_copy(const char *value) { + if (!value) { + return NULL; + } + size_t length = strlen(value); + char *copy = malloc(length + 1); + if (copy) { + memcpy(copy, value, length + 1); + } + return copy; +} + +static char *string_format(const char *format, ...) { + va_list args; + va_start(args, format); + va_list measure; + va_copy(measure, args); + int needed = vsnprintf(NULL, 0, format, measure); + va_end(measure); + if (needed < 0) { + va_end(args); + return NULL; + } + char *result = malloc((size_t)needed + 1); + if (!result || vsnprintf(result, (size_t)needed + 1, format, args) != needed) { + free(result); + result = NULL; + } + va_end(args); + return result; +} + +static cbm_daemon_ipc_posix_publication_hook_fn + g_posix_publication_hook_for_test; +static void *g_posix_publication_hook_context_for_test; +static atomic_uint g_windows_legacy_guard_release_failures_for_test; + +void cbm_daemon_ipc_posix_publication_hook_set_for_test( + cbm_daemon_ipc_posix_publication_hook_fn hook, void *context) { + g_posix_publication_hook_context_for_test = context; + g_posix_publication_hook_for_test = hook; +} + +void cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test( + unsigned int count) { + atomic_store_explicit( + &g_windows_legacy_guard_release_failures_for_test, count, + memory_order_release); +} + +bool cbm_daemon_ipc_windows_legacy_names( + const char *canonical_runtime_parent, const char *instance_key, + char pipe_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP], + char startup_mutex_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]) { + if (!canonical_runtime_parent || !canonical_runtime_parent[0] || + !instance_key_valid(instance_key) || !pipe_out || + !startup_mutex_out) { + return false; + } + uint64_t hash = UINT64_C(14695981039346656037); + const unsigned char *cursor = + (const unsigned char *)canonical_runtime_parent; + while (*cursor) { + hash ^= *cursor++; + hash *= UINT64_C(1099511628211); + } + int pipe_length = snprintf( + pipe_out, CBM_DAEMON_IPC_WINDOWS_NAME_CAP, + "\\\\.\\pipe\\cbm-daemon-%016llx-%s", (unsigned long long)hash, + instance_key); + int mutex_length = snprintf( + startup_mutex_out, CBM_DAEMON_IPC_WINDOWS_NAME_CAP, + "Local\\cbm-daemon-%016llx-%s-startup", (unsigned long long)hash, + instance_key); + return pipe_length > 0 && + (size_t)pipe_length < CBM_DAEMON_IPC_WINDOWS_NAME_CAP && + mutex_length > 0 && + (size_t)mutex_length < CBM_DAEMON_IPC_WINDOWS_NAME_CAP; +} + +static bool windows_sid_valid(const uint8_t *sid, size_t sid_length) { + enum { + WINDOWS_SID_HEADER_SIZE = 8, + WINDOWS_SID_SUBAUTHORITY_SIZE = 4, + WINDOWS_SID_SUBAUTHORITY_MAX = 15, + }; + return sid && sid_length >= WINDOWS_SID_HEADER_SIZE && sid[0] == 1 && + sid[1] <= WINDOWS_SID_SUBAUTHORITY_MAX && + sid_length == + WINDOWS_SID_HEADER_SIZE + + (size_t)sid[1] * WINDOWS_SID_SUBAUTHORITY_SIZE; +} + +static bool windows_pipe_address_valid(const char *address) { + static const char prefix[] = "\\\\.\\pipe\\cbm-daemon-"; + if (!address || strncmp(address, prefix, sizeof(prefix) - 1U) != 0) { + return false; + } + const char *digest = address + sizeof(prefix) - 1U; + for (size_t index = 0; index < CBM_SHA256_HEX_LEN; index++) { + char character = digest[index]; + if (!((character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f'))) { + return false; + } + } + return digest[CBM_SHA256_HEX_LEN] == '\0'; +} + +bool cbm_daemon_ipc_windows_generation_address( + const uint8_t *sid, size_t sid_length, const char *instance_key, + const uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE], + char address_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]) { + static const uint8_t domain[] = "cbm-daemon-win-pipe-v1"; + if (!windows_sid_valid(sid, sid_length) || + !instance_key_valid(instance_key) || !nonce || !address_out) { + return false; + } + cbm_sha256_ctx context; + cbm_sha256_init(&context); + cbm_sha256_update(&context, domain, sizeof(domain) - 1U); + cbm_sha256_update(&context, sid, sid_length); + cbm_sha256_update(&context, instance_key, 16U); + cbm_sha256_update(&context, nonce, + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE); + uint8_t digest[CBM_SHA256_DIGEST_LEN]; + cbm_sha256_final(&context, digest); + + char digest_hex[CBM_SHA256_HEX_LEN + 1U]; + static const char hex[] = "0123456789abcdef"; + for (size_t index = 0; index < sizeof(digest); index++) { + digest_hex[index * 2U] = hex[digest[index] >> 4U]; + digest_hex[index * 2U + 1U] = hex[digest[index] & 0x0fU]; + } + digest_hex[CBM_SHA256_HEX_LEN] = '\0'; + int written = snprintf(address_out, CBM_DAEMON_IPC_WINDOWS_NAME_CAP, + "\\\\.\\pipe\\cbm-daemon-%s", digest_hex); + return written > 0 && + (size_t)written < CBM_DAEMON_IPC_WINDOWS_NAME_CAP; +} + +bool cbm_daemon_ipc_windows_rendezvous_record_encode( + const uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE], + const char *address, + uint8_t record_out[CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE]) { + static const uint8_t magic[8] = {'C', 'B', 'M', 'R', 'D', 'V', '1', 0}; + if (!nonce || !windows_pipe_address_valid(address) || !record_out) { + return false; + } + memset(record_out, 0, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE); + memcpy(record_out, magic, sizeof(magic)); + memcpy(record_out + sizeof(magic), nonce, + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE); + size_t address_length = strlen(address); + memcpy(record_out + sizeof(magic) + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE, + address, address_length + 1U); + return true; +} + +bool cbm_daemon_ipc_windows_rendezvous_record_decode( + const uint8_t *record, size_t record_length, + uint8_t nonce_out[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE], + char address_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]) { + static const uint8_t magic[8] = {'C', 'B', 'M', 'R', 'D', 'V', '1', 0}; + if (!record || record_length != + CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE || + !nonce_out || !address_out || memcmp(record, magic, sizeof(magic)) != 0) { + return false; + } + const uint8_t *encoded_address = + record + sizeof(magic) + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE; + const uint8_t *terminator = + memchr(encoded_address, 0, CBM_DAEMON_IPC_WINDOWS_NAME_CAP); + if (!terminator) { + return false; + } + size_t address_length = (size_t)(terminator - encoded_address); + if (address_length + 1U > CBM_DAEMON_IPC_WINDOWS_NAME_CAP) { + return false; + } + for (size_t index = address_length + 1U; + index < CBM_DAEMON_IPC_WINDOWS_NAME_CAP; index++) { + if (encoded_address[index] != 0) { + return false; + } + } + memcpy(address_out, encoded_address, address_length + 1U); + if (!windows_pipe_address_valid(address_out)) { + memset(address_out, 0, CBM_DAEMON_IPC_WINDOWS_NAME_CAP); + return false; + } + memcpy(nonce_out, record + sizeof(magic), + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE); + return true; +} + +int cbm_daemon_ipc_wait_pending(const cbm_ipc_pending_ops_t *ops, uint32_t timeout_ms, + uint32_t *transferred_out) { + if (!ops || !ops->wait || !ops->cancel || !ops->finish || !transferred_out) { + return -1; + } + cbm_ipc_pending_wait_status_t wait_status = ops->wait(ops->context, timeout_ms); + if (wait_status == CBM_IPC_PENDING_WAIT_SIGNALED) { + return ops->finish(ops->context, false, transferred_out) == CBM_IPC_PENDING_FINISH_COMPLETED + ? 1 + : -1; + } + + /* Cancellation is asynchronous. Keep the platform operation alive and + * drain it to a terminal state before returning, even when waiting itself + * failed. */ + ops->cancel(ops->context); + uint32_t transferred = 0; + cbm_ipc_pending_finish_status_t finish_status = ops->finish(ops->context, true, &transferred); + if (wait_status != CBM_IPC_PENDING_WAIT_TIMEOUT) { + return -1; + } + if (finish_status == CBM_IPC_PENDING_FINISH_COMPLETED) { + *transferred_out = transferred; + return 1; + } + return finish_status == CBM_IPC_PENDING_FINISH_CANCELLED ? 0 : -1; +} + +#ifndef _WIN32 + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifndef O_CLOEXEC +#define O_CLOEXEC 0 +#endif +#ifndef O_DIRECTORY +#define O_DIRECTORY 0 +#endif +#ifndef O_NOFOLLOW +#define O_NOFOLLOW 0 +#endif +#ifndef AT_SYMLINK_NOFOLLOW +#define AT_SYMLINK_NOFOLLOW 0 +#endif + +enum { + POSIX_SOCKET_RECORD_MAGIC_SIZE = 8, + POSIX_SOCKET_IDENTITY_DEVICE_OFFSET = POSIX_SOCKET_RECORD_MAGIC_SIZE, + POSIX_SOCKET_IDENTITY_INODE_OFFSET = + POSIX_SOCKET_IDENTITY_DEVICE_OFFSET + 8, + POSIX_SOCKET_IDENTITY_CTIME_SECONDS_OFFSET = + POSIX_SOCKET_IDENTITY_INODE_OFFSET + 8, + POSIX_SOCKET_IDENTITY_CTIME_NANOSECONDS_OFFSET = + POSIX_SOCKET_IDENTITY_CTIME_SECONDS_OFFSET + 8, + POSIX_SOCKET_IDENTITY_RESERVED_OFFSET = + POSIX_SOCKET_IDENTITY_CTIME_NANOSECONDS_OFFSET + 4, + POSIX_SOCKET_ANCHOR_NAME_OFFSET = + POSIX_SOCKET_IDENTITY_RESERVED_OFFSET + 4, + POSIX_SOCKET_ANCHOR_NAME_CAP = 64, + POSIX_SOCKET_RECORD_SIZE = + POSIX_SOCKET_ANCHOR_NAME_OFFSET + POSIX_SOCKET_ANCHOR_NAME_CAP, +}; + +static const uint8_t POSIX_SOCKET_MARKER_MAGIC[ + POSIX_SOCKET_RECORD_MAGIC_SIZE] = { + 'C', 'B', 'M', 'S', 'O', 'C', 2, 0, +}; + +static const uint8_t POSIX_SOCKET_PENDING_MAGIC[ + POSIX_SOCKET_RECORD_MAGIC_SIZE] = { + 'C', 'B', 'M', 'P', 'E', 'N', 1, 0, +}; + +typedef struct { + dev_t device; + ino_t inode; + int64_t ctime_seconds; + uint32_t ctime_nanoseconds; +} posix_socket_identity_t; + +typedef struct { + posix_socket_identity_t identity; + char anchor_name[POSIX_SOCKET_ANCHOR_NAME_CAP]; +} posix_socket_record_t; + +static void posix_publication_stage_reached( + cbm_daemon_ipc_posix_publication_stage_t stage) { + cbm_daemon_ipc_posix_publication_hook_fn hook = + g_posix_publication_hook_for_test; + if (hook) { + hook(stage, g_posix_publication_hook_context_for_test); + } +} + +static void posix_record_publication_stage_reached( + const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], bool linked) { + cbm_daemon_ipc_posix_publication_stage_t stage; + if (memcmp(magic, POSIX_SOCKET_PENDING_MAGIC, + POSIX_SOCKET_RECORD_MAGIC_SIZE) == 0) { + stage = linked + ? CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_RECORD_LINKED + : CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_TEMP_SYNCED; + } else if (memcmp(magic, POSIX_SOCKET_MARKER_MAGIC, + POSIX_SOCKET_RECORD_MAGIC_SIZE) == 0) { + stage = linked + ? CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_RECORD_LINKED + : CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_TEMP_SYNCED; + } else { + return; + } + posix_publication_stage_reached(stage); +} + +struct cbm_daemon_ipc_endpoint { + char *runtime_dir; + char *address; + char *socket_name; + char *socket_anchor_address; + char *socket_anchor_name; + char *socket_identity_name; + char *socket_pending_name; + char *lock_name; + char *startup_v2_lock_name; + char *lifetime_lock_name; + int dir_fd; + dev_t dir_device; + ino_t dir_inode; +}; + +struct cbm_daemon_ipc_lifetime_reservation { + int fd; + struct process_lock_entry *process_entry; + pid_t owner_pid; +}; + +struct cbm_daemon_ipc_listener { + int fd; + int dir_fd; + char *runtime_dir; + dev_t dir_device; + ino_t dir_inode; + char *address; + char *socket_name; + char *socket_anchor_name; + char *socket_identity_name; + char *socket_pending_name; + posix_socket_identity_t socket_identity; + dev_t identity_device; + ino_t identity_inode; + pid_t owner_pid; + cbm_daemon_ipc_lifetime_reservation_t *lifetime_reservation; + cbm_daemon_ipc_participant_guard_t *participant_guard; +}; + +struct cbm_daemon_ipc_connection { + int fd; + atomic_bool poisoned; +}; + +typedef struct process_lock_entry { + dev_t directory_device; + ino_t directory_inode; + char *lock_name; + bool shared; + struct process_lock_entry *next; +} process_lock_entry_t; + +struct cbm_daemon_ipc_startup_lock { + int startup_v2_fd; + process_lock_entry_t *startup_v2_process_entry; + int legacy_fd; + process_lock_entry_t *legacy_process_entry; + cbm_daemon_ipc_endpoint_t *endpoint_snapshot; + pid_t owner_pid; + bool prepared; +}; + +struct cbm_daemon_ipc_local_transition { + int startup_v2_fd; + process_lock_entry_t *startup_v2_process_entry; + int legacy_fd; + process_lock_entry_t *legacy_process_entry; + const cbm_daemon_ipc_endpoint_t *endpoint; + pid_t owner_pid; + bool sealed; + bool work_begun; +}; + +struct cbm_daemon_ipc_participant_guard { + int legacy_fd; + process_lock_entry_t *legacy_process_entry; + pid_t owner_pid; +}; + +static pthread_mutex_t process_locks_mutex = PTHREAD_MUTEX_INITIALIZER; +static process_lock_entry_t *process_locks; + +static bool posix_socket_status_secure_transport( + const struct stat *status); + +static int process_lock_claim_mode( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, + bool shared, process_lock_entry_t **entry_out) { + *entry_out = NULL; + if (!endpoint || !lock_name || pthread_mutex_lock(&process_locks_mutex) != 0) { + return -1; + } + for (process_lock_entry_t *entry = process_locks; entry; entry = entry->next) { + if (entry->directory_device == endpoint->dir_device && + entry->directory_inode == endpoint->dir_inode && + strcmp(entry->lock_name, lock_name) == 0 && + (!shared || !entry->shared)) { + (void)pthread_mutex_unlock(&process_locks_mutex); + return 0; + } + } + process_lock_entry_t *entry = calloc(1, sizeof(*entry)); + if (entry) { + entry->lock_name = string_copy(lock_name); + } + if (!entry || !entry->lock_name) { + free(entry); + (void)pthread_mutex_unlock(&process_locks_mutex); + return -1; + } + entry->directory_device = endpoint->dir_device; + entry->directory_inode = endpoint->dir_inode; + entry->shared = shared; + entry->next = process_locks; + process_locks = entry; + *entry_out = entry; + (void)pthread_mutex_unlock(&process_locks_mutex); + return 1; +} + +static int process_lock_claim(const cbm_daemon_ipc_endpoint_t *endpoint, + const char *lock_name, + process_lock_entry_t **entry_out) { + return process_lock_claim_mode(endpoint, lock_name, false, entry_out); +} + +static void process_lock_unclaim(process_lock_entry_t *claimed) { + if (!claimed || pthread_mutex_lock(&process_locks_mutex) != 0) { + return; + } + process_lock_entry_t **cursor = &process_locks; + while (*cursor && *cursor != claimed) { + cursor = &(*cursor)->next; + } + if (*cursor) { + *cursor = claimed->next; + free(claimed->lock_name); + free(claimed); + } + (void)pthread_mutex_unlock(&process_locks_mutex); +} + +static int process_lock_is_claimed(const cbm_daemon_ipc_endpoint_t *endpoint, + const char *lock_name) { + if (!endpoint || !lock_name || pthread_mutex_lock(&process_locks_mutex) != 0) { + return -1; + } + int claimed = 0; + for (process_lock_entry_t *entry = process_locks; entry; entry = entry->next) { + if (entry->directory_device == endpoint->dir_device && + entry->directory_inode == endpoint->dir_inode && + strcmp(entry->lock_name, lock_name) == 0) { + claimed = 1; + break; + } + } + (void)pthread_mutex_unlock(&process_locks_mutex); + return claimed; +} + +static uint64_t ipc_now_ms(void) { + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) { + return 0; + } + return (uint64_t)now.tv_sec * 1000U + (uint64_t)now.tv_nsec / 1000000U; +} + +static uint64_t ipc_deadline_after(uint32_t timeout_ms) { + if (timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER) { + return UINT64_MAX; + } + return ipc_now_ms() + (uint64_t)timeout_ms; +} + +static _Noreturn void ipc_coordination_cleanup_fail_stop( + const char *component) { + cbm_log_error("daemon.forced_shutdown", "component", component, + "action", "coordination_cleanup"); + (void)fflush(stdout); + (void)fflush(stderr); + _exit(EXIT_FAILURE); +} + +static void ipc_startup_lock_release_complete( + cbm_daemon_ipc_startup_lock_t **lock_io) { + uint64_t deadline = + ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); + while (lock_io && *lock_io) { + (void)cbm_daemon_ipc_startup_lock_release(lock_io); + if (!*lock_io) { + return; + } + if (ipc_now_ms() >= deadline) { + ipc_coordination_cleanup_fail_stop("startup_lock_cleanup"); + } + cbm_usleep(1000); + } +} + +static void ipc_participant_guard_release_complete( + cbm_daemon_ipc_participant_guard_t **guard_io) { + uint64_t deadline = + ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); + while (guard_io && *guard_io) { + (void)cbm_daemon_ipc_participant_guard_release(guard_io); + if (!*guard_io) { + return; + } + if (ipc_now_ms() >= deadline) { + ipc_coordination_cleanup_fail_stop("participant_guard_cleanup"); + } + cbm_usleep(1000); + } +} + +static int deadline_remaining_ms(uint64_t deadline_ms) { + uint64_t now_ms = ipc_now_ms(); + if (now_ms >= deadline_ms) { + return 0; + } + uint64_t remaining = deadline_ms - now_ms; + return remaining > (uint64_t)INT_MAX ? INT_MAX : (int)remaining; +} + +static bool ipc_retry_pause(uint64_t deadline_ms) { + int remaining_ms = deadline_remaining_ms(deadline_ms); + if (remaining_ms <= 0) { + return false; + } + int pause_ms = remaining_ms < CBM_DAEMON_IPC_RETRY_INTERVAL_MS + ? remaining_ms + : CBM_DAEMON_IPC_RETRY_INTERVAL_MS; + struct timespec pause = { + .tv_sec = 0, + .tv_nsec = (long)pause_ms * 1000000L, + }; + while (nanosleep(&pause, &pause) != 0 && errno == EINTR) {} + return true; +} + +static bool fd_set_cloexec(int fd) { + int flags = fcntl(fd, F_GETFD); + return flags >= 0 && fcntl(fd, F_SETFD, flags | FD_CLOEXEC) == 0; +} + +static bool fd_set_nonblocking(int fd) { + int flags = fcntl(fd, F_GETFL); + return flags >= 0 && fcntl(fd, F_SETFL, flags | O_NONBLOCK) == 0; +} + +static bool socket_disable_sigpipe(int fd) { +#ifdef SO_NOSIGPIPE + int enabled = 1; + return setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &enabled, sizeof(enabled)) == 0; +#else + (void)fd; + return true; +#endif +} + +static int local_socket_new(void) { + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + return -1; + } + if (!fd_set_cloexec(fd) || !fd_set_nonblocking(fd) || !socket_disable_sigpipe(fd)) { + (void)close(fd); + return -1; + } + return fd; +} + +/* Keep the only raw connect call at a sockaddr_un-typed boundary so the + * security audit can count and review the complete local IPC surface. */ +static int local_socket_connect(int fd, const struct sockaddr_un *address, + socklen_t address_length) { + return connect(fd, (const struct sockaddr *)address, address_length); +} + +static bool unix_address_set(struct sockaddr_un *address, const char *path, socklen_t *length_out) { + if (!address || !path || !length_out) { + return false; + } + size_t path_length = strlen(path); + if (path_length == 0 || path_length >= sizeof(address->sun_path)) { + return false; + } + memset(address, 0, sizeof(*address)); + address->sun_family = AF_UNIX; + memcpy(address->sun_path, path, path_length + 1); + socklen_t address_length = + (socklen_t)(offsetof(struct sockaddr_un, sun_path) + path_length + 1); +#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) + address->sun_len = (uint8_t)address_length; +#endif + *length_out = address_length; + return true; +} + +static bool runtime_parent_valid(const char *path) { + struct stat status; + return path && path[0] != '\0' && lstat(path, &status) == 0 && S_ISDIR(status.st_mode); +} + +static bool private_runtime_snapshot(int fd, const char *path, + bool require_empty_acl, + struct stat *status_out) { + struct stat by_handle; + struct stat by_path; + bool valid = fd >= 0 && path && path[0] != '\0' && + fstat(fd, &by_handle) == 0 && lstat(path, &by_path) == 0 && + S_ISDIR(by_handle.st_mode) && S_ISDIR(by_path.st_mode) && + by_handle.st_uid == geteuid() && by_path.st_uid == geteuid() && + (by_handle.st_mode & 07777) == 0700 && + (by_path.st_mode & 07777) == 0700 && + by_handle.st_dev == by_path.st_dev && + by_handle.st_ino == by_path.st_ino && + (!require_empty_acl || + cbm_macos_extended_acl_fd_is_empty(fd)); + if (valid && status_out) { + *status_out = by_handle; + } + return valid; +} + +static bool private_runtime_open(const char *path, int *fd_out, dev_t *device_out, + ino_t *inode_out) { + if (!path || !fd_out || !device_out || !inode_out) { + return false; + } + + bool created = false; + if (mkdir(path, 0700) == 0) { + created = true; + } else if (errno != EEXIST) { + return false; + } + + int fd = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0 || !fd_set_cloexec(fd)) { + if (fd >= 0) { + (void)close(fd); + } + return false; + } + if (created && fchmod(fd, 0700) != 0) { + (void)close(fd); + return false; + } + + struct stat before; + struct stat after; + bool secured = private_runtime_snapshot(fd, path, false, &before) && + cbm_macos_extended_acl_fd_clear(fd) && + private_runtime_snapshot(fd, path, true, &after) && + before.st_dev == after.st_dev && before.st_ino == after.st_ino; + if (!secured) { + (void)close(fd); + return false; + } + *fd_out = fd; + *device_out = after.st_dev; + *inode_out = after.st_ino; + return true; +} + +static bool endpoint_runtime_still_valid(const cbm_daemon_ipc_endpoint_t *endpoint) { + if (!endpoint || endpoint->dir_fd < 0) { + return false; + } + struct stat current; + return private_runtime_snapshot(endpoint->dir_fd, endpoint->runtime_dir, + true, ¤t) && + current.st_dev == endpoint->dir_device && + current.st_ino == endpoint->dir_inode; +} + +static bool private_regular_file_snapshot(int directory_fd, + const char *base_name, int fd, + nlink_t expected_links, + struct stat *status_out) { + struct stat by_handle; + struct stat by_path; + bool valid = directory_fd >= 0 && base_name && base_name[0] != '\0' && + fd >= 0 && fstat(fd, &by_handle) == 0 && + fstatat(directory_fd, base_name, &by_path, + AT_SYMLINK_NOFOLLOW) == 0 && + S_ISREG(by_handle.st_mode) && S_ISREG(by_path.st_mode) && + by_handle.st_uid == geteuid() && by_path.st_uid == geteuid() && + by_handle.st_nlink == expected_links && + by_path.st_nlink == expected_links && + (by_handle.st_mode & 07777) == 0600 && + (by_path.st_mode & 07777) == 0600 && + by_handle.st_dev == by_path.st_dev && + by_handle.st_ino == by_path.st_ino && + cbm_macos_extended_acl_fd_is_empty(directory_fd) && + cbm_macos_extended_acl_fd_is_empty(fd); + if (valid && status_out) { + *status_out = by_handle; + } + return valid; +} + +static bool private_regular_file_at_is_safe(int directory_fd, + const char *base_name, + nlink_t expected_links) { + int fd = openat(directory_fd, base_name, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + bool safe = fd >= 0 && fd_set_cloexec(fd) && + private_regular_file_snapshot(directory_fd, base_name, fd, + expected_links, NULL); + if (fd >= 0 && close(fd) != 0) { + safe = false; + } + return safe; +} + +static int poll_until(int fd, short events, uint64_t deadline_ms) { + for (;;) { + struct pollfd descriptor = {.fd = fd, .events = events, .revents = 0}; + int timeout_ms = deadline_ms == UINT64_MAX ? -1 : deadline_remaining_ms(deadline_ms); + int result = poll(&descriptor, 1, timeout_ms); + if (result > 0) { + if ((descriptor.revents & events) != 0) { + return 1; + } + return -1; + } + if (result == 0) { + return 0; + } + if (errno != EINTR) { + return -1; + } + } +} + +static bool socket_peer_is_current_user(int fd) { +#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) + uid_t peer_uid = (uid_t)-1; + gid_t peer_gid = (gid_t)-1; + return getpeereid(fd, &peer_uid, &peer_gid) == 0 && peer_uid == geteuid(); +#elif defined(__linux__) + struct ucred credentials; + socklen_t length = sizeof(credentials); + return getsockopt(fd, SOL_SOCKET, SO_PEERCRED, &credentials, &length) == 0 && + length == sizeof(credentials) && credentials.uid == geteuid(); +#else + (void)fd; + return false; +#endif +} + +static int connection_read_full(cbm_daemon_ipc_connection_t *connection, void *buffer, + size_t length, uint64_t deadline_ms) { + if (!connection || atomic_load_explicit(&connection->poisoned, memory_order_acquire)) { + return -1; + } + size_t offset = 0; + while (offset < length) { + int ready = poll_until(connection->fd, POLLIN, deadline_ms); + if (ready != 1) { + /* Framing is fail-stop across platforms. Even at offset zero a + * deadline may race transport progress (notably OVERLAPPED I/O on + * Windows), so callers must reconnect instead of reusing a stream + * whose next byte boundary is uncertain. */ + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return ready; + } + ssize_t received = recv(connection->fd, (uint8_t *)buffer + offset, length - offset, 0); + if (received > 0) { + offset += (size_t)received; + continue; + } + if (received == 0) { + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return -1; + } + if (errno != EINTR && errno != EAGAIN && errno != EWOULDBLOCK) { + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return -1; + } + } + return 1; +} + +static int connection_write_full(cbm_daemon_ipc_connection_t *connection, const void *buffer, + size_t length, uint64_t deadline_ms) { + if (!connection || atomic_load_explicit(&connection->poisoned, memory_order_acquire)) { + return -1; + } + size_t offset = 0; + while (offset < length) { + int ready = poll_until(connection->fd, POLLOUT, deadline_ms); + if (ready != 1) { + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return ready; + } +#ifdef MSG_NOSIGNAL + ssize_t sent = + send(connection->fd, (const uint8_t *)buffer + offset, length - offset, MSG_NOSIGNAL); +#else + ssize_t sent = send(connection->fd, (const uint8_t *)buffer + offset, length - offset, 0); +#endif + if (sent > 0) { + offset += (size_t)sent; + continue; + } + if (sent == 0) { + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return -1; + } + if (errno != EINTR && errno != EAGAIN && errno != EWOULDBLOCK) { + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return -1; + } + } + return 1; +} + +cbm_daemon_ipc_endpoint_t *cbm_daemon_ipc_endpoint_new(const char *instance_key, + const char *runtime_parent) { + if (!instance_key_valid(instance_key)) { + return NULL; + } +#ifdef __APPLE__ + const char *default_parent = "/private/tmp"; /* /tmp itself is a symlink on macOS. */ +#else + const char *default_parent = "/tmp"; +#endif + const char *requested_parent = runtime_parent ? runtime_parent : default_parent; + char canonical_parent[CBM_DAEMON_IPC_PATH_CAP]; + if (!cbm_canonical_path(requested_parent, canonical_parent, sizeof(canonical_parent))) { + return NULL; + } + const char *parent = canonical_parent; + if (!runtime_parent_valid(parent)) { + return NULL; + } + + cbm_daemon_ipc_endpoint_t *endpoint = calloc(1, sizeof(*endpoint)); + if (!endpoint) { + return NULL; + } + endpoint->dir_fd = -1; + endpoint->runtime_dir = + string_format("%s%s%s%lu", parent, parent[strlen(parent) - 1] == '/' ? "" : "/", + "cbm-daemon-", (unsigned long)geteuid()); + endpoint->socket_name = string_format("cbm-%s.sock", instance_key); + endpoint->socket_anchor_name = string_format("cbm-%s.anc", instance_key); + endpoint->socket_identity_name = + string_format("cbm-%s.sock.identity", instance_key); + endpoint->socket_pending_name = + string_format("cbm-%s.sock.pending", instance_key); + endpoint->lock_name = string_format("cbm-%s.lock", instance_key); + endpoint->startup_v2_lock_name = + string_format("cbm-%s.startup-v2.lock", instance_key); + endpoint->lifetime_lock_name = string_format("cbm-%s.lifetime.lock", instance_key); + if (!endpoint->runtime_dir || !endpoint->socket_name || + !endpoint->socket_anchor_name || !endpoint->socket_identity_name || + !endpoint->socket_pending_name || !endpoint->lock_name || + !endpoint->startup_v2_lock_name || !endpoint->lifetime_lock_name || + !private_runtime_open(endpoint->runtime_dir, &endpoint->dir_fd, &endpoint->dir_device, + &endpoint->dir_inode)) { + cbm_daemon_ipc_endpoint_free(endpoint); + return NULL; + } + endpoint->address = string_format("%s/%s", endpoint->runtime_dir, endpoint->socket_name); + endpoint->socket_anchor_address = + string_format("%s/%s", endpoint->runtime_dir, + endpoint->socket_anchor_name); + struct sockaddr_un address; + socklen_t address_length; + if (!endpoint->address || !endpoint->socket_anchor_address || + strlen(endpoint->socket_anchor_name) >= + POSIX_SOCKET_ANCHOR_NAME_CAP || + !unix_address_set(&address, endpoint->address, &address_length) || + !unix_address_set(&address, endpoint->socket_anchor_address, + &address_length)) { + cbm_daemon_ipc_endpoint_free(endpoint); + return NULL; + } + return endpoint; +} + +void cbm_daemon_ipc_endpoint_free(cbm_daemon_ipc_endpoint_t *endpoint) { + if (!endpoint) { + return; + } + if (endpoint->dir_fd >= 0) { + (void)close(endpoint->dir_fd); + } + free(endpoint->runtime_dir); + free(endpoint->address); + free(endpoint->socket_name); + free(endpoint->socket_anchor_address); + free(endpoint->socket_anchor_name); + free(endpoint->socket_identity_name); + free(endpoint->socket_pending_name); + free(endpoint->lock_name); + free(endpoint->startup_v2_lock_name); + free(endpoint->lifetime_lock_name); + free(endpoint); +} + +static cbm_daemon_ipc_endpoint_t *endpoint_snapshot_new( + const cbm_daemon_ipc_endpoint_t *endpoint) { + if (!endpoint_runtime_still_valid(endpoint)) { + return NULL; + } + cbm_daemon_ipc_endpoint_t *snapshot = calloc(1, sizeof(*snapshot)); + if (!snapshot) { + return NULL; + } + snapshot->dir_fd = -1; + snapshot->runtime_dir = string_copy(endpoint->runtime_dir); + snapshot->address = string_copy(endpoint->address); + snapshot->socket_name = string_copy(endpoint->socket_name); + snapshot->socket_anchor_address = + string_copy(endpoint->socket_anchor_address); + snapshot->socket_anchor_name = + string_copy(endpoint->socket_anchor_name); + snapshot->socket_identity_name = + string_copy(endpoint->socket_identity_name); + snapshot->socket_pending_name = + string_copy(endpoint->socket_pending_name); + snapshot->lock_name = string_copy(endpoint->lock_name); + snapshot->startup_v2_lock_name = + string_copy(endpoint->startup_v2_lock_name); + snapshot->lifetime_lock_name = + string_copy(endpoint->lifetime_lock_name); + snapshot->dir_fd = dup(endpoint->dir_fd); + snapshot->dir_device = endpoint->dir_device; + snapshot->dir_inode = endpoint->dir_inode; + if (!snapshot->runtime_dir || !snapshot->address || + !snapshot->socket_name || !snapshot->socket_anchor_address || + !snapshot->socket_anchor_name || !snapshot->socket_identity_name || + !snapshot->socket_pending_name || + !snapshot->lock_name || !snapshot->startup_v2_lock_name || + !snapshot->lifetime_lock_name || + snapshot->dir_fd < 0 || !fd_set_cloexec(snapshot->dir_fd) || + !endpoint_runtime_still_valid(snapshot)) { + cbm_daemon_ipc_endpoint_free(snapshot); + return NULL; + } + return snapshot; +} + +const char *cbm_daemon_ipc_endpoint_address(const cbm_daemon_ipc_endpoint_t *endpoint) { + return endpoint ? endpoint->address : NULL; +} + +const char *cbm_daemon_ipc_endpoint_runtime_dir(const cbm_daemon_ipc_endpoint_t *endpoint) { + return endpoint ? endpoint->runtime_dir : NULL; +} + +cbm_private_file_lock_status_t cbm_daemon_ipc_private_lock_directory_new( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_private_lock_directory_t **directory_out) { + if (directory_out) { + *directory_out = NULL; + } + if (!directory_out || !endpoint_runtime_still_valid(endpoint)) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } +#ifdef F_DUPFD_CLOEXEC + int duplicate = fcntl(endpoint->dir_fd, F_DUPFD_CLOEXEC, 0); +#else + int duplicate = fcntl(endpoint->dir_fd, F_DUPFD, 0); + if (duplicate >= 0 && !fd_set_cloexec(duplicate)) { + (void)close(duplicate); + duplicate = -1; + } +#endif + if (duplicate < 0) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_private_file_lock_status_t status = + cbm_private_lock_directory_adopt_posix(duplicate, endpoint->runtime_dir, + directory_out); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + (void)close(duplicate); + } + return status; +} + +static void posix_named_lock_release(int fd, process_lock_entry_t *process_entry) { + if (fd >= 0) { + (void)flock(fd, LOCK_UN); + (void)close(fd); + } + process_lock_unclaim(process_entry); +} + +static int posix_named_lock_try_acquire_mode( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, + bool shared, int *fd_out, process_lock_entry_t **process_entry_out) { + if (fd_out) { + *fd_out = -1; + } + if (process_entry_out) { + *process_entry_out = NULL; + } + if (!fd_out || !process_entry_out || !lock_name || + !endpoint_runtime_still_valid(endpoint)) { + return -1; + } + + process_lock_entry_t *process_entry = NULL; + int process_result = process_lock_claim_mode( + endpoint, lock_name, shared, &process_entry); + if (process_result != 1) { + return process_result == 0 && private_regular_file_at_is_safe( + endpoint->dir_fd, lock_name, 1) && + endpoint_runtime_still_valid(endpoint) + ? 0 + : -1; + } + int fd = openat(endpoint->dir_fd, lock_name, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, + 0600); + if (fd < 0 || !fd_set_cloexec(fd)) { + if (fd >= 0) { + (void)close(fd); + } + process_lock_unclaim(process_entry); + return -1; + } + if (fchmod(fd, 0600) != 0 || + !private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, + NULL) || + !endpoint_runtime_still_valid(endpoint)) { + (void)close(fd); + process_lock_unclaim(process_entry); + return -1; + } + if (flock(fd, (shared ? LOCK_SH : LOCK_EX) | LOCK_NB) != 0) { + int lock_error = errno; + bool still_private = private_regular_file_snapshot( + endpoint->dir_fd, lock_name, fd, 1, NULL) && + endpoint_runtime_still_valid(endpoint); + (void)close(fd); + process_lock_unclaim(process_entry); + return still_private && + (lock_error == EWOULDBLOCK || lock_error == EAGAIN) + ? 0 + : -1; + } + if (!private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, + NULL) || + !endpoint_runtime_still_valid(endpoint)) { + posix_named_lock_release(fd, process_entry); + return -1; + } + *fd_out = fd; + *process_entry_out = process_entry; + return 1; +} + +static int posix_named_lock_try_acquire( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, + int *fd_out, process_lock_entry_t **process_entry_out) { + return posix_named_lock_try_acquire_mode( + endpoint, lock_name, false, fd_out, process_entry_out); +} + +static int posix_named_shared_lock_try_acquire( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, + int *fd_out, process_lock_entry_t **process_entry_out) { + return posix_named_lock_try_acquire_mode( + endpoint, lock_name, true, fd_out, process_entry_out); +} + +static int posix_record_lock_set(int fd, short lock_type) { + struct flock record_lock = { + .l_type = lock_type, + .l_whence = SEEK_SET, + .l_start = 0, + .l_len = 0, + }; + int result; + do { + result = fcntl(fd, F_SETLK, &record_lock); + } while (result != 0 && errno == EINTR); + return result; +} + +static int posix_lifetime_lock_probe(const cbm_daemon_ipc_endpoint_t *endpoint, + const char *lock_name) { + if (!lock_name || !endpoint_runtime_still_valid(endpoint)) { + return -1; + } + int process_claimed = process_lock_is_claimed(endpoint, lock_name); + if (process_claimed < 0) { + return -1; + } + + int fd = openat(endpoint->dir_fd, lock_name, O_RDWR | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) { + return errno == ENOENT && process_claimed == 0 ? 0 : -1; + } + if (!fd_set_cloexec(fd) || + !private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, + NULL) || + !endpoint_runtime_still_valid(endpoint)) { + (void)close(fd); + return -1; + } + if (process_claimed == 1) { + bool still_private = endpoint_runtime_still_valid(endpoint); + return close(fd) == 0 && still_private ? 1 : -1; + } + struct flock record_lock = { + .l_type = F_WRLCK, + .l_whence = SEEK_SET, + .l_start = 0, + .l_len = 0, + }; + int query_result; + do { + query_result = fcntl(fd, F_GETLK, &record_lock); + } while (query_result != 0 && errno == EINTR); + bool still_private = + private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, + NULL) && + endpoint_runtime_still_valid(endpoint); + (void)close(fd); + if (query_result != 0 || !still_private) { + return -1; + } + if (record_lock.l_type == F_UNLCK) { + return 0; + } + return record_lock.l_type == F_RDLCK || record_lock.l_type == F_WRLCK ? 1 : -1; +} + +static int posix_lifetime_lock_try_acquire( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, int *fd_out, + process_lock_entry_t **process_entry_out) { + if (fd_out) { + *fd_out = -1; + } + if (process_entry_out) { + *process_entry_out = NULL; + } + if (!fd_out || !process_entry_out || !lock_name || + !endpoint_runtime_still_valid(endpoint)) { + return -1; + } + process_lock_entry_t *process_entry = NULL; + int process_result = process_lock_claim(endpoint, lock_name, &process_entry); + if (process_result != 1) { + return process_result == 0 && private_regular_file_at_is_safe( + endpoint->dir_fd, lock_name, 1) && + endpoint_runtime_still_valid(endpoint) + ? 0 + : -1; + } + int fd = openat(endpoint->dir_fd, lock_name, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, + 0600); + if (fd < 0 || !fd_set_cloexec(fd)) { + if (fd >= 0) { + (void)close(fd); + } + process_lock_unclaim(process_entry); + return -1; + } + if (fchmod(fd, 0600) != 0 || + !private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, + NULL) || + !endpoint_runtime_still_valid(endpoint)) { + (void)close(fd); + process_lock_unclaim(process_entry); + return -1; + } + if (posix_record_lock_set(fd, F_WRLCK) != 0) { + int lock_error = errno; + bool still_private = private_regular_file_snapshot( + endpoint->dir_fd, lock_name, fd, 1, NULL) && + endpoint_runtime_still_valid(endpoint); + (void)close(fd); + process_lock_unclaim(process_entry); + return still_private && + (lock_error == EACCES || lock_error == EAGAIN) + ? 0 + : -1; + } + if (!private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, + NULL) || + !endpoint_runtime_still_valid(endpoint)) { + (void)posix_record_lock_set(fd, F_UNLCK); + (void)close(fd); + process_lock_unclaim(process_entry); + return -1; + } + *fd_out = fd; + *process_entry_out = process_entry; + return 1; +} + +static void posix_lifetime_lock_release(int fd, process_lock_entry_t *process_entry) { + if (fd >= 0) { + (void)posix_record_lock_set(fd, F_UNLCK); + (void)close(fd); + } + process_lock_unclaim(process_entry); +} + +int cbm_daemon_ipc_lifetime_reservation_try_acquire( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_lifetime_reservation_t **reservation_out) { + if (reservation_out) { + *reservation_out = NULL; + } + if (!endpoint || !reservation_out) { + return -1; + } + int fd = -1; + process_lock_entry_t *process_entry = NULL; + int result = posix_lifetime_lock_try_acquire(endpoint, endpoint->lifetime_lock_name, &fd, + &process_entry); + if (result != 1) { + return result; + } + cbm_daemon_ipc_lifetime_reservation_t *reservation = malloc(sizeof(*reservation)); + if (!reservation) { + posix_lifetime_lock_release(fd, process_entry); + return -1; + } + reservation->fd = fd; + reservation->process_entry = process_entry; + reservation->owner_pid = getpid(); + *reservation_out = reservation; + return 1; +} + +void cbm_daemon_ipc_lifetime_reservation_release( + cbm_daemon_ipc_lifetime_reservation_t *reservation) { + if (!reservation) { + return; + } + posix_lifetime_lock_release(reservation->fd, reservation->process_entry); + free(reservation); +} + +static bool lifetime_reservation_matches_endpoint( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_lifetime_reservation_t *reservation) { + const process_lock_entry_t *entry = + reservation ? reservation->process_entry : NULL; + if (!endpoint_runtime_still_valid(endpoint) || !reservation || + reservation->fd < 0 || reservation->owner_pid != getpid() || + !entry || !endpoint->lifetime_lock_name || + entry->directory_device != endpoint->dir_device || + entry->directory_inode != endpoint->dir_inode || !entry->lock_name || + strcmp(entry->lock_name, endpoint->lifetime_lock_name) != 0) { + return false; + } + if (!private_regular_file_snapshot( + endpoint->dir_fd, endpoint->lifetime_lock_name, + reservation->fd, 1, NULL) || + !endpoint_runtime_still_valid(endpoint)) { + return false; + } + /* Reasserting the same process-owned record lock is a no-op for a valid + * reservation and fails if another process has replaced ownership. */ + return posix_record_lock_set(reservation->fd, F_WRLCK) == 0; +} + +int cbm_daemon_ipc_lifetime_reservation_probe( + const cbm_daemon_ipc_endpoint_t *endpoint) { + return endpoint ? posix_lifetime_lock_probe(endpoint, endpoint->lifetime_lock_name) : -1; +} + +static int posix_startup_lock_probe( + const cbm_daemon_ipc_endpoint_t *endpoint) { + if (!endpoint_runtime_still_valid(endpoint)) { + return -1; + } + int process_claimed = process_lock_is_claimed(endpoint, endpoint->lock_name); + if (process_claimed < 0) { + return -1; + } + int fd = openat(endpoint->dir_fd, endpoint->lock_name, + O_RDWR | O_CLOEXEC | O_NOFOLLOW); + if (fd < 0) { + return errno == ENOENT && process_claimed == 0 ? 0 : -1; + } + if (!fd_set_cloexec(fd) || + !private_regular_file_snapshot(endpoint->dir_fd, + endpoint->lock_name, fd, 1, NULL)) { + (void)close(fd); + return -1; + } + if (process_claimed == 1) { + bool still_private = endpoint_runtime_still_valid(endpoint); + return close(fd) == 0 && still_private ? 1 : -1; + } + int lock_result; + do { + lock_result = flock(fd, LOCK_SH | LOCK_NB); + } while (lock_result != 0 && errno == EINTR); + if (lock_result != 0) { + int lock_error = errno; + bool still_private = private_regular_file_snapshot( + endpoint->dir_fd, endpoint->lock_name, fd, 1, NULL) && + endpoint_runtime_still_valid(endpoint); + (void)close(fd); + return still_private && + (lock_error == EWOULDBLOCK || lock_error == EAGAIN) + ? 1 + : -1; + } + int unlock_result; + do { + unlock_result = flock(fd, LOCK_UN); + } while (unlock_result != 0 && errno == EINTR); + bool still_private = private_regular_file_snapshot( + endpoint->dir_fd, endpoint->lock_name, fd, 1, NULL) && + endpoint_runtime_still_valid(endpoint); + int close_result = close(fd); + return unlock_result == 0 && still_private && close_result == 0 ? 0 : -1; +} + +int cbm_daemon_ipc_legacy_generation_probe( + const cbm_daemon_ipc_endpoint_t *endpoint) { + if (!endpoint_runtime_still_valid(endpoint)) { + return -1; + } + struct stat status; + if (fstatat(endpoint->dir_fd, endpoint->socket_name, &status, + AT_SYMLINK_NOFOLLOW) == 0) { + return posix_socket_status_secure_transport(&status) + ? 1 + : -1; + } + if (errno != ENOENT) { + return -1; + } + return posix_startup_lock_probe(endpoint); +} + +static bool private_log_base_name_valid(const char *base_name) { + if (!base_name || !base_name[0] || strcmp(base_name, ".") == 0 || + strcmp(base_name, "..") == 0 || strchr(base_name, '/') || + strchr(base_name, '\\')) { + return false; + } + size_t length = strlen(base_name); + return length <= NAME_MAX - 2; +} + +static char *private_log_directory_path_copy(const char *directory_path) { +#ifdef __APPLE__ + /* Darwin exposes the trusted top-level aliases /tmp -> /private/tmp and + * /var -> /private/var. Resolve only those root-owned aliases before the + * component-wise O_NOFOLLOW walk. Canonicalizing the complete caller path + * would follow an attacker-controlled cache/log symlink and is forbidden. */ + static const char *const aliases[] = {"/tmp", "/var"}; + for (size_t index = 0; index < sizeof(aliases) / sizeof(aliases[0]); index++) { + const char *alias = aliases[index]; + size_t alias_length = strlen(alias); + if (strncmp(directory_path, alias, alias_length) != 0 || + (directory_path[alias_length] != '\0' && directory_path[alias_length] != '/')) { + continue; + } + struct stat alias_status; + if (lstat(alias, &alias_status) != 0 || !S_ISLNK(alias_status.st_mode)) { + break; + } + struct stat root_status; + char resolved[CBM_DAEMON_IPC_PATH_CAP]; + if (alias_status.st_uid != 0 || lstat("/", &root_status) != 0 || + !S_ISDIR(root_status.st_mode) || root_status.st_uid != 0 || + (root_status.st_mode & 0022) != 0 || !realpath(alias, resolved)) { + return NULL; + } + struct stat resolved_status; + if (lstat(resolved, &resolved_status) != 0 || + !S_ISDIR(resolved_status.st_mode) || resolved_status.st_uid != 0) { + return NULL; + } + return string_format("%s%s", resolved, directory_path + alias_length); + } +#endif + return string_copy(directory_path); +} + +static int private_log_directory_open(const char *directory_path) { + if (!directory_path || !directory_path[0] || O_DIRECTORY == 0 || + O_NOFOLLOW == 0) { + return -1; + } + char *path = private_log_directory_path_copy(directory_path); + if (!path) { + return -1; + } + bool absolute = path[0] == '/'; + int current_fd = open(absolute ? "/" : ".", + O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + bool ok = current_fd >= 0 && fd_set_cloexec(current_fd); + char *cursor = path; + while (ok && *cursor == '/') { + cursor++; + } + bool visited = false; + while (ok && *cursor) { + char *component = cursor; + while (*cursor && *cursor != '/') { + cursor++; + } + char saved = *cursor; + *cursor = '\0'; + if (strcmp(component, ".") == 0) { + /* Relative paths may contain a harmless explicit current-dir + * component. Parent traversal is never valid for private logs. */ + } else if (strcmp(component, "..") == 0 || !component[0]) { + ok = false; + } else { + bool created = mkdirat(current_fd, component, 0700) == 0; + if (!created && errno != EEXIST) { + ok = false; + } + int next_fd = ok ? openat(current_fd, component, + O_RDONLY | O_DIRECTORY | O_CLOEXEC | + O_NOFOLLOW) + : -1; + struct stat status; + ok = next_fd >= 0 && fd_set_cloexec(next_fd) && + fstat(next_fd, &status) == 0 && S_ISDIR(status.st_mode); + if (ok && created) { + ok = status.st_uid == geteuid() && fchmod(next_fd, 0700) == 0; + } + if (ok) { + (void)close(current_fd); + current_fd = next_fd; + visited = true; + } else if (next_fd >= 0) { + (void)close(next_fd); + } + } + *cursor = saved; + while (*cursor == '/') { + cursor++; + } + } + struct stat final_status; + ok = ok && visited && fstat(current_fd, &final_status) == 0 && + S_ISDIR(final_status.st_mode) && final_status.st_uid == geteuid() && + fchmod(current_fd, 0700) == 0 && + fstat(current_fd, &final_status) == 0 && + (final_status.st_mode & 07777) == 0700 && + cbm_macos_extended_acl_fd_is_empty(current_fd); + free(path); + if (!ok) { + if (current_fd >= 0) { + (void)close(current_fd); + } + return -1; + } + return current_fd; +} + +static int private_log_file_open(int directory_fd, const char *base_name, + struct stat *status_out) { + int flags = O_WRONLY | O_APPEND | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK; + int fd = openat(directory_fd, base_name, + flags | O_CREAT | O_EXCL, 0600); + if (fd < 0 && errno == EEXIST) { + fd = openat(directory_fd, base_name, flags); + } + struct stat status; + if (fd < 0 || !fd_set_cloexec(fd) || fchmod(fd, 0600) != 0 || + !private_regular_file_snapshot(directory_fd, base_name, fd, 1, + &status) || + status.st_size < 0) { + if (fd >= 0) { + (void)close(fd); + } + return -1; + } + *status_out = status; + return fd; +} + +FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, + const char *base_name, + size_t rotate_cap_bytes) { + if (!private_log_base_name_valid(base_name) || rotate_cap_bytes == 0) { + return NULL; + } + int directory_fd = private_log_directory_open(directory_path); + if (directory_fd < 0) { + return NULL; + } + struct stat status; + int fd = private_log_file_open(directory_fd, base_name, &status); + bool ok = fd >= 0; + if (ok && (uintmax_t)status.st_size > (uintmax_t)rotate_cap_bytes) { + char rotated[NAME_MAX + 3]; + int written = snprintf(rotated, sizeof(rotated), "%s.1", base_name); + struct stat destination; + bool destination_ok = false; + if (written > 0 && written < (int)sizeof(rotated)) { + if (fstatat(directory_fd, rotated, &destination, + AT_SYMLINK_NOFOLLOW) == 0) { + destination_ok = S_ISREG(destination.st_mode) && + destination.st_uid == geteuid() && + destination.st_nlink == 1; + } else { + destination_ok = errno == ENOENT; + } + } + struct stat current; + bool current_ok = + fstatat(directory_fd, base_name, ¤t, + AT_SYMLINK_NOFOLLOW) == 0 && + S_ISREG(current.st_mode) && current.st_uid == geteuid() && + current.st_nlink == 1 && current.st_dev == status.st_dev && + current.st_ino == status.st_ino; + ok = destination_ok && current_ok && close(fd) == 0; + fd = -1; + if (ok) { + ok = renameat(directory_fd, base_name, directory_fd, rotated) == 0; + } + if (ok) { + fd = private_log_file_open(directory_fd, base_name, &status); + ok = fd >= 0; + } + } + FILE *stream = NULL; + if (ok) { + stream = fdopen(fd, "ab"); + if (stream) { + fd = -1; + } + } + if (fd >= 0) { + (void)close(fd); + } + (void)fsync(directory_fd); + (void)close(directory_fd); + return stream; +} + +static void posix_u32_encode(uint8_t out[4], uint32_t value) { + out[0] = (uint8_t)(value & 0xffU); + out[1] = (uint8_t)((value >> 8U) & 0xffU); + out[2] = (uint8_t)((value >> 16U) & 0xffU); + out[3] = (uint8_t)((value >> 24U) & 0xffU); +} + +static uint32_t posix_u32_decode(const uint8_t in[4]) { + return (uint32_t)in[0] | ((uint32_t)in[1] << 8U) | + ((uint32_t)in[2] << 16U) | ((uint32_t)in[3] << 24U); +} + +static void posix_u64_encode(uint8_t out[8], uint64_t value) { + for (size_t index = 0; index < 8; index++) { + out[index] = (uint8_t)(value >> (index * 8U)); + } +} + +static uint64_t posix_u64_decode(const uint8_t in[8]) { + uint64_t value = 0; + for (size_t index = 0; index < 8; index++) { + value |= (uint64_t)in[index] << (index * 8U); + } + return value; +} + +static bool posix_stat_ctime(const struct stat *status, int64_t *seconds_out, + uint32_t *nanoseconds_out) { + if (!status || !seconds_out || !nanoseconds_out) { + return false; + } +#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || \ + defined(__NetBSD__) + time_t raw_seconds = status->st_ctimespec.tv_sec; + long raw_nanoseconds = status->st_ctimespec.tv_nsec; +#else + time_t raw_seconds = status->st_ctim.tv_sec; + long raw_nanoseconds = status->st_ctim.tv_nsec; +#endif + int64_t seconds = (int64_t)raw_seconds; + if ((time_t)seconds != raw_seconds || seconds < 0 || + raw_nanoseconds < 0 || raw_nanoseconds >= 1000000000L) { + return false; + } + *seconds_out = seconds; + *nanoseconds_out = (uint32_t)raw_nanoseconds; + return true; +} + +static bool posix_socket_identity_from_stat( + const struct stat *status, posix_socket_identity_t *identity_out) { + if (!status || !identity_out || !S_ISSOCK(status->st_mode)) { + return false; + } + uint64_t device = (uint64_t)status->st_dev; + uint64_t inode = (uint64_t)status->st_ino; + int64_t ctime_seconds = 0; + uint32_t ctime_nanoseconds = 0; + if ((dev_t)device != status->st_dev || (ino_t)inode != status->st_ino || + !posix_stat_ctime(status, &ctime_seconds, &ctime_nanoseconds)) { + return false; + } + identity_out->device = status->st_dev; + identity_out->inode = status->st_ino; + identity_out->ctime_seconds = ctime_seconds; + identity_out->ctime_nanoseconds = ctime_nanoseconds; + return true; +} + +static bool posix_socket_identity_equal( + const posix_socket_identity_t *left, + const posix_socket_identity_t *right) { + return left && right && left->device == right->device && + left->inode == right->inode && + left->ctime_seconds == right->ctime_seconds && + left->ctime_nanoseconds == right->ctime_nanoseconds; +} + +static bool posix_socket_inode_equal( + const posix_socket_identity_t *left, + const posix_socket_identity_t *right) { + return left && right && left->device == right->device && + left->inode == right->inode; +} + +static bool posix_socket_status_secure_links(const struct stat *status, + nlink_t links) { + return status && S_ISSOCK(status->st_mode) && + status->st_uid == geteuid() && status->st_nlink == links && + (status->st_mode & 0777) == 0600; +} + +static bool posix_socket_status_secure_transport( + const struct stat *status) { + return status && S_ISSOCK(status->st_mode) && + status->st_uid == geteuid() && + (status->st_nlink == 1 || status->st_nlink == 2) && + (status->st_mode & 0777) == 0600; +} + +static bool posix_socket_record_encode( + const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], + const posix_socket_record_t *source, + uint8_t record[POSIX_SOCKET_RECORD_SIZE]) { + if (!magic || !source || !record || source->identity.ctime_seconds < 0 || + source->identity.ctime_nanoseconds >= 1000000000U) { + return false; + } + size_t anchor_length = strnlen(source->anchor_name, + POSIX_SOCKET_ANCHOR_NAME_CAP); + uint64_t device = (uint64_t)source->identity.device; + uint64_t inode = (uint64_t)source->identity.inode; + if ((dev_t)device != source->identity.device || + (ino_t)inode != source->identity.inode || anchor_length == 0 || + anchor_length >= POSIX_SOCKET_ANCHOR_NAME_CAP || + strchr(source->anchor_name, '/')) { + return false; + } + memset(record, 0, POSIX_SOCKET_RECORD_SIZE); + memcpy(record, magic, POSIX_SOCKET_RECORD_MAGIC_SIZE); + posix_u64_encode(record + POSIX_SOCKET_IDENTITY_DEVICE_OFFSET, device); + posix_u64_encode(record + POSIX_SOCKET_IDENTITY_INODE_OFFSET, inode); + posix_u64_encode(record + POSIX_SOCKET_IDENTITY_CTIME_SECONDS_OFFSET, + (uint64_t)source->identity.ctime_seconds); + posix_u32_encode( + record + POSIX_SOCKET_IDENTITY_CTIME_NANOSECONDS_OFFSET, + source->identity.ctime_nanoseconds); + memcpy(record + POSIX_SOCKET_ANCHOR_NAME_OFFSET, source->anchor_name, + anchor_length + 1U); + return true; +} + +static bool posix_socket_record_decode( + const uint8_t record[POSIX_SOCKET_RECORD_SIZE], + const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], + posix_socket_record_t *record_out) { + if (!record || !magic || !record_out || + memcmp(record, magic, POSIX_SOCKET_RECORD_MAGIC_SIZE) != 0 || + posix_u32_decode(record + POSIX_SOCKET_IDENTITY_RESERVED_OFFSET) != 0) { + return false; + } + uint64_t device = + posix_u64_decode(record + POSIX_SOCKET_IDENTITY_DEVICE_OFFSET); + uint64_t inode = + posix_u64_decode(record + POSIX_SOCKET_IDENTITY_INODE_OFFSET); + uint64_t seconds = posix_u64_decode( + record + POSIX_SOCKET_IDENTITY_CTIME_SECONDS_OFFSET); + uint32_t nanoseconds = posix_u32_decode( + record + POSIX_SOCKET_IDENTITY_CTIME_NANOSECONDS_OFFSET); + if ((dev_t)device != device || (ino_t)inode != inode || + seconds > (uint64_t)INT64_MAX || nanoseconds >= 1000000000U) { + return false; + } + const uint8_t *anchor = record + POSIX_SOCKET_ANCHOR_NAME_OFFSET; + const uint8_t *terminator = + memchr(anchor, 0, POSIX_SOCKET_ANCHOR_NAME_CAP); + if (!terminator || terminator == anchor || + memchr(anchor, '/', (size_t)(terminator - anchor))) { + return false; + } + size_t anchor_length = (size_t)(terminator - anchor); + for (size_t index = anchor_length + 1U; + index < POSIX_SOCKET_ANCHOR_NAME_CAP; index++) { + if (anchor[index] != 0) { + return false; + } + } + memset(record_out, 0, sizeof(*record_out)); + record_out->identity.device = (dev_t)device; + record_out->identity.inode = (ino_t)inode; + record_out->identity.ctime_seconds = (int64_t)seconds; + record_out->identity.ctime_nanoseconds = nanoseconds; + memcpy(record_out->anchor_name, anchor, anchor_length + 1U); + return true; +} + +static bool posix_fd_write_all(int fd, const uint8_t *buffer, + size_t length) { + size_t offset = 0; + while (offset < length) { + ssize_t written = write(fd, buffer + offset, length - offset); + if (written > 0) { + offset += (size_t)written; + } else if (written < 0 && errno == EINTR) { + continue; + } else { + return false; + } + } + return true; +} + +static bool posix_fd_pread_all(int fd, uint8_t *buffer, size_t length) { + size_t offset = 0; + while (offset < length) { + ssize_t count = pread(fd, buffer + offset, length - offset, + (off_t)offset); + if (count > 0) { + offset += (size_t)count; + } else if (count < 0 && errno == EINTR) { + continue; + } else { + return false; + } + } + return true; +} + +typedef enum { + POSIX_RECORD_UNSAFE = -1, + POSIX_RECORD_ABSENT = 0, + POSIX_RECORD_VALID = 1, + POSIX_RECORD_UNKNOWN = 2, +} posix_record_state_t; + +static posix_record_state_t posix_socket_record_read_links( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *record_name, + const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], + nlink_t expected_links, posix_socket_record_t *record_out, + struct stat *record_status_out) { + if (!endpoint || !record_name || !magic || !record_out || + !record_status_out || !endpoint->socket_anchor_name || + (expected_links != 1 && expected_links != 2) || + !endpoint_runtime_still_valid(endpoint)) { + return POSIX_RECORD_UNSAFE; + } + int fd = openat(endpoint->dir_fd, record_name, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + if (fd < 0) { + return errno == ENOENT ? POSIX_RECORD_ABSENT + : POSIX_RECORD_UNSAFE; + } + struct stat before; + struct stat after; + struct stat by_path; + int64_t before_ctime_seconds = 0; + uint32_t before_ctime_nanoseconds = 0; + int64_t after_ctime_seconds = 0; + uint32_t after_ctime_nanoseconds = 0; + bool metadata_safe = + fd_set_cloexec(fd) && fstat(fd, &before) == 0 && + S_ISREG(before.st_mode) && before.st_uid == geteuid() && + before.st_nlink == expected_links && + (before.st_mode & 07777) == 0600 && + cbm_macos_extended_acl_fd_is_empty(fd) && + posix_stat_ctime(&before, &before_ctime_seconds, + &before_ctime_nanoseconds); + if (!metadata_safe) { + (void)close(fd); + return POSIX_RECORD_UNSAFE; + } + if (before.st_size != (off_t)POSIX_SOCKET_RECORD_SIZE) { + bool still_private = private_regular_file_snapshot( + endpoint->dir_fd, record_name, fd, + expected_links, NULL) && + endpoint_runtime_still_valid(endpoint); + int close_result = close(fd); + return still_private && close_result == 0 ? POSIX_RECORD_UNKNOWN + : POSIX_RECORD_UNSAFE; + } + uint8_t record[POSIX_SOCKET_RECORD_SIZE]; + bool read_ok = posix_fd_pread_all(fd, record, sizeof(record)); + bool stable = + read_ok && fstat(fd, &after) == 0 && + posix_stat_ctime(&after, &after_ctime_seconds, + &after_ctime_nanoseconds) && + fstatat(endpoint->dir_fd, record_name, &by_path, + AT_SYMLINK_NOFOLLOW) == 0 && + S_ISREG(after.st_mode) && after.st_uid == geteuid() && + after.st_nlink == expected_links && + (after.st_mode & 07777) == 0600 && + cbm_macos_extended_acl_fd_is_empty(fd) && + after.st_size == before.st_size && after.st_dev == before.st_dev && + after.st_ino == before.st_ino && + after_ctime_seconds == before_ctime_seconds && + after_ctime_nanoseconds == before_ctime_nanoseconds && + S_ISREG(by_path.st_mode) && by_path.st_uid == geteuid() && + by_path.st_nlink == expected_links && + (by_path.st_mode & 07777) == 0600 && + by_path.st_dev == before.st_dev && by_path.st_ino == before.st_ino && + endpoint_runtime_still_valid(endpoint); + int close_result = close(fd); + if (!stable || close_result != 0) { + return POSIX_RECORD_UNSAFE; + } + if (!posix_socket_record_decode(record, magic, record_out) || + strcmp(record_out->anchor_name, + endpoint->socket_anchor_name) != 0) { + return POSIX_RECORD_UNKNOWN; + } + *record_status_out = before; + return POSIX_RECORD_VALID; +} + +static posix_record_state_t posix_socket_record_read( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *record_name, + const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], + posix_socket_record_t *record_out, struct stat *record_status_out) { + return posix_socket_record_read_links( + endpoint, record_name, magic, 1, record_out, record_status_out); +} + +static int posix_socket_path_identity_read( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *socket_name, + posix_socket_identity_t *identity_out, struct stat *status_out) { + struct stat status; + if (!endpoint || !socket_name || !identity_out) { + return -1; + } + if (fstatat(endpoint->dir_fd, socket_name, &status, + AT_SYMLINK_NOFOLLOW) != 0) { + return errno == ENOENT ? 0 : -1; + } + if (!posix_socket_status_secure_transport(&status) || + !posix_socket_identity_from_stat(&status, identity_out)) { + return -1; + } + if (status_out) { + *status_out = status; + } + return 1; +} + +static bool posix_path_unlink_regular_if_matches( + int dir_fd, const char *name, dev_t device, ino_t inode, + nlink_t links) { + if (dir_fd < 0 || !name) { + return false; + } + int fd = openat(dir_fd, name, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + struct stat status; + bool matches = fd >= 0 && fd_set_cloexec(fd) && + private_regular_file_snapshot(dir_fd, name, fd, links, + &status) && + status.st_dev == device && status.st_ino == inode; + if (fd >= 0 && close(fd) != 0) { + matches = false; + } + return matches && unlinkat(dir_fd, name, 0) == 0; +} + +static bool posix_socket_path_unlink_inode_if_matches( + int dir_fd, const char *socket_name, + const posix_socket_identity_t *identity, nlink_t links) { + struct stat status; + posix_socket_identity_t observed; + return dir_fd >= 0 && socket_name && identity && + fstatat(dir_fd, socket_name, &status, + AT_SYMLINK_NOFOLLOW) == 0 && + posix_socket_status_secure_links(&status, links) && + posix_socket_identity_from_stat(&status, &observed) && + posix_socket_inode_equal(&observed, identity) && + unlinkat(dir_fd, socket_name, 0) == 0; +} + +static bool posix_directory_sync(int dir_fd) { + if (dir_fd < 0) { + return false; + } + int result; + do { + result = fsync(dir_fd); + } while (result != 0 && errno == EINTR); + if (result == 0) { + return true; + } + + int sync_error = errno; + bool unsupported = sync_error == EINVAL; +#ifdef ENOTSUP + unsupported = unsupported || sync_error == ENOTSUP; +#endif +#ifdef EOPNOTSUPP + unsupported = unsupported || sync_error == EOPNOTSUPP; +#endif + /* Some POSIX filesystems, including Darwin filesystems, do not expose a + * directory-sync operation. Record contents are still fsynced before + * publication, and the recovery state machine accepts either atomic + * namespace outcome after a crash. */ + return unsupported; +} + +static bool posix_socket_record_temp_name( + const char *record_name, char temp_name[NAME_MAX + 1]) { + if (!record_name || !temp_name) { + return false; + } + int written = snprintf(temp_name, NAME_MAX + 1, "%s.tmp", record_name); + return written > 0 && written <= NAME_MAX; +} + +static int posix_record_artifact_status( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *name, + struct stat *status_out) { + if (!endpoint || !name || !status_out || + !endpoint_runtime_still_valid(endpoint)) { + return -1; + } + struct stat status; + if (fstatat(endpoint->dir_fd, name, &status, + AT_SYMLINK_NOFOLLOW) != 0) { + return errno == ENOENT ? 0 : -1; + } + if (!S_ISREG(status.st_mode) || status.st_uid != geteuid() || + (status.st_mode & 07777) != 0600 || + (status.st_nlink != 1 && status.st_nlink != 2)) { + return -1; + } + int fd = openat(endpoint->dir_fd, name, + O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + struct stat by_handle; + bool safe = fd >= 0 && fd_set_cloexec(fd) && + private_regular_file_snapshot( + endpoint->dir_fd, name, fd, status.st_nlink, + &by_handle) && + by_handle.st_dev == status.st_dev && + by_handle.st_ino == status.st_ino; + if (fd >= 0 && close(fd) != 0) { + safe = false; + } + if (!safe) { + return -1; + } + *status_out = status; + return 1; +} + +static int posix_socket_record_identity_corroborated( + const cbm_daemon_ipc_endpoint_t *endpoint, + const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], + const posix_socket_record_t *record) { + if (!endpoint || !magic || !record) { + return -1; + } + posix_socket_identity_t stable = {0}; + posix_socket_identity_t anchor = {0}; + struct stat stable_status = {0}; + struct stat anchor_status = {0}; + int stable_state = posix_socket_path_identity_read( + endpoint, endpoint->socket_name, &stable, &stable_status); + int anchor_state = posix_socket_path_identity_read( + endpoint, endpoint->socket_anchor_name, &anchor, &anchor_status); + if (stable_state < 0 || anchor_state < 0) { + return -1; + } + + if (memcmp(magic, POSIX_SOCKET_PENDING_MAGIC, + POSIX_SOCKET_RECORD_MAGIC_SIZE) == 0) { + return stable_state == 0 && anchor_state == 1 && + anchor_status.st_nlink == 1 && + posix_socket_identity_equal(&record->identity, + &anchor) + ? 1 + : 0; + } + if (memcmp(magic, POSIX_SOCKET_MARKER_MAGIC, + POSIX_SOCKET_RECORD_MAGIC_SIZE) == 0) { + return stable_state == 1 && anchor_state == 1 && + stable_status.st_nlink == 2 && + anchor_status.st_nlink == 2 && + posix_socket_identity_equal(&record->identity, + &stable) && + posix_socket_identity_equal(&record->identity, + &anchor) + ? 1 + : 0; + } + return -1; +} + +/* Normalize only publication artifacts whose deterministic temp name, + * owner/mode/link shape, encoded record, and socket phase all agree. A + * mismatched artifact is preserved and blocks startup rather than becoming + * deletion authority. */ +static int posix_socket_record_publication_recover( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *record_name, + const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE]) { + char temp_name[NAME_MAX + 1]; + if (!posix_socket_record_temp_name(record_name, temp_name)) { + return -1; + } + struct stat record_status = {0}; + struct stat temp_status = {0}; + int record_state = posix_record_artifact_status( + endpoint, record_name, &record_status); + int temp_state = posix_record_artifact_status( + endpoint, temp_name, &temp_status); + if (record_state < 0 || temp_state < 0) { + return -1; + } + if (temp_state == 0) { + return record_state == 0 || record_status.st_nlink == 1 ? 1 : 0; + } + + posix_socket_record_t recovered = {0}; + struct stat recovered_status = {0}; + if (record_state == 0) { + if (temp_status.st_nlink != 1) { + return 0; + } + posix_record_state_t read_state = posix_socket_record_read_links( + endpoint, temp_name, magic, 1, &recovered, + &recovered_status); + if (read_state != POSIX_RECORD_VALID) { + return read_state == POSIX_RECORD_UNSAFE ? -1 : 0; + } + } else { + if (record_status.st_nlink != 2 || temp_status.st_nlink != 2 || + record_status.st_dev != temp_status.st_dev || + record_status.st_ino != temp_status.st_ino) { + return 0; + } + posix_record_state_t read_state = posix_socket_record_read_links( + endpoint, record_name, magic, 2, &recovered, + &recovered_status); + if (read_state != POSIX_RECORD_VALID) { + return read_state == POSIX_RECORD_UNSAFE ? -1 : 0; + } + if (recovered_status.st_dev != temp_status.st_dev || + recovered_status.st_ino != temp_status.st_ino) { + return 0; + } + } + + int corroborated = posix_socket_record_identity_corroborated( + endpoint, magic, &recovered); + if (corroborated != 1) { + return corroborated; + } + if (!posix_path_unlink_regular_if_matches( + endpoint->dir_fd, temp_name, recovered_status.st_dev, + recovered_status.st_ino, record_state == 1 ? 2 : 1) || + !posix_directory_sync(endpoint->dir_fd)) { + return -1; + } + return 1; +} + +static int posix_linkat_no_follow(int source_dir_fd, + const char *source_name, + int destination_dir_fd, + const char *destination_name) { + int flags = 0; +#if defined(__APPLE__) && defined(AT_SYMLINK_NOFOLLOW_ANY) + /* Darwin documents that linkat(..., 0) may be rejected by some + * filesystems. This explicit flag preserves the intended no-follow + * semantics while making the operation portable to those filesystems. */ + flags = AT_SYMLINK_NOFOLLOW_ANY; +#endif + return linkat(source_dir_fd, source_name, destination_dir_fd, + destination_name, flags); +} + +static void posix_bound_socket_unlink_if_matches( + int dir_fd, const char *socket_name, dev_t device, ino_t inode) { + struct stat status; + if (dir_fd >= 0 && socket_name && + fstatat(dir_fd, socket_name, &status, AT_SYMLINK_NOFOLLOW) == 0 && + S_ISSOCK(status.st_mode) && status.st_uid == geteuid() && + status.st_dev == device && status.st_ino == inode) { + (void)unlinkat(dir_fd, socket_name, 0); + } +} + +static bool posix_socket_record_publish( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *record_name, + const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], + const posix_socket_record_t *source, dev_t *device_out, + ino_t *inode_out) { + if (!endpoint || !record_name || !magic || !source || !device_out || + !inode_out || !endpoint_runtime_still_valid(endpoint)) { + return false; + } + struct stat existing; + if (fstatat(endpoint->dir_fd, record_name, &existing, + AT_SYMLINK_NOFOLLOW) == 0 || errno != ENOENT) { + return false; + } + uint8_t record[POSIX_SOCKET_RECORD_SIZE]; + if (!posix_socket_record_encode(magic, source, record)) { + return false; + } + + char temp_name[NAME_MAX + 1]; + if (!posix_socket_record_temp_name(record_name, temp_name)) { + return false; + } + int fd = openat(endpoint->dir_fd, temp_name, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW | + O_NONBLOCK, + 0600); + if (fd < 0) { + return false; + } + + struct stat created = {0}; + bool temp_exists = true; + bool stable_linked = false; + bool ok = fd_set_cloexec(fd) && fchmod(fd, 0600) == 0 && + private_regular_file_snapshot(endpoint->dir_fd, temp_name, fd, + 1, &created) && + posix_fd_write_all(fd, record, sizeof(record)) && + fsync(fd) == 0 && + private_regular_file_snapshot(endpoint->dir_fd, temp_name, fd, + 1, &created) && + created.st_size == (off_t)POSIX_SOCKET_RECORD_SIZE && + endpoint_runtime_still_valid(endpoint); + if (ok) { + posix_record_publication_stage_reached(magic, false); + ok = posix_linkat_no_follow( + endpoint->dir_fd, temp_name, endpoint->dir_fd, + record_name) == 0; + stable_linked = ok; + } + if (ok) { + posix_record_publication_stage_reached(magic, true); + ok = posix_path_unlink_regular_if_matches( + endpoint->dir_fd, temp_name, created.st_dev, created.st_ino, 2); + temp_exists = !ok; + } + if (ok) { + ok = posix_directory_sync(endpoint->dir_fd); + } + if (close(fd) != 0) { + ok = false; + } + + posix_socket_record_t published_record; + struct stat published_status; + if (ok) { + ok = posix_socket_record_read(endpoint, record_name, magic, + &published_record, + &published_status) == + POSIX_RECORD_VALID && + posix_socket_identity_equal(&published_record.identity, + &source->identity) && + strcmp(published_record.anchor_name, + source->anchor_name) == 0 && + published_status.st_dev == created.st_dev && + published_status.st_ino == created.st_ino; + } + if (!ok) { + if (stable_linked) { + (void)posix_path_unlink_regular_if_matches( + endpoint->dir_fd, record_name, + created.st_dev, created.st_ino, temp_exists ? 2 : 1); + } + if (temp_exists) { + (void)posix_path_unlink_regular_if_matches( + endpoint->dir_fd, temp_name, created.st_dev, + created.st_ino, 1); + } + (void)posix_directory_sync(endpoint->dir_fd); + return false; + } + *device_out = published_status.st_dev; + *inode_out = published_status.st_ino; + return true; +} + +static bool posix_endpoint_namespace_equal( + const cbm_daemon_ipc_endpoint_t *left, + const cbm_daemon_ipc_endpoint_t *right) { + return left && right && left->dir_device == right->dir_device && + left->dir_inode == right->dir_inode && left->socket_name && + right->socket_name && + strcmp(left->socket_name, right->socket_name) == 0 && + left->socket_anchor_name && right->socket_anchor_name && + strcmp(left->socket_anchor_name, + right->socket_anchor_name) == 0 && + left->socket_anchor_address && right->socket_anchor_address && + strcmp(left->socket_anchor_address, + right->socket_anchor_address) == 0 && + left->socket_identity_name && right->socket_identity_name && + strcmp(left->socket_identity_name, + right->socket_identity_name) == 0 && + left->socket_pending_name && right->socket_pending_name && + strcmp(left->socket_pending_name, + right->socket_pending_name) == 0 && + left->lock_name && right->lock_name && + strcmp(left->lock_name, right->lock_name) == 0 && + left->startup_v2_lock_name && right->startup_v2_lock_name && + strcmp(left->startup_v2_lock_name, + right->startup_v2_lock_name) == 0 && + left->lifetime_lock_name && right->lifetime_lock_name && + strcmp(left->lifetime_lock_name, + right->lifetime_lock_name) == 0; +} + +static bool posix_named_lock_is_retained( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, + bool shared, int fd, const process_lock_entry_t *entry, + pid_t owner_pid) { + if (!endpoint_runtime_still_valid(endpoint) || !lock_name || fd < 0 || + !entry || entry->shared != shared || owner_pid != getpid()) { + return false; + } + if (entry->directory_device != endpoint->dir_device || + entry->directory_inode != endpoint->dir_inode || !entry->lock_name || + strcmp(entry->lock_name, lock_name) != 0 || + process_lock_is_claimed(endpoint, lock_name) != 1) { + return false; + } + + if (!private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, + NULL)) { + return false; + } + int lock_result; + do { + lock_result = flock(fd, (shared ? LOCK_SH : LOCK_EX) | LOCK_NB); + } while (lock_result != 0 && errno == EINTR); + return lock_result == 0; +} + +static bool posix_startup_lock_matches_endpoint( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_startup_lock_t *startup_lock) { + return startup_lock && + endpoint_runtime_still_valid(startup_lock->endpoint_snapshot) && + posix_endpoint_namespace_equal(endpoint, + startup_lock->endpoint_snapshot) && + posix_named_lock_is_retained( + endpoint, endpoint->startup_v2_lock_name, false, + startup_lock->startup_v2_fd, + startup_lock->startup_v2_process_entry, + startup_lock->owner_pid) && + posix_named_lock_is_retained( + endpoint, endpoint->lock_name, true, + startup_lock->legacy_fd, + startup_lock->legacy_process_entry, + startup_lock->owner_pid); +} + +static int posix_stale_generation_cleanup_locked( + const cbm_daemon_ipc_endpoint_t *endpoint) { + /* Holding the startup lock excludes compliant starters. Reserving the + * endpoint lifetime as well closes the check/unlink race against a caller + * that already owns a transferred reservation. */ + cbm_daemon_ipc_lifetime_reservation_t *cleanup_reservation = NULL; + int reservation_result = + cbm_daemon_ipc_lifetime_reservation_try_acquire( + endpoint, &cleanup_reservation); + if (reservation_result != 1) { + return reservation_result == 0 ? 0 : -1; + } + + int result = -1; + int pending_publication = posix_socket_record_publication_recover( + endpoint, endpoint->socket_pending_name, + POSIX_SOCKET_PENDING_MAGIC); + int marker_publication = posix_socket_record_publication_recover( + endpoint, endpoint->socket_identity_name, + POSIX_SOCKET_MARKER_MAGIC); + if (pending_publication != 1 || marker_publication != 1) { + result = pending_publication < 0 || marker_publication < 0 ? -1 : 0; + cbm_daemon_ipc_lifetime_reservation_release(cleanup_reservation); + return result; + } + + posix_socket_record_t marker = {0}; + posix_socket_record_t pending = {0}; + struct stat marker_status = {0}; + struct stat pending_status = {0}; + posix_record_state_t marker_state = posix_socket_record_read( + endpoint, endpoint->socket_identity_name, POSIX_SOCKET_MARKER_MAGIC, + &marker, &marker_status); + posix_record_state_t pending_state = posix_socket_record_read( + endpoint, endpoint->socket_pending_name, POSIX_SOCKET_PENDING_MAGIC, + &pending, &pending_status); + posix_socket_identity_t stable_identity = {0}; + posix_socket_identity_t anchor_identity = {0}; + struct stat stable_status = {0}; + struct stat anchor_status = {0}; + int stable_state = posix_socket_path_identity_read( + endpoint, endpoint->socket_name, &stable_identity, &stable_status); + int anchor_state = posix_socket_path_identity_read( + endpoint, endpoint->socket_anchor_name, &anchor_identity, + &anchor_status); + + if (marker_state == POSIX_RECORD_UNSAFE || + pending_state == POSIX_RECORD_UNSAFE || stable_state < 0 || + anchor_state < 0) { + result = -1; + goto cleanup_done; + } + if (marker_state == POSIX_RECORD_UNKNOWN || + pending_state == POSIX_RECORD_UNKNOWN || + (marker_state == POSIX_RECORD_VALID && + pending_state == POSIX_RECORD_VALID && + !posix_socket_inode_equal(&marker.identity, + &pending.identity))) { + result = 0; + goto cleanup_done; + } + + /* A durable pending record may complete the commit only when both names + * still corroborate its exact anchor inode. It never authorizes deleting + * the stable name by itself. linkat changes ctime, so pending's pre-link + * identity is compared by dev+ino and the final identity is captured now. */ + if (marker_state == POSIX_RECORD_ABSENT && + pending_state == POSIX_RECORD_VALID && stable_state == 1 && + anchor_state == 1 && stable_status.st_nlink == 2 && + anchor_status.st_nlink == 2 && + posix_socket_inode_equal(&stable_identity, &anchor_identity) && + posix_socket_inode_equal(&pending.identity, &anchor_identity)) { + posix_socket_record_t completed = { + .identity = anchor_identity, + }; + (void)snprintf(completed.anchor_name, + sizeof(completed.anchor_name), "%s", + endpoint->socket_anchor_name); + if (!posix_socket_record_publish( + endpoint, endpoint->socket_identity_name, + POSIX_SOCKET_MARKER_MAGIC, &completed, + &marker_status.st_dev, &marker_status.st_ino)) { + result = -1; + goto cleanup_done; + } + marker = completed; + marker_state = POSIX_RECORD_VALID; + } + + if (marker_state == POSIX_RECORD_VALID) { + bool anchor_owned = + anchor_state == 1 && + posix_socket_inode_equal(&marker.identity, &anchor_identity); + if (stable_state == 1 && anchor_owned && + posix_socket_inode_equal(&stable_identity, &anchor_identity)) { + if (stable_status.st_nlink != 2 || + anchor_status.st_nlink != 2 || + !posix_socket_identity_equal(&marker.identity, + &stable_identity) || + !posix_socket_identity_equal(&marker.identity, + &anchor_identity)) { + result = -1; + goto cleanup_done; + } + posix_socket_identity_t confirmed_stable = {0}; + posix_socket_identity_t confirmed_anchor = {0}; + struct stat confirmed_stable_status = {0}; + struct stat confirmed_anchor_status = {0}; + bool confirmed = + posix_socket_path_identity_read( + endpoint, endpoint->socket_name, &confirmed_stable, + &confirmed_stable_status) == 1 && + posix_socket_path_identity_read( + endpoint, endpoint->socket_anchor_name, + &confirmed_anchor, &confirmed_anchor_status) == 1 && + confirmed_stable_status.st_nlink == 2 && + confirmed_anchor_status.st_nlink == 2 && + posix_socket_identity_equal(&confirmed_stable, + &marker.identity) && + posix_socket_identity_equal(&confirmed_anchor, + &marker.identity); + if (!confirmed || + !posix_socket_path_unlink_inode_if_matches( + endpoint->dir_fd, endpoint->socket_name, + &marker.identity, 2) || + !posix_socket_path_unlink_inode_if_matches( + endpoint->dir_fd, endpoint->socket_anchor_name, + &marker.identity, 1)) { + result = -1; + goto cleanup_done; + } + stable_state = 0; + anchor_state = 0; + } else { + /* A legacy/unknown process may have replaced stable. The retained + * anchor proves which inode is ours; preserve every differing + * stable socket and collect only our private publication state. */ + if (anchor_state == 1) { + if (!anchor_owned || anchor_status.st_nlink != 1 || + !posix_socket_path_unlink_inode_if_matches( + endpoint->dir_fd, endpoint->socket_anchor_name, + &marker.identity, 1)) { + result = -1; + goto cleanup_done; + } + anchor_state = 0; + } + } + + if (!posix_path_unlink_regular_if_matches( + endpoint->dir_fd, endpoint->socket_identity_name, + marker_status.st_dev, marker_status.st_ino, 1) || + (pending_state == POSIX_RECORD_VALID && + !posix_path_unlink_regular_if_matches( + endpoint->dir_fd, endpoint->socket_pending_name, + pending_status.st_dev, pending_status.st_ino, 1)) || + !posix_directory_sync(endpoint->dir_fd)) { + result = -1; + goto cleanup_done; + } + result = stable_state == 0 ? 1 : 0; + goto cleanup_done; + } + + if (pending_state == POSIX_RECORD_VALID) { + bool anchor_owned = + anchor_state == 1 && + posix_socket_inode_equal(&pending.identity, &anchor_identity); + if (anchor_state == 1 && + (!anchor_owned || anchor_status.st_nlink != 1)) { + result = -1; + goto cleanup_done; + } + if (anchor_owned && + !posix_socket_path_unlink_inode_if_matches( + endpoint->dir_fd, endpoint->socket_anchor_name, + &pending.identity, 1)) { + result = -1; + goto cleanup_done; + } + if (!posix_path_unlink_regular_if_matches( + endpoint->dir_fd, endpoint->socket_pending_name, + pending_status.st_dev, pending_status.st_ino, 1) || + !posix_directory_sync(endpoint->dir_fd)) { + result = -1; + goto cleanup_done; + } + result = stable_state == 0 ? 1 : 0; + goto cleanup_done; + } + + /* The deterministic generation-local anchor lets us collect the sole + * otherwise-untrackable crash boundary: bind/listen completed but the + * pending record was not yet durable. It never grants authority over the + * public stable path. */ + if (anchor_state == 1) { + if (anchor_status.st_nlink != 1 || + !posix_socket_path_unlink_inode_if_matches( + endpoint->dir_fd, endpoint->socket_anchor_name, + &anchor_identity, 1) || + !posix_directory_sync(endpoint->dir_fd)) { + result = -1; + goto cleanup_done; + } + } + result = stable_state == 0 ? 1 : 0; + +cleanup_done: + cbm_daemon_ipc_lifetime_reservation_release(cleanup_reservation); + return result; +} + +int cbm_daemon_ipc_stale_generation_cleanup( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_startup_lock_t *startup_lock) { + return posix_startup_lock_matches_endpoint(endpoint, startup_lock) + ? posix_stale_generation_cleanup_locked(endpoint) + : -1; +} + +static void posix_publication_abort( + const cbm_daemon_ipc_endpoint_t *endpoint, + const posix_socket_identity_t *anchor_identity, + bool pending_published, const struct stat *pending_status, + bool marker_published, const struct stat *marker_status) { + if (!endpoint || !anchor_identity) { + return; + } + posix_socket_identity_t stable = {0}; + posix_socket_identity_t anchor = {0}; + struct stat stable_status = {0}; + struct stat anchor_status = {0}; + int stable_state = posix_socket_path_identity_read( + endpoint, endpoint->socket_name, &stable, &stable_status); + int anchor_state = posix_socket_path_identity_read( + endpoint, endpoint->socket_anchor_name, &anchor, &anchor_status); + if (stable_state == 1 && anchor_state == 1 && + stable_status.st_nlink == 2 && anchor_status.st_nlink == 2 && + posix_socket_inode_equal(&stable, anchor_identity) && + posix_socket_inode_equal(&anchor, anchor_identity) && + posix_socket_path_unlink_inode_if_matches( + endpoint->dir_fd, endpoint->socket_name, anchor_identity, 2)) { + anchor_status.st_nlink = 1; + } + if (anchor_state == 1 && anchor_status.st_nlink == 1 && + posix_socket_inode_equal(&anchor, anchor_identity)) { + (void)posix_socket_path_unlink_inode_if_matches( + endpoint->dir_fd, endpoint->socket_anchor_name, + anchor_identity, 1); + } + if (marker_published && marker_status) { + (void)posix_path_unlink_regular_if_matches( + endpoint->dir_fd, endpoint->socket_identity_name, + marker_status->st_dev, marker_status->st_ino, 1); + } + if (pending_published && pending_status) { + (void)posix_path_unlink_regular_if_matches( + endpoint->dir_fd, endpoint->socket_pending_name, + pending_status->st_dev, pending_status->st_ino, 1); + } + (void)posix_directory_sync(endpoint->dir_fd); +} + +cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_lifetime_reservation_t **reservation_io) { + cbm_daemon_ipc_lifetime_reservation_t *lifetime_reservation = + reservation_io ? *reservation_io : NULL; + if (!lifetime_reservation_matches_endpoint(endpoint, + lifetime_reservation)) { + cbm_log_error("daemon.ipc.listen_failed", "stage", + "reservation_validation"); + return NULL; + } + /* Stale removal happens only under the startup lock, before a daemon host + * transfers this lifetime reservation. The listener path itself never + * connects to, classifies, or removes pre-existing artifacts. */ + char identity_temp_name[NAME_MAX + 1]; + char pending_temp_name[NAME_MAX + 1]; + if (!posix_socket_record_temp_name(endpoint->socket_identity_name, + identity_temp_name) || + !posix_socket_record_temp_name(endpoint->socket_pending_name, + pending_temp_name)) { + cbm_log_error("daemon.ipc.listen_failed", "stage", "temp_names"); + return NULL; + } + struct stat existing; + const char *required_absent[] = { + endpoint->socket_name, + endpoint->socket_anchor_name, + endpoint->socket_identity_name, + endpoint->socket_pending_name, + identity_temp_name, + pending_temp_name, + }; + bool namespace_absent = true; + for (size_t index = 0; + index < sizeof(required_absent) / sizeof(required_absent[0]); + index++) { + if (fstatat(endpoint->dir_fd, required_absent[index], &existing, + AT_SYMLINK_NOFOLLOW) == 0 || errno != ENOENT) { + namespace_absent = false; + break; + } + } + if (!endpoint_runtime_still_valid(endpoint) || !namespace_absent) { + cbm_log_error("daemon.ipc.listen_failed", "stage", + "namespace_validation"); + return NULL; + } + + int fd = local_socket_new(); + if (fd < 0) { + cbm_log_error("daemon.ipc.listen_failed", "stage", + "socket_creation"); + return NULL; + } + cbm_daemon_ipc_listener_t *listener = calloc(1, sizeof(*listener)); + if (!listener) { + cbm_log_error("daemon.ipc.listen_failed", "stage", + "listener_allocation"); + (void)close(fd); + return NULL; + } + listener->fd = fd; + listener->dir_fd = dup(endpoint->dir_fd); + listener->runtime_dir = string_copy(endpoint->runtime_dir); + listener->dir_device = endpoint->dir_device; + listener->dir_inode = endpoint->dir_inode; + listener->address = string_copy(endpoint->address); + listener->socket_name = string_copy(endpoint->socket_name); + listener->socket_anchor_name = + string_copy(endpoint->socket_anchor_name); + listener->socket_identity_name = + string_copy(endpoint->socket_identity_name); + listener->socket_pending_name = + string_copy(endpoint->socket_pending_name); + listener->owner_pid = getpid(); + if (listener->dir_fd < 0 || !fd_set_cloexec(listener->dir_fd) || + !listener->runtime_dir || !listener->address || !listener->socket_name || + !listener->socket_anchor_name || !listener->socket_identity_name || + !listener->socket_pending_name) { + cbm_log_error("daemon.ipc.listen_failed", "stage", + "listener_initialization"); + if (listener->dir_fd >= 0) { + (void)close(listener->dir_fd); + } + (void)close(fd); + free(listener->runtime_dir); + free(listener->address); + free(listener->socket_name); + free(listener->socket_anchor_name); + free(listener->socket_identity_name); + free(listener->socket_pending_name); + free(listener); + return NULL; + } + + struct sockaddr_un address; + socklen_t address_length; + if (!unix_address_set(&address, endpoint->socket_anchor_address, + &address_length)) { + cbm_log_error("daemon.ipc.listen_failed", "stage", + "socket_address"); + cbm_daemon_ipc_listener_close(listener); + return NULL; + } + if (bind(fd, (const struct sockaddr *)&address, address_length) != 0) { + int bind_error = errno; + char error_text[32]; + (void)snprintf(error_text, sizeof(error_text), "%d", bind_error); + cbm_log_error("daemon.ipc.listen_failed", "bind_errno", error_text); + cbm_daemon_ipc_listener_close(listener); + return NULL; + } + + struct stat bound_status; + bool bound_path_ok = + fstatat(endpoint->dir_fd, endpoint->socket_anchor_name, + &bound_status, AT_SYMLINK_NOFOLLOW) == 0 && + S_ISSOCK(bound_status.st_mode) && bound_status.st_uid == geteuid(); + struct stat anchor_status = {0}; + bool secured = + bound_path_ok && + fchmodat(endpoint->dir_fd, endpoint->socket_anchor_name, 0600, 0) == + 0 && + fstatat(endpoint->dir_fd, endpoint->socket_anchor_name, &anchor_status, + AT_SYMLINK_NOFOLLOW) == 0 && + posix_socket_status_secure_links(&anchor_status, 1) && + anchor_status.st_dev == bound_status.st_dev && + anchor_status.st_ino == bound_status.st_ino; + posix_socket_identity_t anchor_identity = {0}; + if (!secured || listen(fd, 32) != 0 || + fstatat(endpoint->dir_fd, endpoint->socket_anchor_name, + &anchor_status, + AT_SYMLINK_NOFOLLOW) != 0 || + !posix_socket_status_secure_links(&anchor_status, 1) || + anchor_status.st_dev != bound_status.st_dev || + anchor_status.st_ino != bound_status.st_ino || + !posix_socket_identity_from_stat(&anchor_status, &anchor_identity) || + !posix_directory_sync(endpoint->dir_fd)) { + if (bound_path_ok) { + posix_bound_socket_unlink_if_matches( + endpoint->dir_fd, endpoint->socket_anchor_name, + bound_status.st_dev, bound_status.st_ino); + } + cbm_log_error("daemon.ipc.listen_failed", "stage", + "socket_security"); + cbm_daemon_ipc_listener_close(listener); + return NULL; + } + listener->socket_identity = anchor_identity; + posix_publication_stage_reached( + CBM_DAEMON_IPC_POSIX_PUBLICATION_ANCHOR_DURABLE); + + posix_socket_record_t pending = { + .identity = anchor_identity, + }; + (void)snprintf(pending.anchor_name, sizeof(pending.anchor_name), "%s", + endpoint->socket_anchor_name); + struct stat pending_status = {0}; + bool pending_published = posix_socket_record_publish( + endpoint, endpoint->socket_pending_name, POSIX_SOCKET_PENDING_MAGIC, + &pending, &pending_status.st_dev, &pending_status.st_ino); + if (!pending_published) { + cbm_log_error("daemon.ipc.listen_failed", "stage", + "pending_publication"); + posix_publication_abort(endpoint, &anchor_identity, false, NULL, + false, NULL); + cbm_daemon_ipc_listener_close(listener); + return NULL; + } + posix_publication_stage_reached( + CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_DURABLE); + + bool stable_linked = + posix_linkat_no_follow( + endpoint->dir_fd, endpoint->socket_anchor_name, + endpoint->dir_fd, endpoint->socket_name) == 0 && + posix_directory_sync(endpoint->dir_fd); + posix_socket_identity_t stable_identity = {0}; + posix_socket_identity_t committed_identity = {0}; + struct stat stable_status = {0}; + struct stat committed_anchor_status = {0}; + bool stable_valid = + stable_linked && + posix_socket_path_identity_read( + endpoint, endpoint->socket_name, &stable_identity, + &stable_status) == 1 && + posix_socket_path_identity_read( + endpoint, endpoint->socket_anchor_name, &committed_identity, + &committed_anchor_status) == 1 && + stable_status.st_nlink == 2 && + committed_anchor_status.st_nlink == 2 && + posix_socket_identity_equal(&stable_identity, + &committed_identity) && + posix_socket_inode_equal(&anchor_identity, &committed_identity); + if (!stable_valid) { + cbm_log_error("daemon.ipc.listen_failed", "stage", + "stable_publication"); + posix_publication_abort(endpoint, &anchor_identity, + pending_published, &pending_status, false, + NULL); + cbm_daemon_ipc_listener_close(listener); + return NULL; + } + listener->socket_identity = committed_identity; + posix_publication_stage_reached( + CBM_DAEMON_IPC_POSIX_PUBLICATION_STABLE_DURABLE); + + posix_socket_record_t marker = { + .identity = committed_identity, + }; + (void)snprintf(marker.anchor_name, sizeof(marker.anchor_name), "%s", + endpoint->socket_anchor_name); + struct stat marker_status = {0}; + bool marker_published = posix_socket_record_publish( + endpoint, endpoint->socket_identity_name, POSIX_SOCKET_MARKER_MAGIC, + &marker, &marker_status.st_dev, &marker_status.st_ino); + if (!marker_published) { + cbm_log_error("daemon.ipc.listen_failed", "stage", + "marker_publication"); + posix_publication_abort(endpoint, &committed_identity, + pending_published, &pending_status, false, + NULL); + cbm_daemon_ipc_listener_close(listener); + return NULL; + } + listener->identity_device = marker_status.st_dev; + listener->identity_inode = marker_status.st_ino; + posix_publication_stage_reached( + CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_DURABLE); + + if (!posix_path_unlink_regular_if_matches( + endpoint->dir_fd, endpoint->socket_pending_name, + pending_status.st_dev, pending_status.st_ino, 1) || + !posix_directory_sync(endpoint->dir_fd)) { + cbm_log_error("daemon.ipc.listen_failed", "stage", + "pending_removal"); + posix_publication_abort(endpoint, &committed_identity, true, + &pending_status, marker_published, + &marker_status); + cbm_daemon_ipc_listener_close(listener); + return NULL; + } + posix_publication_stage_reached( + CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_REMOVED); + listener->lifetime_reservation = lifetime_reservation; + *reservation_io = NULL; + return listener; +} + +cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen( + const cbm_daemon_ipc_endpoint_t *endpoint) { + cbm_daemon_ipc_startup_lock_t *startup_lock = NULL; + if (cbm_daemon_ipc_startup_lock_try_acquire(endpoint, + &startup_lock) != 1) { + return NULL; + } + cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL; + cbm_daemon_ipc_participant_guard_t *participant_guard = NULL; + if (cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup_lock) != 1 || + cbm_daemon_ipc_participant_guard_try_join( + endpoint, &participant_guard) != 1 || + cbm_daemon_ipc_lifetime_reservation_try_acquire( + endpoint, &reservation) != 1) { + ipc_participant_guard_release_complete(&participant_guard); + ipc_startup_lock_release_complete(&startup_lock); + return NULL; + } + cbm_daemon_ipc_listener_t *listener = + cbm_daemon_ipc_listen_reserved(endpoint, &reservation); + if (listener) { + listener->participant_guard = participant_guard; + participant_guard = NULL; + } + cbm_daemon_ipc_lifetime_reservation_release(reservation); + ipc_participant_guard_release_complete(&participant_guard); + ipc_startup_lock_release_complete(&startup_lock); + return listener; +} + +static void posix_listener_artifacts_remove_if_matches( + cbm_daemon_ipc_listener_t *listener) { + if (!listener || listener->dir_fd < 0 || !listener->socket_name || + !listener->socket_anchor_name || !listener->socket_identity_name || + !listener->socket_pending_name || + listener->owner_pid != getpid()) { + return; + } + cbm_daemon_ipc_endpoint_t view = { + .runtime_dir = listener->runtime_dir, + .dir_device = listener->dir_device, + .dir_inode = listener->dir_inode, + .socket_name = listener->socket_name, + .socket_anchor_name = listener->socket_anchor_name, + .socket_identity_name = listener->socket_identity_name, + .socket_pending_name = listener->socket_pending_name, + .dir_fd = listener->dir_fd, + }; + posix_socket_record_t marker = {0}; + posix_socket_record_t pending = {0}; + struct stat marker_status = {0}; + struct stat pending_status = {0}; + posix_record_state_t marker_state = posix_socket_record_read( + &view, listener->socket_identity_name, POSIX_SOCKET_MARKER_MAGIC, + &marker, &marker_status); + posix_record_state_t pending_state = posix_socket_record_read( + &view, listener->socket_pending_name, POSIX_SOCKET_PENDING_MAGIC, + &pending, &pending_status); + bool marker_matches = marker_state == POSIX_RECORD_VALID && + marker_status.st_dev == listener->identity_device && + marker_status.st_ino == listener->identity_inode && + posix_socket_identity_equal(&marker.identity, + &listener->socket_identity); + bool pending_matches = + pending_state == POSIX_RECORD_ABSENT || + (pending_state == POSIX_RECORD_VALID && + posix_socket_inode_equal(&pending.identity, + &listener->socket_identity)); + if (!marker_matches || !pending_matches) { + return; + } + + posix_socket_identity_t stable = {0}; + posix_socket_identity_t anchor = {0}; + struct stat stable_status = {0}; + struct stat anchor_status = {0}; + int stable_state = posix_socket_path_identity_read( + &view, listener->socket_name, &stable, &stable_status); + int anchor_state = posix_socket_path_identity_read( + &view, listener->socket_anchor_name, &anchor, &anchor_status); + if (stable_state < 0 || anchor_state < 0) { + return; + } + bool anchor_owned = + anchor_state == 1 && + posix_socket_inode_equal(&anchor, &listener->socket_identity); + if (stable_state == 1 && anchor_owned && + posix_socket_inode_equal(&stable, &anchor)) { + if (stable_status.st_nlink != 2 || anchor_status.st_nlink != 2 || + !posix_socket_identity_equal(&stable, + &listener->socket_identity) || + !posix_socket_identity_equal(&anchor, + &listener->socket_identity) || + !posix_socket_path_unlink_inode_if_matches( + listener->dir_fd, listener->socket_name, + &listener->socket_identity, 2) || + !posix_socket_path_unlink_inode_if_matches( + listener->dir_fd, listener->socket_anchor_name, + &listener->socket_identity, 1)) { + return; + } + } else if (anchor_state == 1) { + if (!anchor_owned || anchor_status.st_nlink != 1 || + !posix_socket_path_unlink_inode_if_matches( + listener->dir_fd, listener->socket_anchor_name, + &listener->socket_identity, 1)) { + return; + } + } + + if (!posix_path_unlink_regular_if_matches( + listener->dir_fd, listener->socket_identity_name, + listener->identity_device, listener->identity_inode, 1)) { + return; + } + if (pending_state == POSIX_RECORD_VALID && + !posix_path_unlink_regular_if_matches( + listener->dir_fd, listener->socket_pending_name, + pending_status.st_dev, pending_status.st_ino, 1)) { + return; + } + (void)posix_directory_sync(listener->dir_fd); +} + +void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener) { + if (!listener) { + return; + } + if (listener->fd >= 0) { + (void)close(listener->fd); + listener->fd = -1; + } + if (listener->dir_fd >= 0) { + posix_listener_artifacts_remove_if_matches(listener); + (void)close(listener->dir_fd); + } + cbm_daemon_ipc_lifetime_reservation_release( + listener->lifetime_reservation); + ipc_participant_guard_release_complete(&listener->participant_guard); + free(listener->runtime_dir); + free(listener->address); + free(listener->socket_name); + free(listener->socket_anchor_name); + free(listener->socket_identity_name); + free(listener->socket_pending_name); + free(listener); +} + +int cbm_daemon_ipc_accept(cbm_daemon_ipc_listener_t *listener, uint32_t timeout_ms, + cbm_daemon_ipc_connection_t **connection_out) { + if (connection_out) { + *connection_out = NULL; + } + if (!listener || listener->fd < 0 || listener->owner_pid != getpid() || + !connection_out) { + return -1; + } + uint64_t deadline_ms = ipc_deadline_after(timeout_ms); + for (;;) { + int ready = poll_until(listener->fd, POLLIN, deadline_ms); + if (ready != 1) { + return ready; + } + int fd = accept(listener->fd, NULL, NULL); + if (fd < 0) { + if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK) { + continue; + } + return -1; + } + if (!fd_set_cloexec(fd) || !fd_set_nonblocking(fd) || !socket_disable_sigpipe(fd) || + !socket_peer_is_current_user(fd)) { + (void)close(fd); + return -1; + } + cbm_daemon_ipc_connection_t *connection = malloc(sizeof(*connection)); + if (!connection) { + (void)close(fd); + return -1; + } + connection->fd = fd; + atomic_init(&connection->poisoned, false); + *connection_out = connection; + return 1; + } +} + +int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, + uint32_t timeout_ms) { + if (!endpoint_runtime_still_valid(endpoint)) { + return -1; + } + struct stat status; + if (fstatat(endpoint->dir_fd, endpoint->socket_name, &status, AT_SYMLINK_NOFOLLOW) != 0) { + return errno == ENOENT ? 0 : -1; + } + if (!posix_socket_status_secure_transport(&status)) { + return -1; + } + + int fd = local_socket_new(); + if (fd < 0) { + return -1; + } + struct sockaddr_un address; + socklen_t address_length; + if (!unix_address_set(&address, endpoint->address, &address_length)) { + (void)close(fd); + return -1; + } + + int connected = local_socket_connect(fd, &address, address_length); + int connect_error = connected == 0 ? 0 : errno; + if (connected == 0) { + bool authenticated = socket_peer_is_current_user(fd); + (void)close(fd); + return authenticated ? 1 : -1; + } + if (connect_error == ENOENT) { + (void)close(fd); + return 0; + } + if (connect_error == ECONNREFUSED) { + /* BSD-derived kernels may report ECONNREFUSED when the listen queue + * is full. A validated owner-only socket path therefore fails closed + * as active; treating it as stale could permit an in-place update of + * a running daemon. */ + (void)close(fd); + return 1; + } + if (connect_error == EAGAIN || connect_error == EWOULDBLOCK) { + /* A secure listening socket whose accept queue is full is active. */ + (void)close(fd); + return 1; + } + if (connect_error != EINPROGRESS) { + (void)close(fd); + return -1; + } + + int ready = poll_until(fd, POLLOUT, ipc_deadline_after(timeout_ms)); + int socket_error = 0; + socklen_t error_length = sizeof(socket_error); + if (ready == 0) { + /* Still pending against a validated local socket: conservatively + * active, most commonly a saturated accept queue. */ + (void)close(fd); + return 1; + } + if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &error_length) != 0) { + (void)close(fd); + return -1; + } + if (socket_error == 0) { + bool authenticated = socket_peer_is_current_user(fd); + (void)close(fd); + return authenticated ? 1 : -1; + } + (void)close(fd); + if (socket_error == ENOENT) { + return 0; + } + if (socket_error == ECONNREFUSED) { + return 1; + } + if (socket_error == EAGAIN || socket_error == EWOULDBLOCK || + socket_error == EINPROGRESS) { + return 1; + } + return -1; +} + +cbm_daemon_ipc_connection_t *cbm_daemon_ipc_connect(const cbm_daemon_ipc_endpoint_t *endpoint, + uint32_t timeout_ms) { + if (!endpoint_runtime_still_valid(endpoint)) { + return NULL; + } + uint64_t deadline_ms = ipc_deadline_after(timeout_ms); + for (;;) { + struct stat status; + if (fstatat(endpoint->dir_fd, endpoint->socket_name, &status, AT_SYMLINK_NOFOLLOW) != 0) { + int path_error = errno; + if (path_error == ENOENT && ipc_retry_pause(deadline_ms)) { + continue; + } + return NULL; + } + if (!posix_socket_status_secure_transport(&status)) { + return NULL; + } + + int fd = local_socket_new(); + if (fd < 0) { + return NULL; + } + struct sockaddr_un address; + socklen_t address_length; + if (!unix_address_set(&address, endpoint->address, &address_length)) { + (void)close(fd); + return NULL; + } + + int result = local_socket_connect(fd, &address, address_length); + int connect_error = result == 0 ? 0 : errno; + if (result != 0 && connect_error != EINPROGRESS && connect_error != EAGAIN && + connect_error != EWOULDBLOCK) { + (void)close(fd); + if ((connect_error == ENOENT || connect_error == ECONNREFUSED) && + ipc_retry_pause(deadline_ms)) { + continue; + } + return NULL; + } + if (result != 0) { + int ready = poll_until(fd, POLLOUT, deadline_ms); + int socket_error = 0; + socklen_t error_length = sizeof(socket_error); + if (ready != 1 || + getsockopt(fd, SOL_SOCKET, SO_ERROR, &socket_error, &error_length) != 0) { + (void)close(fd); + return NULL; + } + if (socket_error != 0) { + (void)close(fd); + if ((socket_error == ENOENT || socket_error == ECONNREFUSED) && + ipc_retry_pause(deadline_ms)) { + continue; + } + return NULL; + } + } + if (!socket_peer_is_current_user(fd)) { + (void)close(fd); + return NULL; + } + + cbm_daemon_ipc_connection_t *connection = malloc(sizeof(*connection)); + if (!connection) { + (void)close(fd); + return NULL; + } + connection->fd = fd; + atomic_init(&connection->poisoned, false); + return connection; + } +} + +void cbm_daemon_ipc_connection_close(cbm_daemon_ipc_connection_t *connection) { + if (!connection) { + return; + } + if (connection->fd >= 0) { + (void)close(connection->fd); + } + free(connection); +} + +void cbm_daemon_ipc_connection_interrupt(cbm_daemon_ipc_connection_t *connection) { + if (connection && connection->fd >= 0) { + (void)shutdown(connection->fd, SHUT_RDWR); + } +} + +uint64_t cbm_daemon_ipc_connection_peer_pid( + const cbm_daemon_ipc_connection_t *connection) { + if (!connection || connection->fd < 0 || !socket_peer_is_current_user(connection->fd)) { + return 0; + } +#if defined(__linux__) + struct ucred credentials; + socklen_t length = sizeof(credentials); + if (getsockopt(connection->fd, SOL_SOCKET, SO_PEERCRED, &credentials, &length) != 0 || + length != sizeof(credentials) || credentials.uid != geteuid() || credentials.pid <= 0) { + return 0; + } + return (uint64_t)credentials.pid; +#elif defined(SOL_LOCAL) && defined(LOCAL_PEERPID) + pid_t peer_pid = 0; + socklen_t length = sizeof(peer_pid); + if (getsockopt(connection->fd, SOL_LOCAL, LOCAL_PEERPID, &peer_pid, &length) != 0 || + length != sizeof(peer_pid) || peer_pid <= 0) { + return 0; + } + return (uint64_t)peer_pid; +#else + return 0; +#endif +} + +int cbm_daemon_ipc_startup_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_startup_lock_t **lock_out) { + if (lock_out) { + *lock_out = NULL; + } + if (!lock_out || !endpoint) { + return -1; + } + int startup_v2_fd = -1; + process_lock_entry_t *startup_v2_process_entry = NULL; + int startup_v2_result = posix_named_lock_try_acquire( + endpoint, endpoint->startup_v2_lock_name, &startup_v2_fd, + &startup_v2_process_entry); + if (startup_v2_result != 1) { + return startup_v2_result; + } + int legacy_fd = -1; + process_lock_entry_t *legacy_process_entry = NULL; + int legacy_result = posix_named_shared_lock_try_acquire( + endpoint, endpoint->lock_name, &legacy_fd, &legacy_process_entry); + if (legacy_result != 1) { + posix_named_lock_release(startup_v2_fd, + startup_v2_process_entry); + return legacy_result; + } + cbm_daemon_ipc_startup_lock_t *lock = calloc(1, sizeof(*lock)); + if (lock) { + lock->endpoint_snapshot = endpoint_snapshot_new(endpoint); + } + if (!lock || !lock->endpoint_snapshot) { + if (lock) { + cbm_daemon_ipc_endpoint_free(lock->endpoint_snapshot); + } + free(lock); + posix_named_lock_release(legacy_fd, legacy_process_entry); + posix_named_lock_release(startup_v2_fd, + startup_v2_process_entry); + return -1; + } + lock->startup_v2_fd = startup_v2_fd; + lock->startup_v2_process_entry = startup_v2_process_entry; + lock->legacy_fd = legacy_fd; + lock->legacy_process_entry = legacy_process_entry; + lock->owner_pid = getpid(); + *lock_out = lock; + return 1; +} + +int cbm_daemon_ipc_generation_probe_under_startup_lock( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_startup_lock_t *startup_lock) { + if (!posix_startup_lock_matches_endpoint(endpoint, startup_lock) || + startup_lock->prepared) { + return -1; + } + int lifetime = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + if (lifetime != 0) { + return lifetime; + } + return cbm_daemon_ipc_endpoint_probe(endpoint, 0); +} + +bool cbm_daemon_ipc_startup_lock_prepare_handoff( + cbm_daemon_ipc_startup_lock_t *lock) { + if (!lock || !lock->endpoint_snapshot) { + return false; + } + if (lock->prepared) { + return true; + } + lock->prepared = cbm_daemon_ipc_stale_generation_cleanup( + lock->endpoint_snapshot, lock) == 1; + return lock->prepared; +} + +bool cbm_daemon_ipc_startup_lock_release( + cbm_daemon_ipc_startup_lock_t **lock_io) { + if (!lock_io) { + return false; + } + cbm_daemon_ipc_startup_lock_t *lock = *lock_io; + if (!lock) { + return true; + } + if (lock->owner_pid != getpid()) { + return false; + } + posix_named_lock_release(lock->legacy_fd, + lock->legacy_process_entry); + posix_named_lock_release(lock->startup_v2_fd, + lock->startup_v2_process_entry); + cbm_daemon_ipc_endpoint_free(lock->endpoint_snapshot); + free(lock); + *lock_io = NULL; + return true; +} + +int cbm_daemon_ipc_participant_guard_try_join( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t **guard_out) { + if (guard_out) { + *guard_out = NULL; + } + if (!endpoint || !guard_out) { + return -1; + } + int legacy_fd = -1; + process_lock_entry_t *legacy_process_entry = NULL; + int result = posix_named_shared_lock_try_acquire( + endpoint, endpoint->lock_name, &legacy_fd, &legacy_process_entry); + if (result != 1) { + return result; + } + cbm_daemon_ipc_participant_guard_t *guard = + calloc(1, sizeof(*guard)); + if (!guard) { + posix_named_lock_release(legacy_fd, legacy_process_entry); + return -1; + } + guard->legacy_fd = legacy_fd; + guard->legacy_process_entry = legacy_process_entry; + guard->owner_pid = getpid(); + *guard_out = guard; + return 1; +} + +bool cbm_daemon_ipc_participant_guard_release( + cbm_daemon_ipc_participant_guard_t **guard_io) { + if (!guard_io) { + return false; + } + cbm_daemon_ipc_participant_guard_t *guard = *guard_io; + if (!guard) { + return true; + } + if (guard->legacy_fd < 0 || !guard->legacy_process_entry || + guard->owner_pid != getpid()) { + return false; + } + posix_named_lock_release(guard->legacy_fd, + guard->legacy_process_entry); + free(guard); + *guard_io = NULL; + return true; +} + +int cbm_daemon_ipc_local_transition_try_acquire( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_local_transition_t **transition_out) { + if (transition_out) { + *transition_out = NULL; + } + if (!endpoint || !transition_out) { + return -1; + } + int startup_v2_fd = -1; + process_lock_entry_t *startup_v2_process_entry = NULL; + int startup_v2_result = posix_named_lock_try_acquire( + endpoint, endpoint->startup_v2_lock_name, &startup_v2_fd, + &startup_v2_process_entry); + if (startup_v2_result != 1) { + return startup_v2_result; + } + int legacy_fd = -1; + process_lock_entry_t *legacy_process_entry = NULL; + int legacy_result = posix_named_shared_lock_try_acquire( + endpoint, endpoint->lock_name, &legacy_fd, &legacy_process_entry); + if (legacy_result != 1) { + posix_named_lock_release(startup_v2_fd, + startup_v2_process_entry); + return legacy_result; + } + cbm_daemon_ipc_local_transition_t *transition = + calloc(1, sizeof(*transition)); + if (!transition) { + posix_named_lock_release(legacy_fd, legacy_process_entry); + posix_named_lock_release(startup_v2_fd, + startup_v2_process_entry); + return -1; + } + transition->startup_v2_fd = startup_v2_fd; + transition->startup_v2_process_entry = startup_v2_process_entry; + transition->legacy_fd = legacy_fd; + transition->legacy_process_entry = legacy_process_entry; + transition->endpoint = endpoint; + transition->owner_pid = getpid(); + *transition_out = transition; + return 1; +} + +int cbm_daemon_ipc_local_transition_seal_legacy( + cbm_daemon_ipc_local_transition_t *transition) { + if (!transition || !transition->endpoint || + !posix_named_lock_is_retained( + transition->endpoint, + transition->endpoint->startup_v2_lock_name, false, + transition->startup_v2_fd, + transition->startup_v2_process_entry, + transition->owner_pid) || + !posix_named_lock_is_retained( + transition->endpoint, transition->endpoint->lock_name, true, + transition->legacy_fd, transition->legacy_process_entry, + transition->owner_pid) || transition->work_begun) { + return -1; + } + if (transition->sealed) { + return 1; + } + int lifetime = + cbm_daemon_ipc_lifetime_reservation_probe(transition->endpoint); + int result = lifetime == 1 + ? 1 + : lifetime == 0 + ? posix_stale_generation_cleanup_locked( + transition->endpoint) + : -1; + if (result == 1) { + transition->sealed = true; + } + return result; +} + +int cbm_daemon_ipc_local_transition_lifetime_probe( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_local_transition_t *transition) { + if (!transition || !transition->sealed || !transition->endpoint || + transition->work_begun || + !posix_endpoint_namespace_equal(endpoint, transition->endpoint) || + !posix_named_lock_is_retained( + endpoint, endpoint->startup_v2_lock_name, false, + transition->startup_v2_fd, + transition->startup_v2_process_entry, + transition->owner_pid) || + !posix_named_lock_is_retained( + endpoint, endpoint->lock_name, true, transition->legacy_fd, + transition->legacy_process_entry, transition->owner_pid)) { + return -1; + } + return cbm_daemon_ipc_lifetime_reservation_probe(endpoint); +} + +bool cbm_daemon_ipc_local_transition_begin_work( + cbm_daemon_ipc_local_transition_t *transition) { + if (!transition || !transition->sealed || transition->work_begun || + !transition->endpoint || + !posix_named_lock_is_retained( + transition->endpoint, + transition->endpoint->startup_v2_lock_name, false, + transition->startup_v2_fd, + transition->startup_v2_process_entry, + transition->owner_pid) || + !posix_named_lock_is_retained( + transition->endpoint, transition->endpoint->lock_name, true, + transition->legacy_fd, transition->legacy_process_entry, + transition->owner_pid)) { + return false; + } + posix_named_lock_release(transition->startup_v2_fd, + transition->startup_v2_process_entry); + transition->startup_v2_fd = -1; + transition->startup_v2_process_entry = NULL; + transition->work_begun = true; + return true; +} + +bool cbm_daemon_ipc_local_transition_release( + cbm_daemon_ipc_local_transition_t **transition_io) { + if (!transition_io) { + return false; + } + cbm_daemon_ipc_local_transition_t *transition = *transition_io; + if (!transition) { + return true; + } + if (transition->legacy_fd < 0 || !transition->legacy_process_entry || + transition->owner_pid != getpid() || + (!transition->work_begun && + (transition->startup_v2_fd < 0 || + !transition->startup_v2_process_entry))) { + return false; + } + posix_named_lock_release(transition->legacy_fd, + transition->legacy_process_entry); + if (!transition->work_begun) { + posix_named_lock_release(transition->startup_v2_fd, + transition->startup_v2_process_entry); + } + free(transition); + *transition_io = NULL; + return true; +} + +#else /* _WIN32 */ + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#include +#include +#include +#include + +#ifndef PIPE_REJECT_REMOTE_CLIENTS +#define PIPE_REJECT_REMOTE_CLIENTS 0x00000008 +#endif +#ifndef PROCESS_QUERY_LIMITED_INFORMATION +#define PROCESS_QUERY_LIMITED_INFORMATION 0x1000 +#endif + +typedef BOOL(WINAPI *open_process_token_fn)(HANDLE, DWORD, PHANDLE); +typedef BOOL(WINAPI *open_thread_token_fn)(HANDLE, DWORD, BOOL, PHANDLE); +typedef BOOL(WINAPI *get_token_information_fn)(HANDLE, TOKEN_INFORMATION_CLASS, LPVOID, DWORD, + PDWORD); +typedef DWORD(WINAPI *get_length_sid_fn)(PSID); +typedef BOOL(WINAPI *copy_sid_fn)(DWORD, PSID, PSID); +typedef BOOL(WINAPI *equal_sid_fn)(PSID, PSID); +typedef BOOL(WINAPI *initialize_acl_fn)(PACL, DWORD, DWORD); +typedef BOOL(WINAPI *add_access_allowed_ace_fn)(PACL, DWORD, DWORD, PSID); +typedef BOOL(WINAPI *initialize_security_descriptor_fn)(PSECURITY_DESCRIPTOR, DWORD); +typedef BOOL(WINAPI *set_security_descriptor_dacl_fn)(PSECURITY_DESCRIPTOR, BOOL, PACL, BOOL); +typedef BOOL(WINAPI *get_acl_information_fn)(PACL, LPVOID, DWORD, + ACL_INFORMATION_CLASS); +typedef BOOL(WINAPI *get_ace_fn)(PACL, DWORD, LPVOID *); +typedef DWORD(WINAPI *get_security_info_fn)(HANDLE, SE_OBJECT_TYPE, SECURITY_INFORMATION, PSID *, + PSID *, PACL *, PACL *, PSECURITY_DESCRIPTOR *); +typedef DWORD(WINAPI *set_security_info_fn)(HANDLE, SE_OBJECT_TYPE, SECURITY_INFORMATION, PSID, + PSID, PACL, PACL); +typedef BOOL(WINAPI *impersonate_named_pipe_client_fn)(HANDLE); +typedef BOOL(WINAPI *revert_to_self_fn)(void); +typedef BOOL(WINAPI *get_named_pipe_client_process_id_fn)(HANDLE, PULONG); +typedef BOOL(WINAPI *get_named_pipe_server_process_id_fn)(HANDLE, PULONG); + +typedef struct { + HMODULE advapi; + open_process_token_fn open_process_token; + open_thread_token_fn open_thread_token; + get_token_information_fn get_token_information; + get_length_sid_fn get_length_sid; + copy_sid_fn copy_sid; + equal_sid_fn equal_sid; + initialize_acl_fn initialize_acl; + add_access_allowed_ace_fn add_access_allowed_ace; + initialize_security_descriptor_fn initialize_security_descriptor; + set_security_descriptor_dacl_fn set_security_descriptor_dacl; + get_acl_information_fn get_acl_information; + get_ace_fn get_ace; + get_security_info_fn get_security_info; + set_security_info_fn set_security_info; + impersonate_named_pipe_client_fn impersonate_named_pipe_client; + revert_to_self_fn revert_to_self; + PSID user_sid; + PACL acl; + PSECURITY_DESCRIPTOR descriptor; + SECURITY_ATTRIBUTES attributes; +} win_security_t; + +typedef struct win_generation_address { + char address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]; + wchar_t *pipe_name; + struct win_generation_address *next; +} win_generation_address_t; + +typedef struct win_legacy_mutex_guard win_legacy_mutex_guard_t; + +struct cbm_daemon_ipc_lifetime_reservation { + const struct cbm_daemon_ipc_endpoint *endpoint; + cbm_private_lock_directory_t *directory; + cbm_private_file_lock_t *lock; +}; + +struct cbm_daemon_ipc_endpoint { + char *runtime_dir; + char instance_key[17]; + uint8_t *user_sid; + size_t user_sid_length; + wchar_t *legacy_pipe_name; + wchar_t *legacy_startup_mutex_name; + cbm_mutex_t generations_lock; + _Atomic(win_generation_address_t *) current_generation; + win_generation_address_t *generations; +}; + +struct cbm_daemon_ipc_listener { + wchar_t *pipe_name; + HANDLE pipe; + bool first_instance; + cbm_daemon_ipc_lifetime_reservation_t *lifetime_reservation; + cbm_daemon_ipc_participant_guard_t *participant_guard; +}; + +typedef enum { + CBM_DAEMON_IPC_PIPE_ROLE_ACCEPTED_SERVER = 1, + CBM_DAEMON_IPC_PIPE_ROLE_CONNECTED_CLIENT = 2, +} cbm_daemon_ipc_pipe_role_t; + +struct cbm_daemon_ipc_connection { + HANDLE handle; + atomic_bool poisoned; + cbm_daemon_ipc_pipe_role_t role; +}; + +struct cbm_daemon_ipc_startup_lock { + const cbm_daemon_ipc_endpoint_t *endpoint; + cbm_private_lock_directory_t *directory; + cbm_private_file_lock_t *startup_v2_lock; + win_legacy_mutex_guard_t *legacy_guard; + cbm_private_file_lock_t *group_lock; + HANDLE legacy_sentinel; + bool prepared; +}; + +struct cbm_daemon_ipc_local_transition { + const cbm_daemon_ipc_endpoint_t *endpoint; + cbm_private_lock_directory_t *directory; + cbm_private_file_lock_t *startup_v2_lock; + cbm_private_file_lock_t *group_lock; + HANDLE legacy_sentinel; + win_legacy_mutex_guard_t *teardown_legacy_guard; + bool sealed; + bool work_begun; +}; + +struct cbm_daemon_ipc_participant_guard { + const cbm_daemon_ipc_endpoint_t *endpoint; + cbm_private_lock_directory_t *directory; + cbm_private_file_lock_t *group_lock; + HANDLE legacy_sentinel; + cbm_private_file_lock_t *teardown_startup_v2_lock; + win_legacy_mutex_guard_t *teardown_legacy_guard; +}; + +static uint64_t ipc_now_ms(void) { + return (uint64_t)GetTickCount64(); +} + +static uint64_t ipc_deadline_after(uint32_t timeout_ms) { + if (timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER) { + return UINT64_MAX; + } + return ipc_now_ms() + (uint64_t)timeout_ms; +} + +static _Noreturn void ipc_coordination_cleanup_fail_stop( + const char *component) { + cbm_log_error("daemon.forced_shutdown", "component", component, + "action", "coordination_cleanup"); + (void)fflush(stdout); + (void)fflush(stderr); + (void)TerminateProcess(GetCurrentProcess(), EXIT_FAILURE); + abort(); +} + +static void ipc_startup_lock_release_complete( + cbm_daemon_ipc_startup_lock_t **lock_io) { + uint64_t deadline = + ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); + while (lock_io && *lock_io) { + (void)cbm_daemon_ipc_startup_lock_release(lock_io); + if (!*lock_io) { + return; + } + if (ipc_now_ms() >= deadline) { + ipc_coordination_cleanup_fail_stop("startup_lock_cleanup"); + } + Sleep(1); + } +} + +static void ipc_participant_guard_release_complete( + cbm_daemon_ipc_participant_guard_t **guard_io) { + uint64_t deadline = + ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); + while (guard_io && *guard_io) { + (void)cbm_daemon_ipc_participant_guard_release(guard_io); + if (!*guard_io) { + return; + } + if (ipc_now_ms() >= deadline) { + ipc_coordination_cleanup_fail_stop("participant_guard_cleanup"); + } + Sleep(1); + } +} + +static DWORD win_deadline_remaining(uint64_t deadline_ms) { + if (deadline_ms == UINT64_MAX) { + return INFINITE; + } + uint64_t now_ms = ipc_now_ms(); + if (now_ms >= deadline_ms) { + return 0; + } + uint64_t remaining = deadline_ms - now_ms; + return remaining > (uint64_t)(INFINITE - 1) ? INFINITE - 1 : (DWORD)remaining; +} + +static bool win_retry_pause(uint64_t deadline_ms) { + DWORD remaining_ms = win_deadline_remaining(deadline_ms); + if (remaining_ms == 0) { + return false; + } + DWORD pause_ms = remaining_ms < CBM_DAEMON_IPC_RETRY_INTERVAL_MS + ? remaining_ms + : CBM_DAEMON_IPC_RETRY_INTERVAL_MS; + Sleep(pause_ms); + return true; +} + +static wchar_t *utf8_to_wide(const char *value) { + if (!value) { + return NULL; + } + int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, NULL, 0); + if (needed <= 0) { + return NULL; + } + wchar_t *wide = malloc((size_t)needed * sizeof(*wide)); + if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, wide, needed) <= 0) { + free(wide); + return NULL; + } + return wide; +} + +static char *wide_to_utf8(const wchar_t *value) { + if (!value) { + return NULL; + } + int needed = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value, -1, NULL, 0, NULL, NULL); + if (needed <= 0) { + return NULL; + } + char *utf8 = malloc((size_t)needed); + if (!utf8 || WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, value, -1, utf8, needed, NULL, + NULL) <= 0) { + free(utf8); + return NULL; + } + return utf8; +} + +static wchar_t *wide_copy(const wchar_t *value) { + if (!value) { + return NULL; + } + size_t length = wcslen(value); + wchar_t *copy = malloc((length + 1) * sizeof(*copy)); + if (copy) { + memcpy(copy, value, (length + 1) * sizeof(*copy)); + } + return copy; +} + +static void win_security_destroy(win_security_t *security) { + if (!security) { + return; + } + free(security->descriptor); + free(security->acl); + free(security->user_sid); + if (security->advapi) { + (void)FreeLibrary(security->advapi); + } + memset(security, 0, sizeof(*security)); +} + +static void *win_token_user_query(win_security_t *security, HANDLE token, PSID *sid_out) { + DWORD needed = 0; + (void)security->get_token_information(token, TokenUser, NULL, 0, &needed); + if (needed == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return NULL; + } + /* The dynamically-resolved GetTokenInformation call initializes this + * buffer on success. Zero-initialize it as defense in depth and so + * static analysis does not have to infer writes through a function + * pointer before TOKEN_USER is inspected. */ + void *buffer = calloc(1, needed); + if (!buffer || !security->get_token_information(token, TokenUser, buffer, needed, &needed)) { + free(buffer); + return NULL; + } + *sid_out = ((TOKEN_USER *)buffer)->User.Sid; + return buffer; +} + +#define RESOLVE_ADVAPI_MEMBER(context, member, type, symbol) \ + do { \ + (context)->member = (type)GetProcAddress((context)->advapi, (symbol)); \ + if (!(context)->member) { \ + win_security_destroy((context)); \ + return false; \ + } \ + } while (0) + +static bool win_security_init(win_security_t *security) { + memset(security, 0, sizeof(*security)); + security->advapi = LoadLibraryW(L"advapi32.dll"); + if (!security->advapi) { + return false; + } + RESOLVE_ADVAPI_MEMBER(security, open_process_token, open_process_token_fn, "OpenProcessToken"); + RESOLVE_ADVAPI_MEMBER(security, open_thread_token, open_thread_token_fn, "OpenThreadToken"); + RESOLVE_ADVAPI_MEMBER(security, get_token_information, get_token_information_fn, + "GetTokenInformation"); + RESOLVE_ADVAPI_MEMBER(security, get_length_sid, get_length_sid_fn, "GetLengthSid"); + RESOLVE_ADVAPI_MEMBER(security, copy_sid, copy_sid_fn, "CopySid"); + RESOLVE_ADVAPI_MEMBER(security, equal_sid, equal_sid_fn, "EqualSid"); + RESOLVE_ADVAPI_MEMBER(security, initialize_acl, initialize_acl_fn, "InitializeAcl"); + RESOLVE_ADVAPI_MEMBER(security, add_access_allowed_ace, add_access_allowed_ace_fn, + "AddAccessAllowedAce"); + RESOLVE_ADVAPI_MEMBER(security, initialize_security_descriptor, + initialize_security_descriptor_fn, "InitializeSecurityDescriptor"); + RESOLVE_ADVAPI_MEMBER(security, set_security_descriptor_dacl, set_security_descriptor_dacl_fn, + "SetSecurityDescriptorDacl"); + RESOLVE_ADVAPI_MEMBER(security, get_acl_information, + get_acl_information_fn, "GetAclInformation"); + RESOLVE_ADVAPI_MEMBER(security, get_ace, get_ace_fn, "GetAce"); + RESOLVE_ADVAPI_MEMBER(security, get_security_info, get_security_info_fn, "GetSecurityInfo"); + RESOLVE_ADVAPI_MEMBER(security, set_security_info, set_security_info_fn, "SetSecurityInfo"); + RESOLVE_ADVAPI_MEMBER(security, impersonate_named_pipe_client, impersonate_named_pipe_client_fn, + "ImpersonateNamedPipeClient"); + RESOLVE_ADVAPI_MEMBER(security, revert_to_self, revert_to_self_fn, "RevertToSelf"); + + HANDLE token = NULL; + if (!security->open_process_token(GetCurrentProcess(), TOKEN_QUERY, &token)) { + win_security_destroy(security); + return false; + } + PSID token_sid = NULL; + void *token_user = win_token_user_query(security, token, &token_sid); + (void)CloseHandle(token); + if (!token_user || !token_sid) { + free(token_user); + win_security_destroy(security); + return false; + } + DWORD sid_length = security->get_length_sid(token_sid); + security->user_sid = malloc(sid_length); + if (sid_length == 0 || !security->user_sid || + !security->copy_sid(sid_length, security->user_sid, token_sid)) { + free(token_user); + win_security_destroy(security); + return false; + } + free(token_user); + + size_t acl_size = sizeof(ACL) + sizeof(ACCESS_ALLOWED_ACE) - sizeof(DWORD) + sid_length; + if (acl_size > MAXDWORD) { + win_security_destroy(security); + return false; + } + security->acl = malloc(acl_size); + security->descriptor = malloc(SECURITY_DESCRIPTOR_MIN_LENGTH); + if (!security->acl || !security->descriptor || + !security->initialize_acl(security->acl, (DWORD)acl_size, ACL_REVISION) || + !security->add_access_allowed_ace(security->acl, ACL_REVISION, GENERIC_ALL, + security->user_sid) || + !security->initialize_security_descriptor(security->descriptor, + SECURITY_DESCRIPTOR_REVISION) || + !security->set_security_descriptor_dacl(security->descriptor, TRUE, security->acl, FALSE)) { + win_security_destroy(security); + return false; + } + security->attributes.nLength = sizeof(security->attributes); + security->attributes.lpSecurityDescriptor = security->descriptor; + security->attributes.bInheritHandle = FALSE; + return true; +} + +#undef RESOLVE_ADVAPI_MEMBER + +static bool win_kernel_mutex_current_user_only(win_security_t *security, + HANDLE mutex) { + if (!security || !mutex || mutex == INVALID_HANDLE_VALUE) { + return false; + } + PSID owner = NULL; + PACL dacl = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + DWORD status = security->get_security_info( + mutex, SE_KERNEL_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner, NULL, + &dacl, NULL, &descriptor); + ACL_SIZE_INFORMATION information; + memset(&information, 0, sizeof(information)); + void *raw_ace = NULL; + bool valid = + status == ERROR_SUCCESS && owner && + security->equal_sid(owner, security->user_sid) && dacl && + security->get_acl_information(dacl, &information, + sizeof(information), + AclSizeInformation) && + information.AceCount == 1 && security->get_ace(dacl, 0, &raw_ace) && + raw_ace; + if (valid) { + const ACCESS_ALLOWED_ACE *ace = raw_ace; + const uint8_t *ace_sid = (const uint8_t *)&ace->SidStart; + size_t sid_offset = offsetof(ACCESS_ALLOWED_ACE, SidStart); + size_t sid_capacity = ace->Header.AceSize >= sid_offset + ? ace->Header.AceSize - sid_offset + : 0; + size_t sid_length = sid_capacity >= 8U + ? 8U + (size_t)ace_sid[1] * 4U + : 0; + DWORD required = SYNCHRONIZE | MUTEX_MODIFY_STATE | READ_CONTROL; + bool access_ok = (ace->Mask & GENERIC_ALL) != 0 || + (ace->Mask & required) == required; + valid = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE && + ace->Header.AceFlags == 0 && access_ok && + sid_length <= sid_capacity && + windows_sid_valid(ace_sid, sid_length) && + security->equal_sid((PSID)ace_sid, security->user_sid); + } + if (descriptor) { + (void)LocalFree(descriptor); + } + return valid; +} + +struct win_legacy_mutex_guard { + HANDLE mutex; + HANDLE ready_event; + HANDLE stop_event; + cbm_thread_t owner_thread; + bool owner_started; + atomic_bool release_ok; + atomic_int acquire_result; +}; + +static void *win_legacy_mutex_owner(void *opaque) { + win_legacy_mutex_guard_t *guard = opaque; + DWORD wait_result = WaitForSingleObject(guard->mutex, 0); + int acquire_result; + if (wait_result == WAIT_OBJECT_0 || wait_result == WAIT_ABANDONED) { + acquire_result = 1; + } else { + acquire_result = wait_result == WAIT_TIMEOUT ? 0 : -1; + } + atomic_store_explicit(&guard->acquire_result, acquire_result, + memory_order_release); + bool ready = SetEvent(guard->ready_event) != 0; + bool release_ok = acquire_result != 1; + if (acquire_result == 1) { + DWORD stopped = WaitForSingleObject(guard->stop_event, INFINITE); + release_ok = stopped == WAIT_OBJECT_0 && + ReleaseMutex(guard->mutex) != 0; + } + if (!ready) { + release_ok = false; + } + atomic_store_explicit(&guard->release_ok, release_ok, + memory_order_release); + return NULL; +} + +static bool win_legacy_mutex_guard_release( + win_legacy_mutex_guard_t **guard_io) { + if (!guard_io || !*guard_io) { + return true; + } + win_legacy_mutex_guard_t *guard = *guard_io; + unsigned int failures = atomic_load_explicit( + &g_windows_legacy_guard_release_failures_for_test, + memory_order_acquire); + while (failures > 0) { + if (atomic_compare_exchange_weak_explicit( + &g_windows_legacy_guard_release_failures_for_test, + &failures, failures - 1U, memory_order_acq_rel, + memory_order_acquire)) { + return false; + } + } + bool stopped = !guard->owner_started || SetEvent(guard->stop_event) != 0; + bool joined = !guard->owner_started || + cbm_thread_join(&guard->owner_thread) == 0; + bool released = + stopped && joined && + atomic_load_explicit(&guard->release_ok, memory_order_acquire); + if (!joined) { + return false; + } + if (guard->ready_event) { + (void)CloseHandle(guard->ready_event); + } + if (guard->stop_event) { + (void)CloseHandle(guard->stop_event); + } + if (guard->mutex) { + (void)CloseHandle(guard->mutex); + } + free(guard); + *guard_io = NULL; + return released; +} + +static void win_legacy_mutex_guard_release_complete( + win_legacy_mutex_guard_t **guard_io) { + uint64_t deadline = + ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); + while (guard_io && *guard_io) { + (void)win_legacy_mutex_guard_release(guard_io); + if (!*guard_io) { + return; + } + if (ipc_now_ms() >= deadline) { + ipc_coordination_cleanup_fail_stop("legacy_mutex_cleanup"); + } + Sleep(1); + } +} + +static int win_legacy_mutex_guard_try_acquire( + const cbm_daemon_ipc_endpoint_t *endpoint, + win_legacy_mutex_guard_t **guard_out) { + if (guard_out) { + *guard_out = NULL; + } + if (!endpoint || !endpoint->legacy_startup_mutex_name || !guard_out) { + return -1; + } + win_security_t security; + if (!win_security_init(&security)) { + return -1; + } + SetLastError(ERROR_SUCCESS); + HANDLE mutex = CreateMutexW(&security.attributes, FALSE, + endpoint->legacy_startup_mutex_name); + DWORD create_error = mutex ? GetLastError() : ERROR_GEN_FAILURE; + bool secured = mutex && mutex != INVALID_HANDLE_VALUE; + if (secured && create_error != ERROR_ALREADY_EXISTS) { + secured = security.set_security_info( + mutex, SE_KERNEL_OBJECT, + OWNER_SECURITY_INFORMATION | + DACL_SECURITY_INFORMATION, + security.user_sid, NULL, security.acl, NULL) == + ERROR_SUCCESS; + } + secured = secured && + SetHandleInformation(mutex, HANDLE_FLAG_INHERIT, 0) != 0 && + win_kernel_mutex_current_user_only(&security, mutex); + win_security_destroy(&security); + if (!secured) { + if (mutex && mutex != INVALID_HANDLE_VALUE) { + (void)CloseHandle(mutex); + } + return -1; + } + + win_legacy_mutex_guard_t *guard = calloc(1, sizeof(*guard)); + if (guard) { + guard->mutex = mutex; + guard->ready_event = CreateEventW(NULL, TRUE, FALSE, NULL); + guard->stop_event = CreateEventW(NULL, TRUE, FALSE, NULL); + atomic_init(&guard->release_ok, false); + atomic_init(&guard->acquire_result, -1); + } + if (!guard || !guard->ready_event || !guard->stop_event || + cbm_thread_create(&guard->owner_thread, 0, win_legacy_mutex_owner, + guard) != 0) { + if (guard) { + win_legacy_mutex_guard_release_complete(&guard); + } else { + (void)CloseHandle(mutex); + } + return -1; + } + guard->owner_started = true; + DWORD ready = WaitForSingleObject(guard->ready_event, 5000); + int result = + ready == WAIT_OBJECT_0 + ? atomic_load_explicit(&guard->acquire_result, + memory_order_acquire) + : -1; + if (result != 1) { + win_legacy_mutex_guard_release_complete(&guard); + return result; + } + *guard_out = guard; + return 1; +} + +typedef enum { + WIN_RENDEZVOUS_ERROR = -1, + WIN_RENDEZVOUS_ABSENT = 0, + WIN_RENDEZVOUS_VALID = 1, + WIN_RENDEZVOUS_BUSY = 2, + WIN_RENDEZVOUS_CORRUPT = 3, +} win_rendezvous_status_t; + +static win_rendezvous_status_t win_endpoint_refresh_rendezvous( + const cbm_daemon_ipc_endpoint_t *endpoint); +static win_generation_address_t *win_endpoint_generation_snapshot( + const cbm_daemon_ipc_endpoint_t *endpoint); +static int win_legacy_pipe_probe( + const cbm_daemon_ipc_endpoint_t *endpoint); + +static bool win_token_is_current_user(win_security_t *security, HANDLE token) { + PSID token_sid = NULL; + void *token_user = win_token_user_query(security, token, &token_sid); + bool equal = token_user && token_sid && security->equal_sid(token_sid, security->user_sid); + free(token_user); + return equal; +} + +static bool win_runtime_directory_secure(const wchar_t *runtime_dir) { + win_security_t security; + if (!win_security_init(&security)) { + return false; + } + bool created = CreateDirectoryW(runtime_dir, &security.attributes) != 0; + if (!created && GetLastError() != ERROR_ALREADY_EXISTS) { + win_security_destroy(&security); + return false; + } + DWORD attributes = GetFileAttributesW(runtime_dir); + if (attributes == INVALID_FILE_ATTRIBUTES || (attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + win_security_destroy(&security); + return false; + } + HANDLE directory = + CreateFileW(runtime_dir, READ_CONTROL | WRITE_DAC, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (directory == INVALID_HANDLE_VALUE) { + win_security_destroy(&security); + return false; + } + BY_HANDLE_FILE_INFORMATION file_info; + PSID owner = NULL; + PSECURITY_DESCRIPTOR existing_descriptor = NULL; + bool valid_handle = GetFileInformationByHandle(directory, &file_info) != 0 && + (file_info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (file_info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; + DWORD owner_result = + security.get_security_info(directory, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, + NULL, NULL, NULL, &existing_descriptor); + bool owner_ok = + owner_result == ERROR_SUCCESS && owner && security.equal_sid(owner, security.user_sid); + if (existing_descriptor) { + (void)LocalFree(existing_descriptor); + } + DWORD secure_result = ERROR_ACCESS_DENIED; + if (valid_handle && owner_ok) { + secure_result = security.set_security_info(directory, SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | + PROTECTED_DACL_SECURITY_INFORMATION, + NULL, NULL, security.acl, NULL); + } + (void)CloseHandle(directory); + win_security_destroy(&security); + return valid_handle && owner_ok && secure_result == ERROR_SUCCESS; +} + +static bool win_directory_component_is_plain(const wchar_t *path) { + HANDLE directory = CreateFileW( + path, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (directory == INVALID_HANDLE_VALUE) { + return false; + } + BY_HANDLE_FILE_INFORMATION info; + bool valid = GetFileInformationByHandle(directory, &info) != 0 && + (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; + (void)CloseHandle(directory); + return valid; +} + +static bool win_private_directory_tree_secure(const wchar_t *directory_path) { + if (!directory_path) { + return false; + } + size_t length = wcslen(directory_path); + bool drive_absolute = + length >= 4 && + ((directory_path[0] >= L'A' && directory_path[0] <= L'Z') || + (directory_path[0] >= L'a' && directory_path[0] <= L'z')) && + directory_path[1] == L':' && + (directory_path[2] == L'\\' || directory_path[2] == L'/'); + if (!drive_absolute) { + /* Local current-user ACLs do not provide the intended guarantee for + * UNC/device namespaces. Daemon cache logs must stay on a local + * absolute drive path. */ + return false; + } + wchar_t *path = wide_copy(directory_path); + if (!path) { + return false; + } + for (size_t i = 0; i < length; i++) { + if (path[i] == L'/') { + path[i] = L'\\'; + } + } + win_security_t security; + if (!win_security_init(&security)) { + free(path); + return false; + } + bool ok = true; + size_t component_start = 3; + for (size_t i = component_start; ok && i <= length; i++) { + if (i < length && path[i] != L'\\') { + continue; + } + if (i == component_start) { + component_start = i + 1; + continue; + } + wchar_t saved = path[i]; + path[i] = L'\0'; + const wchar_t *component = path + component_start; + if (wcscmp(component, L".") == 0 || wcscmp(component, L"..") == 0) { + ok = false; + } else { + DWORD attributes = GetFileAttributesW(path); + if (attributes == INVALID_FILE_ATTRIBUTES) { + DWORD error = GetLastError(); + ok = (error == ERROR_FILE_NOT_FOUND || + error == ERROR_PATH_NOT_FOUND) && + CreateDirectoryW(path, &security.attributes) != 0; + } + if (ok) { + ok = win_directory_component_is_plain(path); + } + } + path[i] = saved; + component_start = i + 1; + } + win_security_destroy(&security); + if (ok) { + ok = win_runtime_directory_secure(path); + } + free(path); + return ok; +} + +static bool private_log_base_name_valid(const char *base_name) { + if (!base_name || !base_name[0] || strcmp(base_name, ".") == 0 || + strcmp(base_name, "..") == 0 || strchr(base_name, '/') || + strchr(base_name, '\\')) { + return false; + } + return strlen(base_name) <= 253; +} + +static HANDLE win_private_log_file_open( + const wchar_t *path, DWORD creation_disposition, win_security_t *security, + LARGE_INTEGER *size_out) { + HANDLE file = CreateFileW( + path, GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC, + FILE_SHARE_READ, &security->attributes, creation_disposition, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (file == INVALID_HANDLE_VALUE) { + return INVALID_HANDLE_VALUE; + } + (void)SetHandleInformation(file, HANDLE_FLAG_INHERIT, 0); + BY_HANDLE_FILE_INFORMATION info; + PSID owner = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + bool valid = GetFileType(file) == FILE_TYPE_DISK && + GetFileInformationByHandle(file, &info) != 0 && + (info.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == + 0 && info.nNumberOfLinks == 1 && + GetFileSizeEx(file, size_out) != 0 && size_out->QuadPart >= 0; + DWORD owner_result = security->get_security_info( + file, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, NULL, NULL, + NULL, &descriptor); + bool owner_ok = owner_result == ERROR_SUCCESS && owner && + security->equal_sid(owner, security->user_sid); + if (descriptor) { + (void)LocalFree(descriptor); + } + DWORD secure_result = ERROR_ACCESS_DENIED; + if (valid && owner_ok) { + secure_result = security->set_security_info( + file, SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + NULL, NULL, security->acl, NULL); + } + if (!valid || !owner_ok || secure_result != ERROR_SUCCESS) { + (void)CloseHandle(file); + return INVALID_HANDLE_VALUE; + } + return file; +} + +FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, + const char *base_name, + size_t rotate_cap_bytes) { + if (!directory_path || !directory_path[0] || + !private_log_base_name_valid(base_name) || rotate_cap_bytes == 0) { + return NULL; + } + wchar_t *wide_directory = utf8_to_wide(directory_path); + char *path = string_format("%s/%s", directory_path, base_name); + char *rotated_path = path ? string_format("%s.1", path) : NULL; + wchar_t *wide_path = path ? utf8_to_wide(path) : NULL; + wchar_t *wide_rotated = rotated_path ? utf8_to_wide(rotated_path) : NULL; + if (!wide_directory || !path || !rotated_path || !wide_path || + !wide_rotated || !win_private_directory_tree_secure(wide_directory)) { + free(wide_directory); + free(path); + free(rotated_path); + free(wide_path); + free(wide_rotated); + return NULL; + } + free(wide_directory); + + win_security_t security; + if (!win_security_init(&security)) { + free(path); + free(rotated_path); + free(wide_path); + free(wide_rotated); + return NULL; + } + LARGE_INTEGER size; + HANDLE file = win_private_log_file_open(wide_path, OPEN_ALWAYS, &security, + &size); + bool ok = file != INVALID_HANDLE_VALUE; + if (ok && (uint64_t)size.QuadPart > (uint64_t)rotate_cap_bytes) { + (void)CloseHandle(file); + file = INVALID_HANDLE_VALUE; + DWORD attributes = GetFileAttributesW(wide_rotated); + DWORD attributes_error = + attributes == INVALID_FILE_ATTRIBUTES ? GetLastError() : ERROR_SUCCESS; + bool destination_ok = attributes == INVALID_FILE_ATTRIBUTES && + (attributes_error == ERROR_FILE_NOT_FOUND || + attributes_error == ERROR_PATH_NOT_FOUND); + if (attributes != INVALID_FILE_ATTRIBUTES) { + LARGE_INTEGER destination_size; + HANDLE destination = win_private_log_file_open( + wide_rotated, OPEN_EXISTING, &security, &destination_size); + destination_ok = destination != INVALID_HANDLE_VALUE; + if (destination != INVALID_HANDLE_VALUE) { + (void)CloseHandle(destination); + } + } + ok = destination_ok && + MoveFileExW(wide_path, wide_rotated, + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != + 0; + if (ok) { + file = win_private_log_file_open(wide_path, OPEN_ALWAYS, &security, + &size); + ok = file != INVALID_HANDLE_VALUE; + } + } + FILE *stream = NULL; + if (ok) { + LARGE_INTEGER end = {.QuadPart = 0}; + ok = SetFilePointerEx(file, end, NULL, FILE_END) != 0; + } + if (ok) { + int fd = _open_osfhandle((intptr_t)file, + _O_WRONLY | _O_APPEND | _O_BINARY); + if (fd >= 0) { + file = INVALID_HANDLE_VALUE; + stream = _fdopen(fd, "ab"); + if (!stream) { + (void)_close(fd); + } + } + } + if (file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(file); + } + win_security_destroy(&security); + free(path); + free(rotated_path); + free(wide_path); + free(wide_rotated); + return stream; +} + +static bool win_parent_valid(const wchar_t *parent) { + DWORD attributes = GetFileAttributesW(parent); + return attributes != INVALID_FILE_ATTRIBUTES && (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; +} + +static wchar_t *win_default_runtime_parent(void) { + wchar_t path[MAX_PATH]; + HRESULT result = SHGetFolderPathW(NULL, CSIDL_LOCAL_APPDATA | CSIDL_FLAG_CREATE, NULL, + SHGFP_TYPE_CURRENT, path); + return SUCCEEDED(result) ? wide_copy(path) : NULL; +} + +cbm_daemon_ipc_endpoint_t *cbm_daemon_ipc_endpoint_new(const char *instance_key, + const char *runtime_parent) { + if (!instance_key_valid(instance_key)) { + return NULL; + } + char *parent_utf8 = NULL; + wchar_t *parent_wide = NULL; + if (runtime_parent) { + parent_utf8 = string_copy(runtime_parent); + parent_wide = utf8_to_wide(runtime_parent); + } else { + parent_wide = win_default_runtime_parent(); + parent_utf8 = wide_to_utf8(parent_wide); + } + if (!parent_utf8 || !parent_wide) { + free(parent_utf8); + free(parent_wide); + return NULL; + } + free(parent_wide); + + char canonical_parent[CBM_DAEMON_IPC_PATH_CAP]; + if (!cbm_canonical_path(parent_utf8, canonical_parent, sizeof(canonical_parent))) { + free(parent_utf8); + return NULL; + } + free(parent_utf8); + parent_utf8 = string_copy(canonical_parent); + parent_wide = utf8_to_wide(parent_utf8); + if (!parent_utf8 || !parent_wide || !win_parent_valid(parent_wide)) { + free(parent_utf8); + free(parent_wide); + return NULL; + } + free(parent_wide); + + size_t parent_length = strlen(parent_utf8); + bool has_separator = parent_length > 0 && (parent_utf8[parent_length - 1] == '/' || + parent_utf8[parent_length - 1] == '\\'); + cbm_daemon_ipc_endpoint_t *endpoint = calloc(1, sizeof(*endpoint)); + if (!endpoint) { + free(parent_utf8); + return NULL; + } + win_security_t identity_security; + if (!win_security_init(&identity_security)) { + free(parent_utf8); + free(endpoint); + return NULL; + } + DWORD sid_length = identity_security.get_length_sid(identity_security.user_sid); + bool identity_ok = sid_length > 0 && + windows_sid_valid( + (const uint8_t *)identity_security.user_sid, + sid_length); + if (identity_ok) { + endpoint->user_sid = identity_security.user_sid; + endpoint->user_sid_length = sid_length; + identity_security.user_sid = NULL; + } + win_security_destroy(&identity_security); + if (!identity_ok) { + free(parent_utf8); + free(endpoint); + return NULL; + } + char legacy_pipe[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]; + char legacy_startup[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]; + bool legacy_names_ok = cbm_daemon_ipc_windows_legacy_names( + parent_utf8, instance_key, legacy_pipe, legacy_startup); + endpoint->runtime_dir = + string_format("%s%scbm-daemon-%s", parent_utf8, has_separator ? "" : "/", instance_key); + endpoint->legacy_pipe_name = + legacy_names_ok ? utf8_to_wide(legacy_pipe) : NULL; + endpoint->legacy_startup_mutex_name = + legacy_names_ok ? utf8_to_wide(legacy_startup) : NULL; + (void)memcpy(endpoint->instance_key, instance_key, + sizeof(endpoint->instance_key)); + cbm_mutex_init(&endpoint->generations_lock); + atomic_init(&endpoint->current_generation, NULL); + free(parent_utf8); + wchar_t *runtime_wide = utf8_to_wide(endpoint->runtime_dir); + if (!endpoint->runtime_dir || !runtime_wide || !legacy_names_ok || + !endpoint->legacy_pipe_name || !endpoint->legacy_startup_mutex_name || + !win_runtime_directory_secure(runtime_wide)) { + free(runtime_wide); + cbm_daemon_ipc_endpoint_free(endpoint); + return NULL; + } + free(runtime_wide); + /* Construction never publishes or repairs rendezvous payload. Absence or + * a partial/corrupt record leaves the endpoint safely unaddressed; only a + * later startup-lock winner is permitted to publish a replacement. */ + (void)win_endpoint_refresh_rendezvous(endpoint); + return endpoint; +} + +void cbm_daemon_ipc_endpoint_free(cbm_daemon_ipc_endpoint_t *endpoint) { + if (!endpoint) { + return; + } + win_generation_address_t *generation = endpoint->generations; + while (generation) { + win_generation_address_t *next = generation->next; + free(generation->pipe_name); + free(generation); + generation = next; + } + cbm_mutex_destroy(&endpoint->generations_lock); + free(endpoint->runtime_dir); + free(endpoint->legacy_pipe_name); + free(endpoint->legacy_startup_mutex_name); + free(endpoint->user_sid); + free(endpoint); +} + +const char *cbm_daemon_ipc_endpoint_address(const cbm_daemon_ipc_endpoint_t *endpoint) { + if (!endpoint || win_endpoint_refresh_rendezvous(endpoint) != + WIN_RENDEZVOUS_VALID) { + return NULL; + } + win_generation_address_t *generation = + win_endpoint_generation_snapshot(endpoint); + return generation ? generation->address : NULL; +} + +const char *cbm_daemon_ipc_endpoint_runtime_dir(const cbm_daemon_ipc_endpoint_t *endpoint) { + return endpoint ? endpoint->runtime_dir : NULL; +} + +cbm_private_file_lock_status_t cbm_daemon_ipc_private_lock_directory_new( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_private_lock_directory_t **directory_out) { + if (directory_out) { + *directory_out = NULL; + } + if (!directory_out || !endpoint || !endpoint->runtime_dir) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + wchar_t *runtime_wide = utf8_to_wide(endpoint->runtime_dir); + if (!runtime_wide) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + HANDLE directory = CreateFileW( + runtime_wide, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + free(runtime_wide); + if (directory == INVALID_HANDLE_VALUE) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + (void)SetHandleInformation(directory, HANDLE_FLAG_INHERIT, 0); + cbm_private_file_lock_status_t status = + cbm_private_lock_directory_adopt_windows(directory, endpoint->runtime_dir, + directory_out); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + (void)CloseHandle(directory); + } + return status; +} + +static const char WIN_STARTUP_V2_LOCK_NAME[] = "cbm-startup-v2.lock"; +static const char WIN_PARTICIPANT_GROUP_LOCK_NAME[] = + "cbm-participant-group-v1.lock"; +static const char WIN_LIFETIME_LOCK_NAME[] = "cbm-lifetime.lock"; + +static void win_private_lock_release_complete(cbm_private_file_lock_t **lock_io) { + uint64_t deadline = + ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); + while (lock_io && *lock_io) { + (void)cbm_private_file_lock_release(lock_io); + if (!*lock_io) { + return; + } + if (ipc_now_ms() >= deadline) { + ipc_coordination_cleanup_fail_stop("private_lock_cleanup"); + } + Sleep(1); + } +} + +static win_generation_address_t *win_endpoint_generation_snapshot( + const cbm_daemon_ipc_endpoint_t *endpoint) { + return endpoint + ? atomic_load_explicit(&endpoint->current_generation, + memory_order_acquire) + : NULL; +} + +static void win_endpoint_generation_invalidate( + const cbm_daemon_ipc_endpoint_t *endpoint) { + if (endpoint) { + atomic_store_explicit( + &((cbm_daemon_ipc_endpoint_t *)endpoint)->current_generation, + NULL, memory_order_release); + } +} + +static bool win_endpoint_generation_install( + const cbm_daemon_ipc_endpoint_t *endpoint, const char *address) { + if (!endpoint || !windows_pipe_address_valid(address)) { + return false; + } + win_generation_address_t *snapshot = + win_endpoint_generation_snapshot(endpoint); + if (snapshot && strcmp(snapshot->address, address) == 0) { + return true; + } + win_generation_address_t *candidate = calloc(1, sizeof(*candidate)); + if (candidate) { + (void)memcpy(candidate->address, address, strlen(address) + 1U); + candidate->pipe_name = utf8_to_wide(address); + } + if (!candidate || !candidate->pipe_name) { + free(candidate ? candidate->pipe_name : NULL); + free(candidate); + return false; + } + + cbm_daemon_ipc_endpoint_t *mutable_endpoint = + (cbm_daemon_ipc_endpoint_t *)endpoint; + cbm_mutex_lock(&mutable_endpoint->generations_lock); + win_generation_address_t *current = atomic_load_explicit( + &mutable_endpoint->current_generation, memory_order_acquire); + if (current && strcmp(current->address, address) == 0) { + cbm_mutex_unlock(&mutable_endpoint->generations_lock); + free(candidate->pipe_name); + free(candidate); + return true; + } + candidate->next = mutable_endpoint->generations; + mutable_endpoint->generations = candidate; + atomic_store_explicit(&mutable_endpoint->current_generation, candidate, + memory_order_release); + cbm_mutex_unlock(&mutable_endpoint->generations_lock); + return true; +} + +static win_rendezvous_status_t win_endpoint_refresh_rendezvous( + const cbm_daemon_ipc_endpoint_t *endpoint) { + if (!endpoint || !endpoint->user_sid || endpoint->user_sid_length == 0) { + return WIN_RENDEZVOUS_ERROR; + } + cbm_private_lock_directory_t *directory = NULL; + cbm_private_file_lock_t *record_lock = NULL; + cbm_private_file_lock_status_t directory_status = + cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory); + if (directory_status != CBM_PRIVATE_FILE_LOCK_OK) { + win_endpoint_generation_invalidate(endpoint); + return WIN_RENDEZVOUS_ERROR; + } + cbm_private_file_lock_status_t lock_status = + cbm_private_file_lock_try_acquire( + directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_SH, &record_lock); + if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) { + win_private_lock_release_complete(&record_lock); + cbm_private_lock_directory_close(directory); + win_endpoint_generation_invalidate(endpoint); + return lock_status == CBM_PRIVATE_FILE_LOCK_BUSY + ? WIN_RENDEZVOUS_BUSY + : WIN_RENDEZVOUS_ERROR; + } + + uint8_t record[CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE]; + size_t record_length = 0; + cbm_private_file_lock_status_t read_status = + cbm_private_file_lock_payload_read(record_lock, record, sizeof(record), + &record_length); + uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE]; + char address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]; + bool decoded = read_status == CBM_PRIVATE_FILE_LOCK_OK && + cbm_daemon_ipc_windows_rendezvous_record_decode( + record, record_length, nonce, address); + char expected[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]; + bool bound = decoded && cbm_daemon_ipc_windows_generation_address( + endpoint->user_sid, + endpoint->user_sid_length, + endpoint->instance_key, nonce, expected) && + strcmp(expected, address) == 0; + win_rendezvous_status_t result; + if (read_status != CBM_PRIVATE_FILE_LOCK_OK) { + win_endpoint_generation_invalidate(endpoint); + result = WIN_RENDEZVOUS_ERROR; + } else if (record_length == 0) { + win_endpoint_generation_invalidate(endpoint); + result = WIN_RENDEZVOUS_ABSENT; + } else if (!bound) { + win_endpoint_generation_invalidate(endpoint); + result = WIN_RENDEZVOUS_CORRUPT; + } else if (!win_endpoint_generation_install(endpoint, address)) { + win_endpoint_generation_invalidate(endpoint); + result = WIN_RENDEZVOUS_ERROR; + } else { + result = WIN_RENDEZVOUS_VALID; + } + /* Keep the record lock through the in-memory snapshot update. Otherwise + * a reader of generation N could install/invalidate after a startup owner + * has published generation N+1, regressing this endpoint to stale state. */ + win_private_lock_release_complete(&record_lock); + cbm_private_lock_directory_close(directory); + return result; +} + +typedef LONG(WINAPI *bcrypt_gen_random_fn)(void *, unsigned char *, ULONG, + ULONG); + +static bool win_generation_nonce(uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE]) { + enum { WIN_BCRYPT_USE_SYSTEM_PREFERRED_RNG = 0x00000002 }; + HMODULE bcrypt = LoadLibraryW(L"bcrypt.dll"); + bcrypt_gen_random_fn generate = + bcrypt ? (bcrypt_gen_random_fn)GetProcAddress(bcrypt, + "BCryptGenRandom") + : NULL; + LONG status = generate + ? generate(NULL, nonce, + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE, + WIN_BCRYPT_USE_SYSTEM_PREFERRED_RNG) + : (LONG)-1; + if (bcrypt) { + (void)FreeLibrary(bcrypt); + } + return status >= 0; +} + +static int win_private_lock_probe(cbm_private_lock_directory_t *directory, + const char *base_name) { + cbm_private_file_lock_t *probe = NULL; + cbm_private_file_lock_status_t status = + cbm_private_file_lock_try_acquire(directory, base_name, + CBM_PRIVATE_FILE_LOCK_SH, &probe); + if (status == CBM_PRIVATE_FILE_LOCK_BUSY) { + return 1; + } + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + win_private_lock_release_complete(&probe); + return -1; + } + win_private_lock_release_complete(&probe); + return 0; +} + +static bool win_startup_publish_generation_locked( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_private_file_lock_t *record_lock) { + uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE]; + char address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]; + uint8_t record[CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE]; + if (!record_lock || !win_generation_nonce(nonce) || + !cbm_daemon_ipc_windows_generation_address( + endpoint->user_sid, endpoint->user_sid_length, + endpoint->instance_key, nonce, address) || + !cbm_daemon_ipc_windows_rendezvous_record_encode(nonce, address, + record)) { + return false; + } + bool written = cbm_private_file_lock_payload_write( + record_lock, record, sizeof(record)) == + CBM_PRIVATE_FILE_LOCK_OK; + return written && win_endpoint_generation_install(endpoint, address); +} + +/* Startup ownership serializes publishers, while the rendezvous EX lock is + * the bridge to lifetime ownership: a lifetime acquirer must finish its final + * rendezvous SH refresh before this probe can declare lifetime free. If it + * acquires lifetime after the probe, its final refresh waits for this publish + * and therefore binds the newly advertised generation. */ +static int win_startup_prepare_generation( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_private_lock_directory_t *directory) { + uint64_t now = ipc_now_ms(); + uint64_t deadline = + now > UINT64_MAX - CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RETRY_MS + ? UINT64_MAX + : now + CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RETRY_MS; + for (;;) { + cbm_private_file_lock_t *record_lock = NULL; + cbm_private_file_lock_status_t status = + cbm_private_file_lock_try_acquire( + directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &record_lock); + if (status == CBM_PRIVATE_FILE_LOCK_OK) { + int lifetime = + win_private_lock_probe(directory, WIN_LIFETIME_LOCK_NAME); + bool published = + lifetime == 0 && win_startup_publish_generation_locked( + endpoint, record_lock); + win_private_lock_release_complete(&record_lock); + if (lifetime != 0) { + return lifetime == 1 ? 0 : -1; + } + return published ? 1 : -1; + } + win_private_lock_release_complete(&record_lock); + if (status != CBM_PRIVATE_FILE_LOCK_BUSY) { + return -1; + } + if (ipc_now_ms() >= deadline) { + return 0; + } + Sleep(1); + } +} + +int cbm_daemon_ipc_lifetime_reservation_try_acquire( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_lifetime_reservation_t **reservation_out) { + if (reservation_out) { + *reservation_out = NULL; + } + if (!endpoint || !reservation_out) { + return -1; + } + + win_rendezvous_status_t rendezvous = + win_endpoint_refresh_rendezvous(endpoint); + if (rendezvous == WIN_RENDEZVOUS_ABSENT || + rendezvous == WIN_RENDEZVOUS_CORRUPT) { + cbm_daemon_ipc_startup_lock_t *startup = NULL; + int startup_status = cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &startup); + bool prepared = startup_status == 1 && + cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + ipc_startup_lock_release_complete(&startup); + if (!prepared) { + return startup_status == 0 ? 0 : -1; + } + rendezvous = win_endpoint_refresh_rendezvous(endpoint); + } + if (rendezvous != WIN_RENDEZVOUS_VALID) { + return rendezvous == WIN_RENDEZVOUS_ABSENT || + rendezvous == WIN_RENDEZVOUS_BUSY + ? 0 + : -1; + } + + cbm_private_lock_directory_t *directory = NULL; + cbm_private_file_lock_t *lock = NULL; + if (cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory) != + CBM_PRIVATE_FILE_LOCK_OK) { + return -1; + } + cbm_private_file_lock_status_t status = + cbm_private_file_lock_try_acquire( + directory, WIN_LIFETIME_LOCK_NAME, CBM_PRIVATE_FILE_LOCK_EX, + &lock); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + win_private_lock_release_complete(&lock); + cbm_private_lock_directory_close(directory); + return status == CBM_PRIVATE_FILE_LOCK_BUSY ? 0 : -1; + } + + /* A starter may have won immediately before this lifetime lock. Its + * rendezvous publication must become visible before this owner listens; + * otherwise release rather than binding a stale generation address. */ + if (win_endpoint_refresh_rendezvous(endpoint) != WIN_RENDEZVOUS_VALID) { + win_private_lock_release_complete(&lock); + cbm_private_lock_directory_close(directory); + return 0; + } + cbm_daemon_ipc_lifetime_reservation_t *reservation = + calloc(1, sizeof(*reservation)); + if (!reservation) { + win_private_lock_release_complete(&lock); + cbm_private_lock_directory_close(directory); + return -1; + } + reservation->endpoint = endpoint; + reservation->directory = directory; + reservation->lock = lock; + *reservation_out = reservation; + return 1; +} + +void cbm_daemon_ipc_lifetime_reservation_release( + cbm_daemon_ipc_lifetime_reservation_t *reservation) { + if (!reservation) { + return; + } + win_private_lock_release_complete(&reservation->lock); + cbm_private_lock_directory_close(reservation->directory); + free(reservation); +} + +static bool lifetime_reservation_matches_endpoint( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_lifetime_reservation_t *reservation) { + return endpoint && reservation && reservation->endpoint == endpoint && + reservation->lock; +} + +int cbm_daemon_ipc_lifetime_reservation_probe( + const cbm_daemon_ipc_endpoint_t *endpoint) { + cbm_private_lock_directory_t *directory = NULL; + if (!endpoint || + cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory) != + CBM_PRIVATE_FILE_LOCK_OK) { + return -1; + } + int result = win_private_lock_probe(directory, WIN_LIFETIME_LOCK_NAME); + cbm_private_lock_directory_close(directory); + return result; +} + +static int win_legacy_pipe_probe( + const cbm_daemon_ipc_endpoint_t *endpoint) { + if (!endpoint || !endpoint->legacy_pipe_name || + !endpoint->legacy_startup_mutex_name) { + return -1; + } + + if (WaitNamedPipeW(endpoint->legacy_pipe_name, 0)) { + return 1; + } + DWORD pipe_error = GetLastError(); + if (pipe_error == ERROR_SEM_TIMEOUT || pipe_error == ERROR_PIPE_BUSY) { + return 1; + } + if (pipe_error != ERROR_FILE_NOT_FOUND && + pipe_error != ERROR_PATH_NOT_FOUND) { + return -1; + } + return 0; +} + +int cbm_daemon_ipc_legacy_generation_probe( + const cbm_daemon_ipc_endpoint_t *endpoint) { + int pipe = win_legacy_pipe_probe(endpoint); + if (pipe != 0) { + return pipe; + } + + /* Open-only observation is sufficient: the legacy startup owner retains + * the sole named-object handle until it releases startup. Never create or + * wait on the mutex, so this compatibility path cannot become authority. */ + HANDLE startup = OpenMutexW(SYNCHRONIZE | READ_CONTROL, FALSE, + endpoint->legacy_startup_mutex_name); + if (startup) { + win_security_t security; + bool safe = win_security_init(&security) && + win_kernel_mutex_current_user_only(&security, startup); + win_security_destroy(&security); + (void)SetHandleInformation(startup, HANDLE_FLAG_INHERIT, 0); + (void)CloseHandle(startup); + return safe ? 1 : -1; + } + DWORD startup_error = GetLastError(); + return startup_error == ERROR_FILE_NOT_FOUND ? 0 : -1; +} + +static HANDLE win_pipe_instance_new(const wchar_t *pipe_name, bool first_instance) { + win_security_t security; + if (!win_security_init(&security)) { + return INVALID_HANDLE_VALUE; + } + DWORD open_mode = PIPE_ACCESS_DUPLEX | FILE_FLAG_OVERLAPPED; + if (first_instance) { + open_mode |= FILE_FLAG_FIRST_PIPE_INSTANCE; + } + HANDLE pipe = CreateNamedPipeW( + pipe_name, open_mode, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + PIPE_UNLIMITED_INSTANCES, 64U * 1024U, 64U * 1024U, 0, &security.attributes); + win_security_destroy(&security); + if (pipe != INVALID_HANDLE_VALUE) { + (void)SetHandleInformation(pipe, HANDLE_FLAG_INHERIT, 0); + } + return pipe; +} + +/* This is a namespace reservation, never a protocol endpoint. Every current + * participant owns one idle instance. The first uses + * FILE_FLAG_FIRST_PIPE_INSTANCE; later participants use the same exact pipe + * parameters without that flag. Never calling ConnectNamedPipe means no + * sentinel thread or cancellable I/O survives a participant. */ +static HANDLE win_legacy_sentinel_new( + const cbm_daemon_ipc_endpoint_t *endpoint, bool first, + DWORD *error_out) { + if (error_out) { + *error_out = ERROR_INVALID_PARAMETER; + } + if (!endpoint || !endpoint->legacy_pipe_name || !error_out) { + return INVALID_HANDLE_VALUE; + } + win_security_t security; + if (!win_security_init(&security)) { + *error_out = ERROR_GEN_FAILURE; + return INVALID_HANDLE_VALUE; + } + SetLastError(ERROR_SUCCESS); + DWORD open_mode = PIPE_ACCESS_DUPLEX; + if (first) { + open_mode |= FILE_FLAG_FIRST_PIPE_INSTANCE; + } + HANDLE sentinel = CreateNamedPipeW( + endpoint->legacy_pipe_name, open_mode, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | + PIPE_REJECT_REMOTE_CLIENTS, + PIPE_UNLIMITED_INSTANCES, 1, 1, 0, &security.attributes); + DWORD create_error = sentinel == INVALID_HANDLE_VALUE + ? GetLastError() + : ERROR_SUCCESS; + win_security_destroy(&security); + if (sentinel != INVALID_HANDLE_VALUE && + SetHandleInformation(sentinel, HANDLE_FLAG_INHERIT, 0) == 0) { + create_error = GetLastError(); + (void)CloseHandle(sentinel); + sentinel = INVALID_HANDLE_VALUE; + } + *error_out = create_error; + return sentinel; +} + +static int win_startup_v2_try_acquire( + cbm_private_lock_directory_t *directory, + cbm_private_file_lock_t **lock_out) { + if (lock_out) { + *lock_out = NULL; + } + if (!directory || !lock_out) { + return -1; + } + cbm_private_file_lock_status_t status = + cbm_private_file_lock_try_acquire( + directory, WIN_STARTUP_V2_LOCK_NAME, + CBM_PRIVATE_FILE_LOCK_EX, lock_out); + if (status == CBM_PRIVATE_FILE_LOCK_OK) { + return 1; + } + win_private_lock_release_complete(lock_out); + return status == CBM_PRIVATE_FILE_LOCK_BUSY ? 0 : -1; +} + +static bool win_legacy_sentinel_conflict(DWORD error) { + return error == ERROR_ACCESS_DENIED || error == ERROR_PIPE_BUSY || + error == ERROR_ALREADY_EXISTS; +} + +/* The validated legacy mutex serializes the group-file and deterministic-pipe + * observations. startup-v2 is retained by this process, or by the bootstrap + * parent when a child joins. The SH lock is acquired before the mutex is + * released, so group existence and at least one sentinel have no gap. */ +static int win_participant_group_join_locked( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_private_lock_directory_t *directory, bool allow_first, + cbm_private_file_lock_t **group_out, HANDLE *sentinel_out) { + if (group_out) { + *group_out = NULL; + } + if (sentinel_out) { + *sentinel_out = INVALID_HANDLE_VALUE; + } + if (!endpoint || !directory || !group_out || !sentinel_out) { + return -1; + } + + cbm_private_file_lock_t *exclusive_probe = NULL; + cbm_private_file_lock_status_t group_status = + cbm_private_file_lock_try_acquire( + directory, WIN_PARTICIPANT_GROUP_LOCK_NAME, + CBM_PRIVATE_FILE_LOCK_EX, &exclusive_probe); + bool first = group_status == CBM_PRIVATE_FILE_LOCK_OK; + if (!first && group_status != CBM_PRIVATE_FILE_LOCK_BUSY) { + win_private_lock_release_complete(&exclusive_probe); + return -1; + } + if (first && !allow_first) { + win_private_lock_release_complete(&exclusive_probe); + return 0; + } + + int pipe = win_legacy_pipe_probe(endpoint); + if ((first && pipe != 0) || (!first && pipe != 1)) { + win_private_lock_release_complete(&exclusive_probe); + return pipe < 0 ? -1 : 0; + } + DWORD create_error = ERROR_SUCCESS; + HANDLE sentinel = + win_legacy_sentinel_new(endpoint, first, &create_error); + if (sentinel == INVALID_HANDLE_VALUE) { + win_private_lock_release_complete(&exclusive_probe); + return win_legacy_sentinel_conflict(create_error) ? 0 : -1; + } + if (exclusive_probe) { + win_private_lock_release_complete(&exclusive_probe); + if (exclusive_probe) { + (void)CloseHandle(sentinel); + return -1; + } + } + + cbm_private_file_lock_t *group = NULL; + group_status = cbm_private_file_lock_try_acquire( + directory, WIN_PARTICIPANT_GROUP_LOCK_NAME, + CBM_PRIVATE_FILE_LOCK_SH, &group); + if (group_status != CBM_PRIVATE_FILE_LOCK_OK) { + win_private_lock_release_complete(&group); + (void)CloseHandle(sentinel); + return group_status == CBM_PRIVATE_FILE_LOCK_BUSY ? 0 : -1; + } + *group_out = group; + *sentinel_out = sentinel; + return 1; +} + +/* Called only while startup-v2 and the validated legacy mutex are retained. + * Group SH disappears before this participant's sentinel; both gates exclude + * joiners from observing that required teardown intermediate state. */ +static bool win_participant_group_release_locked( + cbm_private_file_lock_t **group_io, HANDLE *sentinel_io) { + if (!group_io || !sentinel_io) { + return false; + } + if (*group_io && + cbm_private_file_lock_release(group_io) != + CBM_PRIVATE_FILE_LOCK_OK) { + return false; + } + if (*sentinel_io != INVALID_HANDLE_VALUE) { + if (CloseHandle(*sentinel_io) == 0) { + return false; + } + *sentinel_io = INVALID_HANDLE_VALUE; + } + return true; +} + +static void win_participant_group_release_complete( + cbm_private_file_lock_t **group_io, HANDLE *sentinel_io) { + uint64_t deadline = + ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); + while ((group_io && *group_io) || + (sentinel_io && *sentinel_io != INVALID_HANDLE_VALUE)) { + (void)win_participant_group_release_locked(group_io, sentinel_io); + if ((!group_io || !*group_io) && + (!sentinel_io || *sentinel_io == INVALID_HANDLE_VALUE)) { + return; + } + if (ipc_now_ms() >= deadline) { + ipc_coordination_cleanup_fail_stop("participant_group_cleanup"); + } + Sleep(1); + } +} + +static bool win_participant_state_release( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_private_lock_directory_t *directory, + cbm_private_file_lock_t **startup_v2_io, + win_legacy_mutex_guard_t **legacy_guard_io, + cbm_private_file_lock_t **group_io, HANDLE *sentinel_io) { + if (!endpoint || !directory || !startup_v2_io || !legacy_guard_io || + !group_io || !sentinel_io) { + return false; + } + if (!*startup_v2_io) { + int startup = win_startup_v2_try_acquire(directory, startup_v2_io); + if (startup != 1) { + return false; + } + } + if (!*legacy_guard_io) { + int legacy = + win_legacy_mutex_guard_try_acquire(endpoint, legacy_guard_io); + if (legacy != 1) { + (void)cbm_private_file_lock_release(startup_v2_io); + return false; + } + } + if (!win_participant_group_release_locked(group_io, sentinel_io)) { + return false; + } + if (!win_legacy_mutex_guard_release(legacy_guard_io)) { + return false; + } + return cbm_private_file_lock_release(startup_v2_io) == + CBM_PRIVATE_FILE_LOCK_OK; +} + +cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_lifetime_reservation_t **reservation_io) { + cbm_daemon_ipc_lifetime_reservation_t *lifetime_reservation = + reservation_io ? *reservation_io : NULL; + if (!lifetime_reservation_matches_endpoint(endpoint, + lifetime_reservation) || + win_endpoint_refresh_rendezvous(endpoint) != + WIN_RENDEZVOUS_VALID) { + return NULL; + } + win_generation_address_t *generation = + win_endpoint_generation_snapshot(endpoint); + if (!generation || !generation->pipe_name) { + return NULL; + } + cbm_daemon_ipc_listener_t *listener = calloc(1, sizeof(*listener)); + if (!listener) { + return NULL; + } + listener->pipe_name = wide_copy(generation->pipe_name); + listener->pipe = INVALID_HANDLE_VALUE; + listener->first_instance = true; + listener->lifetime_reservation = lifetime_reservation; + if (!listener->pipe_name) { + listener->lifetime_reservation = NULL; + cbm_daemon_ipc_listener_close(listener); + return NULL; + } + listener->pipe = win_pipe_instance_new(listener->pipe_name, true); + if (listener->pipe == INVALID_HANDLE_VALUE) { + listener->lifetime_reservation = NULL; + cbm_daemon_ipc_listener_close(listener); + return NULL; + } + listener->first_instance = false; + *reservation_io = NULL; + return listener; +} + +cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen( + const cbm_daemon_ipc_endpoint_t *endpoint) { + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_daemon_ipc_participant_guard_t *participant_guard = NULL; + cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL; + if (cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) != 1 || + !cbm_daemon_ipc_startup_lock_prepare_handoff(startup) || + cbm_daemon_ipc_participant_guard_try_join( + endpoint, &participant_guard) != 1 || + cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, + &reservation) != 1) { + cbm_daemon_ipc_lifetime_reservation_release(reservation); + ipc_participant_guard_release_complete(&participant_guard); + ipc_startup_lock_release_complete(&startup); + return NULL; + } + cbm_daemon_ipc_listener_t *listener = + cbm_daemon_ipc_listen_reserved(endpoint, &reservation); + if (listener) { + listener->participant_guard = participant_guard; + participant_guard = NULL; + } + cbm_daemon_ipc_lifetime_reservation_release(reservation); + ipc_participant_guard_release_complete(&participant_guard); + ipc_startup_lock_release_complete(&startup); + return listener; +} + +void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener) { + if (!listener) { + return; + } + if (listener->pipe != INVALID_HANDLE_VALUE) { + (void)CancelIoEx(listener->pipe, NULL); + (void)DisconnectNamedPipe(listener->pipe); + (void)CloseHandle(listener->pipe); + } + cbm_daemon_ipc_lifetime_reservation_release( + listener->lifetime_reservation); + ipc_participant_guard_release_complete(&listener->participant_guard); + free(listener->pipe_name); + free(listener); +} + +typedef struct { + HANDLE handle; + OVERLAPPED *overlapped; +} win_pending_io_t; + +static cbm_ipc_pending_wait_status_t win_pending_wait(void *opaque, uint32_t timeout_ms) { + win_pending_io_t *pending = (win_pending_io_t *)opaque; + DWORD result = WaitForSingleObject(pending->overlapped->hEvent, timeout_ms); + if (result == WAIT_OBJECT_0) { + return CBM_IPC_PENDING_WAIT_SIGNALED; + } + return result == WAIT_TIMEOUT ? CBM_IPC_PENDING_WAIT_TIMEOUT : CBM_IPC_PENDING_WAIT_FAILED; +} + +static void win_pending_cancel(void *opaque) { + win_pending_io_t *pending = (win_pending_io_t *)opaque; + (void)CancelIoEx(pending->handle, pending->overlapped); +} + +static cbm_ipc_pending_finish_status_t win_pending_finish(void *opaque, bool blocking, + uint32_t *transferred_out) { + win_pending_io_t *pending = (win_pending_io_t *)opaque; + DWORD transferred = 0; + if (GetOverlappedResult(pending->handle, pending->overlapped, &transferred, + blocking ? TRUE : FALSE)) { + *transferred_out = transferred; + return CBM_IPC_PENDING_FINISH_COMPLETED; + } + return GetLastError() == ERROR_OPERATION_ABORTED ? CBM_IPC_PENDING_FINISH_CANCELLED + : CBM_IPC_PENDING_FINISH_FAILED; +} + +static int win_overlapped_wait(HANDLE handle, OVERLAPPED *overlapped, DWORD timeout_ms, + DWORD *transferred_out) { + win_pending_io_t pending = {.handle = handle, .overlapped = overlapped}; + cbm_ipc_pending_ops_t ops = { + .context = &pending, + .wait = win_pending_wait, + .cancel = win_pending_cancel, + .finish = win_pending_finish, + }; + uint32_t transferred = 0; + int result = cbm_daemon_ipc_wait_pending(&ops, timeout_ms, &transferred); + if (result == 1) { + *transferred_out = (DWORD)transferred; + } + return result; +} + +static bool win_pipe_client_is_current_user(HANDLE pipe) { + win_security_t security; + if (!win_security_init(&security) || !security.impersonate_named_pipe_client(pipe)) { + win_security_destroy(&security); + return false; + } + HANDLE token = NULL; + bool opened = security.open_thread_token(GetCurrentThread(), TOKEN_QUERY, TRUE, &token) != 0; + bool same_user = opened && win_token_is_current_user(&security, token); + if (token) { + (void)CloseHandle(token); + } + bool reverted = security.revert_to_self() != 0; + win_security_destroy(&security); + if (!reverted) { + /* Continuing this daemon thread while it still impersonates an + * untrusted pipe client would corrupt every subsequent access check. */ + (void)TerminateProcess(GetCurrentProcess(), ERROR_ACCESS_DENIED); + abort(); + } + return same_user; +} + +int cbm_daemon_ipc_accept(cbm_daemon_ipc_listener_t *listener, uint32_t timeout_ms, + cbm_daemon_ipc_connection_t **connection_out) { + if (connection_out) { + *connection_out = NULL; + } + if (!listener || !connection_out) { + return -1; + } + if (listener->pipe == INVALID_HANDLE_VALUE) { + listener->pipe = win_pipe_instance_new(listener->pipe_name, listener->first_instance); + listener->first_instance = false; + if (listener->pipe == INVALID_HANDLE_VALUE) { + return -1; + } + } + + OVERLAPPED overlapped; + memset(&overlapped, 0, sizeof(overlapped)); + overlapped.hEvent = CreateEventW(NULL, TRUE, FALSE, NULL); + if (!overlapped.hEvent) { + return -1; + } + BOOL connected = ConnectNamedPipe(listener->pipe, &overlapped); + int result = 1; + if (!connected) { + DWORD connect_error = GetLastError(); + if (connect_error == ERROR_IO_PENDING) { + DWORD transferred = 0; + result = win_overlapped_wait(listener->pipe, &overlapped, timeout_ms, &transferred); + } else if (connect_error != ERROR_PIPE_CONNECTED) { + result = -1; + } + } + (void)CloseHandle(overlapped.hEvent); + if (result != 1) { + (void)DisconnectNamedPipe(listener->pipe); + (void)CloseHandle(listener->pipe); + listener->pipe = INVALID_HANDLE_VALUE; + return result; + } + if (!win_pipe_client_is_current_user(listener->pipe)) { + (void)DisconnectNamedPipe(listener->pipe); + (void)CloseHandle(listener->pipe); + listener->pipe = INVALID_HANDLE_VALUE; + return -1; + } + + cbm_daemon_ipc_connection_t *connection = malloc(sizeof(*connection)); + if (!connection) { + (void)DisconnectNamedPipe(listener->pipe); + (void)CloseHandle(listener->pipe); + listener->pipe = INVALID_HANDLE_VALUE; + return -1; + } + connection->handle = listener->pipe; + atomic_init(&connection->poisoned, false); + connection->role = CBM_DAEMON_IPC_PIPE_ROLE_ACCEPTED_SERVER; + listener->pipe = INVALID_HANDLE_VALUE; + *connection_out = connection; + return 1; +} + +static bool win_pipe_server_is_current_user(HANDLE pipe) { + HMODULE kernel = GetModuleHandleW(L"kernel32.dll"); + get_named_pipe_server_process_id_fn get_server_pid = + kernel ? (get_named_pipe_server_process_id_fn)GetProcAddress(kernel, + "GetNamedPipeServerProcessId") + : NULL; + ULONG process_id = 0; + if (!get_server_pid || !get_server_pid(pipe, &process_id) || process_id == 0) { + return false; + } + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, process_id); + if (!process) { + process = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, process_id); + } + if (!process) { + return false; + } + win_security_t security; + bool initialized = win_security_init(&security); + HANDLE token = NULL; + bool opened = initialized && security.open_process_token(process, TOKEN_QUERY, &token) != 0; + bool same_user = opened && win_token_is_current_user(&security, token); + if (token) { + (void)CloseHandle(token); + } + if (initialized) { + win_security_destroy(&security); + } + (void)CloseHandle(process); + return same_user; +} + +static int win_current_generation_transport_probe( + const cbm_daemon_ipc_endpoint_t *endpoint) { + win_rendezvous_status_t rendezvous = + win_endpoint_refresh_rendezvous(endpoint); + if (rendezvous == WIN_RENDEZVOUS_ABSENT) { + return 0; + } + if (rendezvous != WIN_RENDEZVOUS_VALID) { + return -1; + } + win_generation_address_t *generation = + win_endpoint_generation_snapshot(endpoint); + if (!generation || !generation->pipe_name) { + return -1; + } + HANDLE pipe = CreateFileW(generation->pipe_name, + GENERIC_READ | GENERIC_WRITE, 0, NULL, + OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); + if (pipe != INVALID_HANDLE_VALUE) { + (void)SetHandleInformation(pipe, HANDLE_FLAG_INHERIT, 0); + DWORD mode = PIPE_READMODE_BYTE; + bool authenticated = + SetNamedPipeHandleState(pipe, &mode, NULL, NULL) != 0 && + win_pipe_server_is_current_user(pipe); + (void)CloseHandle(pipe); + return authenticated ? 1 : -1; + } + DWORD error = GetLastError(); + if (error == ERROR_PIPE_BUSY) { + return 1; + } + return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND + ? 0 + : -1; +} + +int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, + uint32_t timeout_ms) { + (void)timeout_ms; + if (!endpoint) { + return -1; + } + win_rendezvous_status_t rendezvous = + win_endpoint_refresh_rendezvous(endpoint); + if (rendezvous != WIN_RENDEZVOUS_VALID) { + if (rendezvous == WIN_RENDEZVOUS_CORRUPT || + rendezvous == WIN_RENDEZVOUS_ERROR) { + return -1; + } + cbm_private_lock_directory_t *directory = NULL; + if (cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory) != + CBM_PRIVATE_FILE_LOCK_OK) { + return -1; + } + int startup = + win_private_lock_probe(directory, WIN_STARTUP_V2_LOCK_NAME); + cbm_private_lock_directory_close(directory); + return startup; + } + win_generation_address_t *generation = + win_endpoint_generation_snapshot(endpoint); + if (!generation) { + return -1; + } + HANDLE pipe = CreateFileW(generation->pipe_name, GENERIC_READ | GENERIC_WRITE, 0, NULL, + OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); + if (pipe != INVALID_HANDLE_VALUE) { + (void)SetHandleInformation(pipe, HANDLE_FLAG_INHERIT, 0); + DWORD mode = PIPE_READMODE_BYTE; + bool authenticated = SetNamedPipeHandleState(pipe, &mode, NULL, NULL) != 0 && + win_pipe_server_is_current_user(pipe); + (void)CloseHandle(pipe); + return authenticated ? 1 : -1; + } + DWORD open_error = GetLastError(); + if (open_error == ERROR_PIPE_BUSY) { + /* Every server instance is occupied, which is itself positive + * liveness evidence. Treat capacity saturation as active. */ + return 1; + } + if (open_error == ERROR_FILE_NOT_FOUND || open_error == ERROR_PATH_NOT_FOUND) { + int lifetime = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + if (lifetime != 0) { + return lifetime; + } + cbm_private_lock_directory_t *directory = NULL; + if (cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory) != + CBM_PRIVATE_FILE_LOCK_OK) { + return -1; + } + int startup = + win_private_lock_probe(directory, WIN_STARTUP_V2_LOCK_NAME); + cbm_private_lock_directory_close(directory); + return startup; + } + return -1; +} + +cbm_daemon_ipc_connection_t *cbm_daemon_ipc_connect(const cbm_daemon_ipc_endpoint_t *endpoint, + uint32_t timeout_ms) { + if (!endpoint) { + return NULL; + } + uint64_t deadline_ms = ipc_deadline_after(timeout_ms); + HANDLE pipe = INVALID_HANDLE_VALUE; + for (;;) { + win_rendezvous_status_t rendezvous = + win_endpoint_refresh_rendezvous(endpoint); + if (rendezvous == WIN_RENDEZVOUS_CORRUPT || + rendezvous == WIN_RENDEZVOUS_ERROR) { + return NULL; + } + win_generation_address_t *generation = + rendezvous == WIN_RENDEZVOUS_VALID + ? win_endpoint_generation_snapshot(endpoint) + : NULL; + if (!generation) { + if (win_retry_pause(deadline_ms)) { + continue; + } + return NULL; + } + pipe = CreateFileW(generation->pipe_name, GENERIC_READ | GENERIC_WRITE, 0, NULL, + OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); + if (pipe != INVALID_HANDLE_VALUE) { + break; + } + DWORD open_error = GetLastError(); + if ((open_error == ERROR_FILE_NOT_FOUND || open_error == ERROR_PATH_NOT_FOUND) && + win_retry_pause(deadline_ms)) { + continue; + } + if (open_error != ERROR_PIPE_BUSY) { + return NULL; + } + DWORD remaining = win_deadline_remaining(deadline_ms); + if (remaining == 0) { + return NULL; + } + if (!WaitNamedPipeW(generation->pipe_name, remaining)) { + DWORD wait_error = GetLastError(); + if ((wait_error == ERROR_FILE_NOT_FOUND || wait_error == ERROR_PATH_NOT_FOUND) && + win_retry_pause(deadline_ms)) { + continue; + } + return NULL; + } + } + (void)SetHandleInformation(pipe, HANDLE_FLAG_INHERIT, 0); + DWORD mode = PIPE_READMODE_BYTE; + if (!SetNamedPipeHandleState(pipe, &mode, NULL, NULL) || + !win_pipe_server_is_current_user(pipe)) { + (void)CloseHandle(pipe); + return NULL; + } + cbm_daemon_ipc_connection_t *connection = malloc(sizeof(*connection)); + if (!connection) { + (void)CloseHandle(pipe); + return NULL; + } + connection->handle = pipe; + atomic_init(&connection->poisoned, false); + connection->role = CBM_DAEMON_IPC_PIPE_ROLE_CONNECTED_CLIENT; + return connection; +} + +void cbm_daemon_ipc_connection_close(cbm_daemon_ipc_connection_t *connection) { + if (!connection) { + return; + } + if (connection->handle != INVALID_HANDLE_VALUE) { + (void)CancelIoEx(connection->handle, NULL); + (void)CloseHandle(connection->handle); + } + free(connection); +} + +void cbm_daemon_ipc_connection_interrupt(cbm_daemon_ipc_connection_t *connection) { + if (!connection || connection->handle == INVALID_HANDLE_VALUE) { + return; + } + (void)CancelIoEx(connection->handle, NULL); + if (connection->role == CBM_DAEMON_IPC_PIPE_ROLE_ACCEPTED_SERVER) { + (void)DisconnectNamedPipe(connection->handle); + } +} + +uint64_t cbm_daemon_ipc_connection_peer_pid( + const cbm_daemon_ipc_connection_t *connection) { + if (!connection || !connection->handle || connection->handle == INVALID_HANDLE_VALUE) { + return 0; + } + + HMODULE kernel = GetModuleHandleW(L"kernel32.dll"); + ULONG process_id = 0; + if (connection->role == CBM_DAEMON_IPC_PIPE_ROLE_ACCEPTED_SERVER) { + get_named_pipe_client_process_id_fn get_client_pid = + kernel ? (get_named_pipe_client_process_id_fn)GetProcAddress( + kernel, "GetNamedPipeClientProcessId") + : NULL; + if (!get_client_pid || !get_client_pid(connection->handle, &process_id)) { + return 0; + } + } else if (connection->role == CBM_DAEMON_IPC_PIPE_ROLE_CONNECTED_CLIENT) { + get_named_pipe_server_process_id_fn get_server_pid = + kernel ? (get_named_pipe_server_process_id_fn)GetProcAddress( + kernel, "GetNamedPipeServerProcessId") + : NULL; + if (!get_server_pid || !get_server_pid(connection->handle, &process_id)) { + return 0; + } + } else { + return 0; + } + return process_id == 0 ? 0 : (uint64_t)process_id; +} + +static int win_io_once(cbm_daemon_ipc_connection_t *connection, void *buffer, DWORD length, + bool writing, uint64_t deadline_ms, DWORD *transferred_out) { + OVERLAPPED overlapped; + memset(&overlapped, 0, sizeof(overlapped)); + overlapped.hEvent = CreateEventW(NULL, TRUE, FALSE, NULL); + if (!overlapped.hEvent) { + return -1; + } + DWORD transferred = 0; + BOOL completed = writing + ? WriteFile(connection->handle, buffer, length, &transferred, &overlapped) + : ReadFile(connection->handle, buffer, length, &transferred, &overlapped); + int result = 1; + if (!completed) { + if (GetLastError() != ERROR_IO_PENDING) { + result = -1; + } else { + result = win_overlapped_wait(connection->handle, &overlapped, + win_deadline_remaining(deadline_ms), &transferred); + } + } + (void)CloseHandle(overlapped.hEvent); + if (result == 1) { + *transferred_out = transferred; + } + return result; +} + +static int connection_read_full(cbm_daemon_ipc_connection_t *connection, void *buffer, + size_t length, uint64_t deadline_ms) { + if (!connection || atomic_load_explicit(&connection->poisoned, memory_order_acquire)) { + return -1; + } + size_t offset = 0; + while (offset < length) { + DWORD chunk = length - offset > MAXDWORD ? MAXDWORD : (DWORD)(length - offset); + DWORD transferred = 0; + int result = win_io_once(connection, (uint8_t *)buffer + offset, chunk, false, deadline_ms, + &transferred); + if (result != 1) { + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return result; + } + if (transferred == 0) { + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return -1; + } + offset += transferred; + } + return 1; +} + +static int connection_write_full(cbm_daemon_ipc_connection_t *connection, const void *buffer, + size_t length, uint64_t deadline_ms) { + if (!connection || atomic_load_explicit(&connection->poisoned, memory_order_acquire)) { + return -1; + } + size_t offset = 0; + while (offset < length) { + DWORD chunk = length - offset > MAXDWORD ? MAXDWORD : (DWORD)(length - offset); + DWORD transferred = 0; + int result = win_io_once(connection, (void *)((const uint8_t *)buffer + offset), chunk, + true, deadline_ms, &transferred); + if (result != 1) { + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return result; + } + if (transferred == 0) { + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return -1; + } + offset += transferred; + } + return 1; +} + +int cbm_daemon_ipc_startup_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_startup_lock_t **lock_out) { + if (lock_out) { + *lock_out = NULL; + } + if (!endpoint || !lock_out) { + return -1; + } + cbm_private_lock_directory_t *directory = NULL; + if (cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory) != + CBM_PRIVATE_FILE_LOCK_OK) { + return -1; + } + cbm_private_file_lock_t *startup_v2 = NULL; + int startup_status = + win_startup_v2_try_acquire(directory, &startup_v2); + if (startup_status != 1) { + cbm_private_lock_directory_close(directory); + return startup_status; + } + win_legacy_mutex_guard_t *legacy_guard = NULL; + int legacy_status = + win_legacy_mutex_guard_try_acquire(endpoint, &legacy_guard); + if (legacy_status != 1) { + win_private_lock_release_complete(&startup_v2); + cbm_private_lock_directory_close(directory); + return legacy_status; + } + + cbm_daemon_ipc_startup_lock_t *lock = calloc(1, sizeof(*lock)); + if (!lock) { + win_legacy_mutex_guard_release_complete(&legacy_guard); + win_private_lock_release_complete(&startup_v2); + cbm_private_lock_directory_close(directory); + return -1; + } + lock->endpoint = endpoint; + lock->directory = directory; + lock->startup_v2_lock = startup_v2; + lock->legacy_guard = legacy_guard; + lock->legacy_sentinel = INVALID_HANDLE_VALUE; + *lock_out = lock; + return 1; +} + +int cbm_daemon_ipc_generation_probe_under_startup_lock( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_startup_lock_t *startup_lock) { + if (!endpoint || !startup_lock || startup_lock->endpoint != endpoint || + !startup_lock->directory || !startup_lock->startup_v2_lock || + !startup_lock->legacy_guard || startup_lock->prepared || + startup_lock->group_lock || + startup_lock->legacy_sentinel != INVALID_HANDLE_VALUE) { + return -1; + } + int lifetime = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + if (lifetime != 0) { + return lifetime; + } + int deterministic_pipe = win_legacy_pipe_probe(endpoint); + if (deterministic_pipe != 0) { + return deterministic_pipe; + } + return win_current_generation_transport_probe(endpoint); +} + +bool cbm_daemon_ipc_startup_lock_prepare_handoff( + cbm_daemon_ipc_startup_lock_t *lock) { + if (!lock || !lock->endpoint || !lock->directory || + !lock->startup_v2_lock) { + return false; + } + if (lock->prepared) { + return lock->group_lock && + lock->legacy_sentinel != INVALID_HANDLE_VALUE && + !lock->legacy_guard; + } + if (!lock->legacy_guard || lock->group_lock || + lock->legacy_sentinel != INVALID_HANDLE_VALUE) { + return false; + } + int joined = win_participant_group_join_locked( + lock->endpoint, lock->directory, true, &lock->group_lock, + &lock->legacy_sentinel); + if (joined != 1) { + return false; + } + int generation = + win_startup_prepare_generation(lock->endpoint, lock->directory); + if (generation != 1) { + (void)win_participant_group_release_locked( + &lock->group_lock, &lock->legacy_sentinel); + return false; + } + if (!win_legacy_mutex_guard_release(&lock->legacy_guard)) { + return false; + } + lock->prepared = true; + return true; +} + +bool cbm_daemon_ipc_startup_lock_release( + cbm_daemon_ipc_startup_lock_t **lock_io) { + if (!lock_io) { + return false; + } + cbm_daemon_ipc_startup_lock_t *lock = *lock_io; + if (!lock) { + return true; + } + if (!win_participant_state_release( + lock->endpoint, lock->directory, &lock->startup_v2_lock, + &lock->legacy_guard, &lock->group_lock, + &lock->legacy_sentinel)) { + return false; + } + cbm_private_lock_directory_close(lock->directory); + free(lock); + *lock_io = NULL; + return true; +} + +int cbm_daemon_ipc_participant_guard_try_join( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t **guard_out) { + if (guard_out) { + *guard_out = NULL; + } + if (!endpoint || !guard_out) { + return -1; + } + cbm_daemon_ipc_participant_guard_t *guard = + calloc(1, sizeof(*guard)); + if (!guard) { + return -1; + } + cbm_private_lock_directory_t *directory = NULL; + if (cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory) != + CBM_PRIVATE_FILE_LOCK_OK) { + free(guard); + return -1; + } + win_legacy_mutex_guard_t *legacy_guard = NULL; + int legacy = + win_legacy_mutex_guard_try_acquire(endpoint, &legacy_guard); + if (legacy != 1) { + cbm_private_lock_directory_close(directory); + free(guard); + return legacy; + } + cbm_private_file_lock_t *group = NULL; + HANDLE sentinel = INVALID_HANDLE_VALUE; + int joined = win_participant_group_join_locked( + endpoint, directory, false, &group, &sentinel); + bool mutex_released = + win_legacy_mutex_guard_release(&legacy_guard); + if (joined != 1 || !mutex_released) { + if (group || sentinel != INVALID_HANDLE_VALUE) { + win_participant_group_release_complete(&group, &sentinel); + } + win_legacy_mutex_guard_release_complete(&legacy_guard); + cbm_private_lock_directory_close(directory); + free(guard); + return joined == 0 && mutex_released ? 0 : -1; + } + guard->endpoint = endpoint; + guard->directory = directory; + guard->group_lock = group; + guard->legacy_sentinel = sentinel; + *guard_out = guard; + return 1; +} + +bool cbm_daemon_ipc_participant_guard_release( + cbm_daemon_ipc_participant_guard_t **guard_io) { + if (!guard_io) { + return false; + } + cbm_daemon_ipc_participant_guard_t *guard = *guard_io; + if (!guard) { + return true; + } + if (!win_participant_state_release( + guard->endpoint, guard->directory, + &guard->teardown_startup_v2_lock, + &guard->teardown_legacy_guard, &guard->group_lock, + &guard->legacy_sentinel)) { + return false; + } + cbm_private_lock_directory_close(guard->directory); + free(guard); + *guard_io = NULL; + return true; +} + +int cbm_daemon_ipc_local_transition_try_acquire( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_local_transition_t **transition_out) { + if (transition_out) { + *transition_out = NULL; + } + if (!endpoint || !transition_out) { + return -1; + } + cbm_private_lock_directory_t *directory = NULL; + if (cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory) != + CBM_PRIVATE_FILE_LOCK_OK) { + return -1; + } + cbm_private_file_lock_t *startup_v2 = NULL; + int startup = win_startup_v2_try_acquire(directory, &startup_v2); + if (startup != 1) { + cbm_private_lock_directory_close(directory); + return startup; + } + cbm_daemon_ipc_local_transition_t *transition = + calloc(1, sizeof(*transition)); + if (!transition) { + win_private_lock_release_complete(&startup_v2); + cbm_private_lock_directory_close(directory); + return -1; + } + transition->endpoint = endpoint; + transition->directory = directory; + transition->startup_v2_lock = startup_v2; + transition->legacy_sentinel = INVALID_HANDLE_VALUE; + *transition_out = transition; + return 1; +} + +int cbm_daemon_ipc_local_transition_seal_legacy( + cbm_daemon_ipc_local_transition_t *transition) { + if (!transition || !transition->endpoint || !transition->directory || + !transition->startup_v2_lock || transition->work_begun) { + return -1; + } + if (transition->sealed) { + return transition->group_lock && + transition->legacy_sentinel != INVALID_HANDLE_VALUE + ? 1 + : -1; + } + win_legacy_mutex_guard_t *legacy_guard = NULL; + int legacy = win_legacy_mutex_guard_try_acquire( + transition->endpoint, &legacy_guard); + if (legacy != 1) { + return legacy; + } + int joined = win_participant_group_join_locked( + transition->endpoint, transition->directory, true, + &transition->group_lock, &transition->legacy_sentinel); + bool mutex_released = + win_legacy_mutex_guard_release(&legacy_guard); + if (joined != 1 || !mutex_released) { + if (legacy_guard || transition->group_lock || + transition->legacy_sentinel != INVALID_HANDLE_VALUE) { + transition->teardown_legacy_guard = legacy_guard; + } + return joined == 0 && mutex_released ? 0 : -1; + } + transition->sealed = true; + return 1; +} + +int cbm_daemon_ipc_local_transition_lifetime_probe( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_local_transition_t *transition) { + if (!endpoint || !transition || transition->endpoint != endpoint || + !transition->directory || !transition->startup_v2_lock || + !transition->sealed || transition->work_begun || + !transition->group_lock || + transition->legacy_sentinel == INVALID_HANDLE_VALUE) { + return -1; + } + return cbm_daemon_ipc_lifetime_reservation_probe(endpoint); +} + +bool cbm_daemon_ipc_local_transition_begin_work( + cbm_daemon_ipc_local_transition_t *transition) { + if (!transition || !transition->endpoint || !transition->directory || + !transition->startup_v2_lock || !transition->sealed || + transition->work_begun || !transition->group_lock || + transition->legacy_sentinel == INVALID_HANDLE_VALUE) { + return false; + } + if (cbm_private_file_lock_release(&transition->startup_v2_lock) != + CBM_PRIVATE_FILE_LOCK_OK) { + return false; + } + transition->work_begun = true; + return true; +} + +bool cbm_daemon_ipc_local_transition_release( + cbm_daemon_ipc_local_transition_t **transition_io) { + if (!transition_io) { + return false; + } + cbm_daemon_ipc_local_transition_t *transition = *transition_io; + if (!transition) { + return true; + } + if (!transition->endpoint || !transition->directory) { + return false; + } + if (transition->group_lock || + transition->legacy_sentinel != INVALID_HANDLE_VALUE || + transition->teardown_legacy_guard) { + if (!win_participant_state_release( + transition->endpoint, transition->directory, + &transition->startup_v2_lock, + &transition->teardown_legacy_guard, + &transition->group_lock, + &transition->legacy_sentinel)) { + return false; + } + } else if (transition->startup_v2_lock && + cbm_private_file_lock_release( + &transition->startup_v2_lock) != + CBM_PRIVATE_FILE_LOCK_OK) { + return false; + } + cbm_private_lock_directory_close(transition->directory); + transition->directory = NULL; + free(transition); + *transition_io = NULL; + return true; +} + +#endif /* _WIN32 */ + +bool cbm_daemon_ipc_send_frame(cbm_daemon_ipc_connection_t *connection, + cbm_daemon_frame_type_t type, uint16_t flags, const void *payload, + uint32_t length) { + if (!connection || atomic_load_explicit(&connection->poisoned, memory_order_acquire) || + (length > 0 && !payload)) { + return false; + } + uint8_t header[CBM_DAEMON_FRAME_HEADER_SIZE]; + if (!cbm_daemon_frame_header_encode(header, type, flags, length)) { + return false; + } + uint64_t deadline_ms = ipc_deadline_after(CBM_DAEMON_IPC_SEND_TIMEOUT_MS); + if (connection_write_full(connection, header, sizeof(header), deadline_ms) != 1) { + return false; + } + if (length > 0 && connection_write_full(connection, payload, length, deadline_ms) != 1) { + /* The peer has already received a complete header and will interpret + * subsequent bytes as this payload, so this stream cannot be reused. */ + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return false; + } + return true; +} + +int cbm_daemon_ipc_receive_frame_bounded( + cbm_daemon_ipc_connection_t *connection, uint32_t timeout_ms, + uint32_t max_payload_length, cbm_daemon_frame_t *frame_out, + uint8_t **payload_out) { + if (payload_out) { + *payload_out = NULL; + } + if (!connection || atomic_load_explicit(&connection->poisoned, memory_order_acquire) || + !frame_out || !payload_out) { + return -1; + } + memset(frame_out, 0, sizeof(*frame_out)); + uint64_t deadline_ms = ipc_deadline_after(timeout_ms); + uint8_t header[CBM_DAEMON_FRAME_HEADER_SIZE]; + int result = connection_read_full(connection, header, sizeof(header), deadline_ms); + if (result != 1) { + return result; + } + cbm_daemon_frame_t frame; + if (!cbm_daemon_frame_header_decode(header, &frame)) { + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return -1; + } + if (frame.length > max_payload_length) { + /* The fixed unauthenticated envelope limit is enforced from the header + * before allocating or reading attacker-controlled payload bytes. The + * unread stream is necessarily unusable and is closed by its owner. */ + atomic_store_explicit(&connection->poisoned, true, + memory_order_release); + return -1; + } + uint8_t *payload = NULL; + if (frame.length > 0) { + payload = malloc(frame.length); + if (!payload) { + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + return -1; + } + result = connection_read_full(connection, payload, frame.length, deadline_ms); + if (result != 1) { + atomic_store_explicit(&connection->poisoned, true, memory_order_release); + free(payload); + return result; + } + } + *frame_out = frame; + *payload_out = payload; + return 1; +} + +int cbm_daemon_ipc_receive_frame(cbm_daemon_ipc_connection_t *connection, + uint32_t timeout_ms, + cbm_daemon_frame_t *frame_out, + uint8_t **payload_out) { + return cbm_daemon_ipc_receive_frame_bounded( + connection, timeout_ms, CBM_DAEMON_MAX_FRAME_SIZE, frame_out, + payload_out); +} diff --git a/src/daemon/ipc.h b/src/daemon/ipc.h new file mode 100644 index 000000000..4e94150b9 --- /dev/null +++ b/src/daemon/ipc.h @@ -0,0 +1,248 @@ +/* + * ipc.h — Authenticated local transport for the CBM daemon. + * + * Unix builds use owner-only Unix-domain sockets and advisory file locks. + * Windows builds use generation-random current-user named pipes plus + * handle-anchored private lock files in an owner-only runtime directory. All + * implementation details stay opaque so callers cannot accidentally bypass + * the transport checks. + */ +#ifndef CBM_DAEMON_IPC_H +#define CBM_DAEMON_IPC_H + +#include "daemon/daemon.h" +#include "foundation/private_file_lock.h" + +#include +#include +#include +#include + +typedef struct cbm_daemon_ipc_endpoint cbm_daemon_ipc_endpoint_t; +typedef struct cbm_daemon_ipc_listener cbm_daemon_ipc_listener_t; +typedef struct cbm_daemon_ipc_connection cbm_daemon_ipc_connection_t; +typedef struct cbm_daemon_ipc_startup_lock cbm_daemon_ipc_startup_lock_t; +typedef struct cbm_daemon_ipc_local_transition + cbm_daemon_ipc_local_transition_t; +typedef struct cbm_daemon_ipc_participant_guard + cbm_daemon_ipc_participant_guard_t; +typedef struct cbm_daemon_ipc_lifetime_reservation + cbm_daemon_ipc_lifetime_reservation_t; + +/* A receive wait with no wall-clock expiry. The wait remains interruptible by + * peer EOF and cbm_daemon_ipc_connection_interrupt(). This is intentionally a + * named sentinel rather than a very large finite timeout: authenticated MCP + * sessions routinely remain idle for days, and a long-running application + * request must not lose its control connection merely because no new frame is + * arriving. */ +#define CBM_DAEMON_IPC_WAIT_FOREVER UINT32_MAX + +/* Create one endpoint namespace for an exact 16-lowercase-hex instance key. + * POSIX addresses are deterministic; Windows resolves the current daemon + * generation through an owner-only nonce record. runtime_parent may be NULL + * to use the platform's secure local default. The returned runtime directory + * is an owner-only child of that parent. */ +cbm_daemon_ipc_endpoint_t *cbm_daemon_ipc_endpoint_new(const char *instance_key, + const char *runtime_parent); +void cbm_daemon_ipc_endpoint_free(cbm_daemon_ipc_endpoint_t *endpoint); +const char *cbm_daemon_ipc_endpoint_address(const cbm_daemon_ipc_endpoint_t *endpoint); +const char *cbm_daemon_ipc_endpoint_runtime_dir(const cbm_daemon_ipc_endpoint_t *endpoint); + +/* Duplicate the endpoint's already-validated owner-only runtime-directory + * handle for native lock files. Callers never reopen the path themselves. */ +cbm_private_file_lock_status_t cbm_daemon_ipc_private_lock_directory_new( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_private_lock_directory_t **directory_out); + +/* Listener and connection handles are non-inheritable. */ +cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen(const cbm_daemon_ipc_endpoint_t *endpoint); + +/* A daemon host may reserve endpoint ownership before constructing any + * stateful subsystem, then transfer that exact reservation into its listener. + * POSIX listener publication binds a private generation anchor, durably + * records the pending anchor identity through a recoverable deterministic + * temp link, hard-links the anchor into the stable socket name without + * overwrite, commits an anchor-bearing identity marker through the same + * protocol, and removes pending last. The anchor remains linked for the + * listener lifetime. + * listen_reserved consumes *reservation_io only on success and sets it to + * NULL; on failure the caller still owns it. A reservation is bound to the + * endpoint that created it and cannot be adopted by another endpoint. The + * daemon host must retain a participant guard before taking the reservation; + * the convenience listen() wrapper owns that guard automatically. */ +int cbm_daemon_ipc_lifetime_reservation_try_acquire( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_lifetime_reservation_t **reservation_out); +void cbm_daemon_ipc_lifetime_reservation_release( + cbm_daemon_ipc_lifetime_reservation_t *reservation); +cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_lifetime_reservation_t **reservation_io); +void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener); + +/* Create/validate an owner-only directory and securely open one regular + * owner-only append log within it. User-controlled path components may not be + * symlinks, junctions, or other reparse points; trusted root-owned macOS /tmp + * and /var aliases are normalized before validation. A file larger than + * rotate_cap_bytes is moved to .1 before a new stream is opened. + * The caller owns the returned FILE and must fclose it. */ +FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, + const char *base_name, + size_t rotate_cap_bytes); + +/* Tri-state operations return 1 on success, 0 on timeout, and -1 on error. + * receive_frame also accepts CBM_DAEMON_IPC_WAIT_FOREVER. */ +int cbm_daemon_ipc_accept(cbm_daemon_ipc_listener_t *listener, uint32_t timeout_ms, + cbm_daemon_ipc_connection_t **connection_out); + +/* No-spawn endpoint liveness probe for binary activation safety. Returns + * 1 when a secure daemon endpoint is connected, pending, or busy; 0 only when + * it is absent; -1 when endpoint ownership or safety cannot be proven. A + * secure but refused Unix socket also fails closed as active because BSD may + * use ECONNREFUSED for a full listen queue. + * It never sends a protocol frame, so exact and conflicting builds are both + * reported as active. */ +int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, + uint32_t timeout_ms); + +/* Observe the daemon-generation lifetime reservation without spawning a + * daemon or retaining the reservation. Returns 1 while a host is constructing, + * listening, or closing the stable endpoint, 0 when the reservation is free, + * and -1 when ownership/safety cannot be proven. Stale cleanup holds both the + * startup lock and a temporary lifetime reservation; listener construction + * then transfers a lifetime reservation through listener teardown. */ +int cbm_daemon_ipc_lifetime_reservation_probe( + const cbm_daemon_ipc_endpoint_t *endpoint); + +#ifndef _WIN32 +/* Remove only a provably current-generation stale Unix socket identity. The + * caller must first observe an absent lifetime reservation and retain the + * matching startup lock for the complete call; the implementation rechecks + * both conditions. Stable deletion requires a committed marker whose named + * anchor and stable path are the same secure socket inode; pending alone may + * only complete that commit when both paths independently corroborate it. + * A differing stable replacement is preserved while owned anchor/records are + * collected. Returns 1 when the stable endpoint is absent and all owned + * artifacts are absent or were removed, 0 when cleanup is refused for a live, + * legacy, unknown, or mismatched identity, and -1 for an invalid lock or + * unsafe/I/O state. This operation never connects to the endpoint. */ +int cbm_daemon_ipc_stale_generation_cleanup( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_startup_lock_t *startup_lock); +#endif + +/* Migration-only observation of transport/startup primitives used by builds + * that predate version-cohort coordination. This never connects to the old + * endpoint and never claims a legacy primitive as ownership authority. + * Returns 1 when a legacy generation is present or starting, 0 when absent, + * and -1 when ownership/safety cannot be proven. */ +int cbm_daemon_ipc_legacy_generation_probe( + const cbm_daemon_ipc_endpoint_t *endpoint); + +cbm_daemon_ipc_connection_t *cbm_daemon_ipc_connect(const cbm_daemon_ipc_endpoint_t *endpoint, + uint32_t timeout_ms); +void cbm_daemon_ipc_connection_close(cbm_daemon_ipc_connection_t *connection); + +/* Interrupt pending I/O without freeing the connection object. This is + * idempotent and may be called from a supervisor thread; the connection's + * owning worker must still call connection_close after its I/O unwinds. */ +void cbm_daemon_ipc_connection_interrupt(cbm_daemon_ipc_connection_t *connection); + +/* Kernel-reported peer process ID for this established socket/pipe. Returns + * zero when the platform cannot provide an authenticated peer PID. The daemon + * uses this as the anchor for executable identity verification; protocol + * payloads are never trusted to declare their own process identity. */ +uint64_t cbm_daemon_ipc_connection_peer_pid( + const cbm_daemon_ipc_connection_t *connection); + +/* send_frame uses a bounded internal write timeout. receive_frame returns a + * malloc-owned payload (NULL for an empty frame) through payload_out. */ +bool cbm_daemon_ipc_send_frame(cbm_daemon_ipc_connection_t *connection, + cbm_daemon_frame_type_t type, uint16_t flags, const void *payload, + uint32_t length); +int cbm_daemon_ipc_receive_frame(cbm_daemon_ipc_connection_t *connection, uint32_t timeout_ms, + cbm_daemon_frame_t *frame_out, uint8_t **payload_out); + +/* Nonblocking single-winner current-generation startup serialization. POSIX + * retains startup-v2 EX plus a SH claim on the frozen legacy startup file. + * Windows retains startup-v2 EX plus the validated frozen legacy mutex, but + * does not publish a generation or sentinel until prepare_handoff. This makes + * activation/install probes non-mutating. Returns 1 when acquired, 0 on a + * competing transition/legacy owner, and -1 on an unsafe or I/O state. */ +int cbm_daemon_ipc_startup_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_startup_lock_t **lock_out); +/* Activation-only no-spawn generation probe under the exact matching, + * retained, unprepared startup lock. It ignores the caller's own startup-v2 + * and frozen-legacy startup claims, and reports only a live daemon lifetime, + * stable current transport, or deterministic legacy/current sentinel. + * Returns 1 when active, 0 when authoritatively absent, and -1 when the lock, + * endpoint, transport, or ownership cannot be validated. */ +int cbm_daemon_ipc_generation_probe_under_startup_lock( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_startup_lock_t *startup_lock); +/* Prepare a serialized daemon handoff. POSIX validates/removes only a + * provably current-generation stale socket identity while retaining both + * startup-v2 EX and frozen-legacy SH. Windows creates/joins the authenticated + * current-participant group, publishes the generation, then releases only the + * legacy mutex. Both platforms retain startup-v2 and a temporary participant + * claim through child readiness. */ +bool cbm_daemon_ipc_startup_lock_prepare_handoff( + cbm_daemon_ipc_startup_lock_t *lock); +/* Retry-safe teardown. Success consumes and clears *lock_io. A native cleanup + * failure returns false and retains every unreleased claim in *lock_io; the + * caller must retry within a finite deadline or fail-stop its process. */ +bool cbm_daemon_ipc_startup_lock_release( + cbm_daemon_ipc_startup_lock_t **lock_io); + +/* Join the current participant group while a daemon bootstrap parent retains + * its prepared startup lock. The child takes no startup-v2 claim of its own; + * it joins the parent's authenticated legacy-compatible group and retains the + * returned guard for its complete daemon lifetime. The endpoint must outlive + * the guard. Returns 1 when joined, 0 when no prepared group exists or a + * legacy transition won, and -1 on an unsafe/I/O state. Release is retry-safe + * where the platform exposes retryable teardown and consumes *guard_io only + * after the participant claim is completely gone. */ +int cbm_daemon_ipc_participant_guard_try_join( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t **guard_out); +bool cbm_daemon_ipc_participant_guard_release( + cbm_daemon_ipc_participant_guard_t **guard_io); + +/* Standalone CLI work joins the legacy-compatible current participant group + * without becoming a daemon client. Acquisition retains startup-v2 only for + * the seal/presence decision. Sealing also retains a participant claim: + * frozen-legacy SH on POSIX, or group-v1 SH plus one non-serving deterministic + * legacy-pipe sentinel on Windows. After the transition-aware presence check, + * begin_work commits the decision by releasing startup-v2 while keeping the + * participant claim through local work. Multiple local participants and a + * same-build modern daemon may then overlap, while a pre-cohort startup stays + * excluded. POSIX sealing also removes only a provably current-generation + * crash-left socket identity. Tri-state operations return 1 on success, 0 + * when another transition or legacy/unknown generation won, and -1 on an + * unsafe/I/O state. The endpoint must outlive the transition. */ +int cbm_daemon_ipc_local_transition_try_acquire( + const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_local_transition_t **transition_out); +int cbm_daemon_ipc_local_transition_seal_legacy( + cbm_daemon_ipc_local_transition_t *transition); +/* Observe daemon lifetime only through a successfully sealed, matching, + * retained transition. Returns 1 while a daemon lifetime is active, 0 when + * its absence is authoritative under the transition, and -1 when the guard, + * endpoint, or underlying primitive cannot be verified. */ +int cbm_daemon_ipc_local_transition_lifetime_probe( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_local_transition_t *transition); +/* Commit the classified transition and begin standalone work. This releases + * startup-v2 but retains the participant claim. It is valid exactly once after + * sealing/presence classification. */ +bool cbm_daemon_ipc_local_transition_begin_work( + cbm_daemon_ipc_local_transition_t *transition); +/* Release ends protection after the caller has stopped all local work. On + * Windows teardown reacquires startup-v2 and the legacy mutex, releases group + * SH, closes this participant's sentinel last, then releases mutex and v2. + * Success consumes and clears *transition_io; a retryable native cleanup + * failure returns false and retains it. */ +bool cbm_daemon_ipc_local_transition_release( + cbm_daemon_ipc_local_transition_t **transition_io); + +#endif /* CBM_DAEMON_IPC_H */ diff --git a/src/daemon/ipc_internal.h b/src/daemon/ipc_internal.h new file mode 100644 index 000000000..96d398bed --- /dev/null +++ b/src/daemon/ipc_internal.h @@ -0,0 +1,103 @@ +/* + * ipc_internal.h — Private, platform-neutral seams used by daemon IPC tests. + */ +#ifndef CBM_DAEMON_IPC_INTERNAL_H +#define CBM_DAEMON_IPC_INTERNAL_H + +#include "daemon/ipc.h" + +#include +#include +#include + +/* Windows daemon rendezvous addresses are generation-specific and + * unguessable. These platform-neutral seams keep the SID/nonce derivation and + * fixed record parser covered on every CI host; the Windows endpoint + * constructor supplies the current token's binary SID and the startup winner + * supplies a BCryptGenRandom nonce. */ +#define CBM_DAEMON_IPC_WINDOWS_NAME_CAP 256U +#define CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE 32U +#define CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE \ + (8U + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE + \ + CBM_DAEMON_IPC_WINDOWS_NAME_CAP) +#define CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE "cbm-rendezvous.lock" + +bool cbm_daemon_ipc_windows_generation_address( + const uint8_t *sid, size_t sid_length, const char *instance_key, + const uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE], + char address_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]); + +/* Exact pre-cohort Windows namespace retained only for migration safety. New + * generations never publish the deterministic pipe and never treat either + * object as current authority; startup/lifetime may hold the old startup mutex + * solely as a compatibility guard against an overlapping pre-cohort process. */ +bool cbm_daemon_ipc_windows_legacy_names( + const char *canonical_runtime_parent, const char *instance_key, + char pipe_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP], + char startup_mutex_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]); + +bool cbm_daemon_ipc_windows_rendezvous_record_encode( + const uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE], + const char *address, + uint8_t record_out[CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE]); +bool cbm_daemon_ipc_windows_rendezvous_record_decode( + const uint8_t *record, size_t record_length, + uint8_t nonce_out[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE], + char address_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]); + +typedef enum { + CBM_IPC_PENDING_WAIT_FAILED = -1, + CBM_IPC_PENDING_WAIT_TIMEOUT = 0, + CBM_IPC_PENDING_WAIT_SIGNALED = 1, +} cbm_ipc_pending_wait_status_t; + +typedef enum { + CBM_IPC_PENDING_FINISH_FAILED = -1, + CBM_IPC_PENDING_FINISH_CANCELLED = 0, + CBM_IPC_PENDING_FINISH_COMPLETED = 1, +} cbm_ipc_pending_finish_status_t; + +typedef struct { + void *context; + cbm_ipc_pending_wait_status_t (*wait)(void *context, uint32_t timeout_ms); + void (*cancel)(void *context); + cbm_ipc_pending_finish_status_t (*finish)(void *context, bool blocking, + uint32_t *transferred_out); +} cbm_ipc_pending_ops_t; + +/* Returns 1 for a completed transfer, 0 for a cancelled timeout, and -1 for + * an error. Every pending operation is terminal before this function returns. */ +int cbm_daemon_ipc_wait_pending(const cbm_ipc_pending_ops_t *ops, uint32_t timeout_ms, + uint32_t *transferred_out); + +/* Internal receive path for fixed-size unauthenticated protocol envelopes. + * Payloads above max_payload_length poison the stream. The implementation must + * reject from the decoded header before allocating or reading that payload. */ +int cbm_daemon_ipc_receive_frame_bounded( + cbm_daemon_ipc_connection_t *connection, uint32_t timeout_ms, + uint32_t max_payload_length, cbm_daemon_frame_t *frame_out, + uint8_t **payload_out); + +/* Narrow crash/fault seams for publication-state and retry-state tests. They + * are inert unless a test installs a hook/failure count in its own process. */ +typedef enum { + CBM_DAEMON_IPC_POSIX_PUBLICATION_ANCHOR_DURABLE = 1, + CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_DURABLE = 2, + CBM_DAEMON_IPC_POSIX_PUBLICATION_STABLE_DURABLE = 3, + CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_DURABLE = 4, + CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_REMOVED = 5, + CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_TEMP_SYNCED = 6, + CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_RECORD_LINKED = 7, + CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_TEMP_SYNCED = 8, + CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_RECORD_LINKED = 9, +} cbm_daemon_ipc_posix_publication_stage_t; + +typedef void (*cbm_daemon_ipc_posix_publication_hook_fn)( + cbm_daemon_ipc_posix_publication_stage_t stage, void *context); + +void cbm_daemon_ipc_posix_publication_hook_set_for_test( + cbm_daemon_ipc_posix_publication_hook_fn hook, void *context); +void cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test( + unsigned int count); + +#endif /* CBM_DAEMON_IPC_INTERNAL_H */ diff --git a/src/daemon/project_lock.c b/src/daemon/project_lock.c new file mode 100644 index 000000000..1df76e00d --- /dev/null +++ b/src/daemon/project_lock.c @@ -0,0 +1,202 @@ +/* project_lock.c — Shared daemon/local-CLI project mutation leases. */ +#include "daemon/project_lock.h" + +#include +#include +#include +#include + +enum { PROJECT_LOCK_KEY_CAP = 4096 }; + +static const char PROJECT_SET_KEY[] = "cbm-project-set-v1"; + +struct cbm_project_lock_manager { + cbm_private_lock_directory_t *directory; + cbm_lock_registry_t *registry; +}; + +struct cbm_project_lock_lease { + cbm_lock_lease_t *project; + cbm_lock_lease_t *project_set; +}; + +static bool project_lock_key(const char *project, char out[PROJECT_LOCK_KEY_CAP]) { + if (!project || !project[0] || strcmp(project, "*") == 0) { + return false; + } + static const char prefix[] = "cbm-project-v1:"; + size_t prefix_length = sizeof(prefix) - 1U; + size_t project_length = strnlen(project, PROJECT_LOCK_KEY_CAP); + if (project_length == 0 || project_length >= PROJECT_LOCK_KEY_CAP || + prefix_length + project_length >= PROJECT_LOCK_KEY_CAP) { + return false; + } + memcpy(out, prefix, prefix_length); + for (size_t index = 0; index < project_length; index++) { + unsigned char ch = (unsigned char)project[index]; + out[prefix_length + index] = + (char)(ch >= 'A' && ch <= 'Z' ? ch + ('a' - 'A') : ch); + } + out[prefix_length + project_length] = '\0'; + return true; +} + +cbm_project_lock_manager_t *cbm_project_lock_manager_new( + const cbm_daemon_ipc_endpoint_t *endpoint) { + cbm_private_lock_directory_t *directory = NULL; + cbm_private_file_lock_status_t directory_status = + cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory); + if (directory_status != CBM_PRIVATE_FILE_LOCK_OK || !directory) { + if (directory) { + cbm_private_lock_directory_close(directory); + } + return NULL; + } + cbm_project_lock_manager_t *manager = calloc(1, sizeof(*manager)); + if (manager) { + manager->directory = directory; + manager->registry = cbm_lock_registry_new(directory); + } + if (!manager || !manager->registry) { + free(manager); + cbm_private_lock_directory_close(directory); + return NULL; + } + return manager; +} + +cbm_private_file_lock_status_t cbm_project_lock_lease_release( + cbm_project_lock_lease_t **lease_io) { + if (!lease_io || !*lease_io) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_project_lock_lease_t *lease = *lease_io; + cbm_private_file_lock_status_t result = CBM_PRIVATE_FILE_LOCK_OK; + if (lease->project) { + result = cbm_lock_lease_release(&lease->project); + if (lease->project) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + } + if (lease->project_set) { + cbm_private_file_lock_status_t set_status = + cbm_lock_lease_release(&lease->project_set); + if (set_status != CBM_PRIVATE_FILE_LOCK_OK) { + result = set_status; + } + if (lease->project_set) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + } + free(lease); + *lease_io = NULL; + return result; +} + +static cbm_private_file_lock_status_t project_lock_failed_acquire( + cbm_project_lock_lease_t *lease, cbm_private_file_lock_status_t status, + cbm_project_lock_lease_t **lease_out) { + if (!lease->project && !lease->project_set) { + free(lease); + return status; + } + cbm_project_lock_lease_t *cleanup = lease; + cbm_private_file_lock_status_t cleanup_status = + cbm_project_lock_lease_release(&cleanup); + if (cleanup) { + *lease_out = cleanup; + return CBM_PRIVATE_FILE_LOCK_IO; + } + return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK ? status + : CBM_PRIVATE_FILE_LOCK_IO; +} + +static cbm_private_file_lock_status_t project_lock_acquire_internal( + cbm_project_lock_manager_t *manager, const char *project, + uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, + bool try_once, cbm_project_lock_lease_t **lease_out) { + if (lease_out) { + *lease_out = NULL; + } + if (!manager || !manager->registry || !project || !project[0] || !lease_out) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + bool wildcard = strcmp(project, "*") == 0; + char project_key[PROJECT_LOCK_KEY_CAP]; + if (!wildcard && !project_lock_key(project, project_key)) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + cbm_project_lock_lease_t *lease = calloc(1, sizeof(*lease)); + if (!lease) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_private_file_lock_status_t status = + try_once + ? cbm_lock_registry_try_acquire( + manager->registry, PROJECT_SET_KEY, + wildcard ? CBM_PRIVATE_FILE_LOCK_EX + : CBM_PRIVATE_FILE_LOCK_SH, + &lease->project_set) + : cbm_lock_registry_acquire( + manager->registry, PROJECT_SET_KEY, + wildcard ? CBM_PRIVATE_FILE_LOCK_EX + : CBM_PRIVATE_FILE_LOCK_SH, + deadline_ms, cancel_token, &lease->project_set); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + return project_lock_failed_acquire(lease, status, lease_out); + } + if (!wildcard) { + status = try_once + ? cbm_lock_registry_try_acquire( + manager->registry, project_key, + CBM_PRIVATE_FILE_LOCK_EX, &lease->project) + : cbm_lock_registry_acquire( + manager->registry, project_key, + CBM_PRIVATE_FILE_LOCK_EX, deadline_ms, + cancel_token, &lease->project); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + return project_lock_failed_acquire(lease, status, lease_out); + } + } + *lease_out = lease; + return CBM_PRIVATE_FILE_LOCK_OK; +} + +cbm_private_file_lock_status_t cbm_project_lock_acquire( + cbm_project_lock_manager_t *manager, const char *project, + uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, + cbm_project_lock_lease_t **lease_out) { + return project_lock_acquire_internal(manager, project, deadline_ms, + cancel_token, false, lease_out); +} + +cbm_private_file_lock_status_t cbm_project_lock_try_acquire( + cbm_project_lock_manager_t *manager, const char *project, + cbm_project_lock_lease_t **lease_out) { + return project_lock_acquire_internal(manager, project, UINT64_MAX, NULL, + true, lease_out); +} + +cbm_private_file_lock_status_t cbm_project_lock_request_cancel( + cbm_project_lock_manager_t *manager, cbm_lock_cancel_token_t *token) { + return manager ? cbm_lock_registry_request_cancel(manager->registry, token) + : CBM_PRIVATE_FILE_LOCK_IO; +} + +cbm_private_file_lock_status_t cbm_project_lock_manager_free( + cbm_project_lock_manager_t **manager_io) { + if (!manager_io || !*manager_io) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_project_lock_manager_t *manager = *manager_io; + cbm_private_file_lock_status_t status = + cbm_lock_registry_free(&manager->registry); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + return status; + } + cbm_private_lock_directory_close(manager->directory); + manager->directory = NULL; + free(manager); + *manager_io = NULL; + return CBM_PRIVATE_FILE_LOCK_OK; +} diff --git a/src/daemon/project_lock.h b/src/daemon/project_lock.h new file mode 100644 index 000000000..122e1f7ea --- /dev/null +++ b/src/daemon/project_lock.h @@ -0,0 +1,42 @@ +/* project_lock.h — Shared daemon/local-CLI project mutation leases. */ +#ifndef CBM_DAEMON_PROJECT_LOCK_H +#define CBM_DAEMON_PROJECT_LOCK_H + +#include "daemon/ipc.h" +#include "foundation/lock_registry.h" + +#include + +typedef struct cbm_project_lock_manager cbm_project_lock_manager_t; +typedef struct cbm_project_lock_lease cbm_project_lock_lease_t; + +/* Each manager is an independent process-local registry over the endpoint's + * owner-only runtime directory. Separate CBM processes therefore coordinate + * through the same native lock files without sharing memory. */ +cbm_project_lock_manager_t *cbm_project_lock_manager_new( + const cbm_daemon_ipc_endpoint_t *endpoint); + +/* Normal projects hold SH(project-set) + EX(project). "*" holds + * EX(project-set), blocking every named project. Project lock keys are ASCII + * case-folded to cover filename aliases on case-insensitive filesystems. */ +cbm_private_file_lock_status_t cbm_project_lock_acquire( + cbm_project_lock_manager_t *manager, const char *project, + uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, + cbm_project_lock_lease_t **lease_out); + +/* One fair, nonblocking attempt for UI/watcher paths. */ +cbm_private_file_lock_status_t cbm_project_lock_try_acquire( + cbm_project_lock_manager_t *manager, const char *project, + cbm_project_lock_lease_t **lease_out); + +cbm_private_file_lock_status_t cbm_project_lock_lease_release( + cbm_project_lock_lease_t **lease_io); + +cbm_private_file_lock_status_t cbm_project_lock_request_cancel( + cbm_project_lock_manager_t *manager, cbm_lock_cancel_token_t *token); + +/* Refuses teardown while any lease/cleanup state remains. */ +cbm_private_file_lock_status_t cbm_project_lock_manager_free( + cbm_project_lock_manager_t **manager_io); + +#endif /* CBM_DAEMON_PROJECT_LOCK_H */ diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c new file mode 100644 index 000000000..269f7b68d --- /dev/null +++ b/src/daemon/runtime.c @@ -0,0 +1,3001 @@ +/* + * runtime.c — Mandatory per-account daemon service over authenticated IPC. + */ +#include "daemon/runtime.h" + +#include "daemon/ipc_internal.h" +#include "daemon/service_internal.h" + +#include "foundation/compat.h" +#include "foundation/compat_thread.h" +#include "foundation/log.h" +#include "foundation/platform.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#elif defined(__APPLE__) +#include +#include +#include +#include +#include +#include +#elif defined(__linux__) +#include +#include +#include +#endif + +enum { + RUNTIME_MAX_CLIENTS_HARD = 64, + RUNTIME_ACCEPT_POLL_MS = 20, + RUNTIME_WAIT_POLL_NS = 1000000, + RUNTIME_WORKER_STACK_SIZE = 256 * 1024, + RUNTIME_PATH_CAP = 4096, + + RENDEZVOUS_REQUEST_ABI_OFFSET = 0, + RENDEZVOUS_REQUEST_VERSION_OFFSET = 4, + RENDEZVOUS_REQUEST_BUILD_OFFSET = + RENDEZVOUS_REQUEST_VERSION_OFFSET + + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + + RENDEZVOUS_RESPONSE_CONNECT_STATUS_OFFSET = 0, + RENDEZVOUS_RESPONSE_HELLO_STATUS_OFFSET = 4, + RENDEZVOUS_RESPONSE_CLIENT_ID_OFFSET = 8, + RENDEZVOUS_RESPONSE_PROCESS_ID_OFFSET = 16, + RENDEZVOUS_RESPONSE_CONFLICT_STATUS_OFFSET = 24, + RENDEZVOUS_RESPONSE_ACTIVE_VERSION_OFFSET = 28, + RENDEZVOUS_RESPONSE_ACTIVE_BUILD_OFFSET = + RENDEZVOUS_RESPONSE_ACTIVE_VERSION_OFFSET + + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + RENDEZVOUS_RESPONSE_REQUESTED_VERSION_OFFSET = + RENDEZVOUS_RESPONSE_ACTIVE_BUILD_OFFSET + + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + RENDEZVOUS_RESPONSE_REQUESTED_BUILD_OFFSET = + RENDEZVOUS_RESPONSE_REQUESTED_VERSION_OFFSET + + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + RENDEZVOUS_RESPONSE_MESSAGE_OFFSET = + RENDEZVOUS_RESPONSE_REQUESTED_BUILD_OFFSET + + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + + ACTIVATION_REQUEST_ACTION_OFFSET = 0, + ACTIVATION_REQUEST_IDENTITY_OFFSET = 4, + ACTIVATION_RESPONSE_ABI_OFFSET = 0, + ACTIVATION_RESPONSE_ACCEPTED_OFFSET = 4, + ACTIVATION_RESPONSE_CLIENTS_OFFSET = 8, + ACTIVATION_RESPONSE_CONNECTIONS_OFFSET = 16, + + STATUS_RESPONSE_SIZE = 4, + SUBSCRIBE_REQUEST_PREFIX_SIZE = 4, + SUBSCRIBE_RESPONSE_SIZE = 12, + UNSUBSCRIBE_REQUEST_SIZE = 8, + APPLICATION_CANCEL_REQUEST_SIZE = 8, + APPLICATION_REQUEST_PREFIX_SIZE = 12, + APPLICATION_RESPONSE_PREFIX_SIZE = 16, +}; + +_Static_assert(RENDEZVOUS_REQUEST_BUILD_OFFSET + + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP == + CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE, + "rendezvous request layout changed"); +_Static_assert(RENDEZVOUS_RESPONSE_MESSAGE_OFFSET + + CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP == + CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE, + "rendezvous response layout changed"); +_Static_assert(ACTIVATION_REQUEST_IDENTITY_OFFSET + + CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE == + CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE && + ACTIVATION_RESPONSE_CONNECTIONS_OFFSET + 8 == + CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE, + "activation shutdown layout changed"); +_Static_assert(CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP == + CBM_DAEMON_VERSION_TEXT_SIZE, + "service version capacity requires a new rendezvous strategy"); +_Static_assert(CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP == + CBM_DAEMON_BUILD_FINGERPRINT_SIZE, + "service fingerprint capacity requires a new rendezvous strategy"); +_Static_assert(CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP == + CBM_DAEMON_CONFLICT_MESSAGE_SIZE, + "service message capacity requires a new rendezvous strategy"); +_Static_assert(CBM_DAEMON_RUNTIME_CONNECT_ERROR == 0 && + CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED == 1 && + CBM_DAEMON_RUNTIME_CONNECT_CONFLICT == 2 && + CBM_DAEMON_RUNTIME_CONNECT_REJECTED == 3, + "rendezvous connect status values changed"); +_Static_assert(CBM_DAEMON_HELLO_INVALID == 0 && + CBM_DAEMON_HELLO_COMPATIBLE == 1 && + CBM_DAEMON_HELLO_VERSION_CONFLICT == 2 && + CBM_DAEMON_HELLO_BUILD_CONFLICT == 3 && + CBM_DAEMON_HELLO_PROTOCOL_ABI_CONFLICT == 4 && + CBM_DAEMON_HELLO_STORE_ABI_CONFLICT == 5 && + CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT == 6, + "rendezvous hello status values changed"); +_Static_assert(CBM_DAEMON_RENDEZVOUS_FRAME_VERSION == 1 && + CBM_DAEMON_RENDEZVOUS_ABI == 1 && + CBM_DAEMON_FRAME_REQUEST == 1 && + CBM_DAEMON_FRAME_RESPONSE == 2 && + CBM_DAEMON_RUNTIME_OP_HELLO == 1 && + CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN == 8 && + CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL == 1 && + CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE == 2 && + CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL == 3, + "rendezvous framing values changed"); + +typedef struct cbm_daemon_runtime_worker cbm_daemon_runtime_worker_t; + +/* The service keeps the exact native image object whose bytes were hashed at + * startup. HELLO can then prove that a peer maps this same, still-stable file + * object without reading the executable again. A different file identity is + * not a rejection by itself: identical copies remain supported through the + * full fingerprint fallback. */ +typedef struct { + bool held; +#ifdef _WIN32 + HANDLE file; + BY_HANDLE_FILE_INFORMATION information; + LARGE_INTEGER size; +#elif defined(__APPLE__) || defined(__linux__) + int fd; + struct stat status; +#endif +} runtime_process_image_reference_t; + +struct cbm_daemon_runtime_service { + cbm_mutex_t mutex; + cbm_daemon_ipc_listener_t *listener; + cbm_daemon_coordinator_t *coordinator; + cbm_daemon_runtime_worker_t *workers; + size_t worker_capacity; + size_t worker_mutexes_initialized; + size_t active_connections; + size_t committed_clients; + + cbm_thread_t accept_thread; + bool accept_thread_started; + bool accept_thread_joined; + atomic_bool accept_thread_done; + + cbm_daemon_runtime_service_state_t state; + uint64_t stop_deadline_ms; + bool emergency_stop; + bool activation_shutdown_requested; + bool activation_response_inflight; + + char semantic_version[CBM_DAEMON_VERSION_TEXT_SIZE]; + char build_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + cbm_daemon_build_identity_t identity; + runtime_process_image_reference_t active_image; + char *conflict_log_path; + size_t conflict_log_cap_bytes; + uint64_t lease_timeout_ms; + uint32_t request_timeout_ms; + uint32_t shutdown_timeout_ms; + cbm_daemon_runtime_application_callbacks_t application; + /* Owned only by the convenience start() path. start_reserved() callers + * retain their externally managed participant guard. */ + cbm_daemon_ipc_participant_guard_t *owned_participant_guard; +}; + +struct cbm_daemon_runtime_worker { + cbm_daemon_runtime_service_t *service; + cbm_mutex_t send_mutex; + cbm_thread_t thread; + bool thread_started; + bool in_use; + bool joining; + atomic_bool done; + cbm_daemon_ipc_connection_t *connection; + cbm_daemon_client_id_t client_id; + uint64_t peer_process_id; + bool admitted; + bool admission_committed; + bool final_response_inflight; + + cbm_daemon_runtime_application_session_t *application_session; + bool application_session_opened; + bool application_cancelled; + atomic_bool disconnecting; + + cbm_thread_t application_thread; + bool application_thread_started; + atomic_bool application_thread_done; + cbm_daemon_runtime_application_token_t application_request_token; + cbm_daemon_runtime_application_token_t last_application_request_token; + uint8_t *application_request; + uint32_t application_request_length; +}; + +struct cbm_daemon_runtime_client { + cbm_mutex_t exchange_mutex; + cbm_mutex_t send_mutex; + cbm_mutex_t state_mutex; + cbm_daemon_ipc_connection_t *connection; + cbm_daemon_client_id_t client_id; + uint64_t authenticated_process_id; + bool usable; + bool exchange_active; + bool closing; + bool close_interrupted_exchange; + cbm_daemon_runtime_application_token_t next_application_token; + cbm_daemon_runtime_application_token_t last_started_application_token; + cbm_daemon_runtime_application_token_t active_application_token; + cbm_daemon_runtime_application_token_t pending_cancel_token; + bool application_request_sent; + bool application_cancel_sent; +}; + +static uint64_t runtime_deadline_after(uint32_t timeout_ms) { + uint64_t now_ms = cbm_now_ms(); + if (UINT64_MAX - now_ms < (uint64_t)timeout_ms) { + return UINT64_MAX; + } + return now_ms + (uint64_t)timeout_ms; +} + +static void runtime_wait_tick(uint64_t deadline_ms) { + uint64_t now_ms = cbm_now_ms(); + if (now_ms >= deadline_ms) { + return; + } + uint64_t remaining_ms = deadline_ms - now_ms; + struct timespec pause = { + .tv_sec = 0, + .tv_nsec = remaining_ms > 1 ? RUNTIME_WAIT_POLL_NS + : (long)(remaining_ms * 1000000ULL), + }; + (void)cbm_nanosleep(&pause, NULL); +} + +static void runtime_put_u32(uint8_t *out, uint32_t value) { + out[0] = (uint8_t)(value >> 24); + out[1] = (uint8_t)(value >> 16); + out[2] = (uint8_t)(value >> 8); + out[3] = (uint8_t)value; +} + +static uint32_t runtime_get_u32(const uint8_t *in) { + return ((uint32_t)in[0] << 24) | ((uint32_t)in[1] << 16) | + ((uint32_t)in[2] << 8) | (uint32_t)in[3]; +} + +static void runtime_put_u64(uint8_t *out, uint64_t value) { + for (size_t i = 0; i < 8; i++) { + out[i] = (uint8_t)(value >> (56 - i * 8)); + } +} + +static uint64_t runtime_get_u64(const uint8_t *in) { + uint64_t value = 0; + for (size_t i = 0; i < 8; i++) { + value = (value << 8) | in[i]; + } + return value; +} + +static bool runtime_bounded_length(const char *value, size_t capacity, + size_t *length_out) { + if (!value || capacity == 0) { + return false; + } + for (size_t i = 0; i < capacity; i++) { + if (value[i] == '\0') { + if (length_out) { + *length_out = i; + } + return true; + } + } + return false; +} + +static bool runtime_wire_string_encode(uint8_t *out, size_t capacity, + const char *value, bool allow_empty) { + size_t length = 0; + if (!out || !runtime_bounded_length(value, capacity, &length) || + (!allow_empty && length == 0)) { + return false; + } + memset(out, 0, capacity); + memcpy(out, value, length); + return true; +} + +static bool runtime_wire_string_decode(const uint8_t *in, size_t capacity, char *out, + bool allow_empty) { + if (!in || !out || capacity == 0) { + return false; + } + size_t length = capacity; + for (size_t i = 0; i < capacity; i++) { + if (in[i] == 0) { + length = i; + break; + } + } + if (length == capacity || (!allow_empty && length == 0)) { + return false; + } + for (size_t i = length + 1; i < capacity; i++) { + if (in[i] != 0) { + return false; + } + } + memcpy(out, in, length); + out[length] = '\0'; + return true; +} + +/* Reuse the service layer's canonical printable-version and lowercase-SHA256 + * validation without importing detailed ABI values into rendezvous. Fixed + * nonzero sentinels on both sides make only version/build ordering observable. */ +static cbm_daemon_hello_status_t runtime_rendezvous_compare( + const char *active_version, const char *active_build, + const char *requested_version, const char *requested_build, + cbm_daemon_conflict_t *conflict_out) { + cbm_daemon_build_identity_t active = { + .semantic_version = active_version, + .build_fingerprint = active_build, + .protocol_abi = 1, + .store_abi = 1, + .feature_abi = 1, + }; + cbm_daemon_build_identity_t requested = { + .semantic_version = requested_version, + .build_fingerprint = requested_build, + .protocol_abi = 1, + .store_abi = 1, + .feature_abi = 1, + }; + cbm_daemon_hello_status_t status = + cbm_daemon_hello_compare(&active, &requested, conflict_out); + if (status != CBM_DAEMON_HELLO_INVALID && + status != CBM_DAEMON_HELLO_COMPATIBLE && + status != CBM_DAEMON_HELLO_VERSION_CONFLICT && + status != CBM_DAEMON_HELLO_BUILD_CONFLICT) { + if (conflict_out) { + memset(conflict_out, 0, sizeof(*conflict_out)); + conflict_out->status = CBM_DAEMON_HELLO_INVALID; + } + return CBM_DAEMON_HELLO_INVALID; + } + return status; +} + +bool cbm_daemon_runtime_hello_request_encode( + uint8_t out[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE], + const cbm_daemon_build_identity_t *identity) { + if (!out || !identity) { + return false; + } + cbm_daemon_conflict_t validation; + if (runtime_rendezvous_compare( + identity->semantic_version, identity->build_fingerprint, + identity->semantic_version, identity->build_fingerprint, + &validation) != CBM_DAEMON_HELLO_COMPATIBLE) { + return false; + } + memset(out, 0, CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE); + runtime_put_u32(out + RENDEZVOUS_REQUEST_ABI_OFFSET, + CBM_DAEMON_RENDEZVOUS_ABI); + return runtime_wire_string_encode( + out + RENDEZVOUS_REQUEST_VERSION_OFFSET, + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + identity->semantic_version, false) && + runtime_wire_string_encode( + out + RENDEZVOUS_REQUEST_BUILD_OFFSET, + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + identity->build_fingerprint, false); +} + +static bool runtime_rendezvous_request_decode( + const uint8_t payload[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE], + char version[CBM_DAEMON_VERSION_TEXT_SIZE], + char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!payload || !version || !build || + runtime_get_u32(payload + RENDEZVOUS_REQUEST_ABI_OFFSET) != + CBM_DAEMON_RENDEZVOUS_ABI || + !runtime_wire_string_decode( + payload + RENDEZVOUS_REQUEST_VERSION_OFFSET, + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, version, false) || + !runtime_wire_string_decode( + payload + RENDEZVOUS_REQUEST_BUILD_OFFSET, + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, build, false)) { + return false; + } + cbm_daemon_conflict_t validation; + return runtime_rendezvous_compare(version, build, version, build, + &validation) == + CBM_DAEMON_HELLO_COMPATIBLE; +} + +static bool runtime_activation_action_valid( + cbm_daemon_runtime_activation_action_t action) { + return action == CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL || + action == CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE || + action == CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL; +} + +static const char *runtime_activation_action_text( + cbm_daemon_runtime_activation_action_t action) { + switch (action) { + case CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL: + return "install"; + case CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE: + return "update"; + case CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL: + return "uninstall"; + default: + return "invalid"; + } +} + +static bool runtime_activation_request_encode( + uint8_t out[CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE], + cbm_daemon_runtime_activation_action_t action, + const cbm_daemon_build_identity_t *identity) { + if (!out || !identity || !runtime_activation_action_valid(action)) { + return false; + } + memset(out, 0, CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE); + runtime_put_u32(out + ACTIVATION_REQUEST_ACTION_OFFSET, + (uint32_t)action); + return cbm_daemon_runtime_hello_request_encode( + out + ACTIVATION_REQUEST_IDENTITY_OFFSET, identity); +} + +static bool runtime_activation_request_decode( + const uint8_t payload[CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE], + cbm_daemon_runtime_activation_action_t *action_out, + char version[CBM_DAEMON_VERSION_TEXT_SIZE], + char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!payload || !action_out || !version || !build) { + return false; + } + uint32_t raw_action = runtime_get_u32( + payload + ACTIVATION_REQUEST_ACTION_OFFSET); + if (raw_action < (uint32_t)CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL || + raw_action > (uint32_t)CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL || + !runtime_rendezvous_request_decode( + payload + ACTIVATION_REQUEST_IDENTITY_OFFSET, version, build)) { + return false; + } + cbm_daemon_runtime_activation_action_t action = + (cbm_daemon_runtime_activation_action_t)raw_action; + *action_out = action; + return true; +} + +static bool runtime_activation_response_encode( + uint8_t out[CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE], + const cbm_daemon_runtime_activation_result_t *result) { + if (!out || !result) { + return false; + } + memset(out, 0, CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE); + runtime_put_u32(out + ACTIVATION_RESPONSE_ABI_OFFSET, + CBM_DAEMON_RENDEZVOUS_ABI); + runtime_put_u32(out + ACTIVATION_RESPONSE_ACCEPTED_OFFSET, + result->accepted ? 1U : 0U); + runtime_put_u64(out + ACTIVATION_RESPONSE_CLIENTS_OFFSET, + result->active_clients); + runtime_put_u64(out + ACTIVATION_RESPONSE_CONNECTIONS_OFFSET, + result->active_connections); + return true; +} + +static bool runtime_activation_response_decode( + const uint8_t payload[CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE], + cbm_daemon_runtime_activation_result_t *result_out) { + if (!payload || !result_out || + runtime_get_u32(payload + ACTIVATION_RESPONSE_ABI_OFFSET) != + CBM_DAEMON_RENDEZVOUS_ABI) { + return false; + } + uint32_t accepted = + runtime_get_u32(payload + ACTIVATION_RESPONSE_ACCEPTED_OFFSET); + if (accepted > 1U) { + return false; + } + result_out->accepted = accepted == 1U; + result_out->active_clients = + runtime_get_u64(payload + ACTIVATION_RESPONSE_CLIENTS_OFFSET); + result_out->active_connections = + runtime_get_u64(payload + ACTIVATION_RESPONSE_CONNECTIONS_OFFSET); + return true; +} + +static uint64_t runtime_current_process_id(void) { +#ifdef _WIN32 + return (uint64_t)GetCurrentProcessId(); +#elif defined(__APPLE__) || defined(__linux__) + return (uint64_t)getpid(); +#else + return 0; +#endif +} + +static void runtime_process_image_reference_init( + runtime_process_image_reference_t *reference) { + if (!reference) { + return; + } + memset(reference, 0, sizeof(*reference)); +#ifdef _WIN32 + reference->file = INVALID_HANDLE_VALUE; +#elif defined(__APPLE__) || defined(__linux__) + reference->fd = -1; +#endif +} + +static bool runtime_process_image_reference_release( + runtime_process_image_reference_t *reference) { + if (!reference) { + return false; + } + bool ok = true; +#ifdef _WIN32 + if (reference->file != INVALID_HANDLE_VALUE && + !CloseHandle(reference->file)) { + ok = false; + } +#elif defined(__APPLE__) || defined(__linux__) + if (reference->fd >= 0 && close(reference->fd) != 0) { + ok = false; + } +#endif + runtime_process_image_reference_init(reference); + return ok; +} + +#ifdef _WIN32 + +typedef struct { + FILETIME creation_time; + wchar_t path[32768]; +} runtime_windows_process_image_snapshot_t; + +static bool runtime_windows_process_image_snapshot( + HANDLE process, runtime_windows_process_image_snapshot_t *snapshot) { + if (!process || !snapshot) { + return false; + } + memset(snapshot, 0, sizeof(*snapshot)); + FILETIME exit_time; + FILETIME kernel_time; + FILETIME user_time; + DWORD exit_code = 0; + DWORD path_length = (DWORD)(sizeof(snapshot->path) / + sizeof(snapshot->path[0])); + bool ok = GetProcessTimes(process, &snapshot->creation_time, &exit_time, + &kernel_time, &user_time) != 0 && + GetExitCodeProcess(process, &exit_code) != 0 && + exit_code == STILL_ACTIVE && + QueryFullProcessImageNameW(process, 0, snapshot->path, + &path_length) != 0 && + path_length > 0 && + path_length < (DWORD)(sizeof(snapshot->path) / + sizeof(snapshot->path[0])); + if (ok) { + snapshot->path[path_length] = L'\0'; + } + return ok; +} + +static bool runtime_windows_process_image_snapshot_same( + const runtime_windows_process_image_snapshot_t *first, + const runtime_windows_process_image_snapshot_t *second) { + return first && second && + CompareFileTime(&first->creation_time, &second->creation_time) == 0 && + CompareStringOrdinal(first->path, -1, second->path, -1, TRUE) == + CSTR_EQUAL; +} + +static bool runtime_windows_file_snapshot( + HANDLE file, BY_HANDLE_FILE_INFORMATION *information, LARGE_INTEGER *size) { + if (!information || !size) { + return false; + } + memset(information, 0, sizeof(*information)); + size->QuadPart = -1; + return file != INVALID_HANDLE_VALUE && GetFileType(file) == FILE_TYPE_DISK && + GetFileInformationByHandle(file, information) != 0 && + (information->dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + GetFileSizeEx(file, size) != 0 && size->QuadPart >= 0; +} + +static bool runtime_windows_file_snapshot_same( + const BY_HANDLE_FILE_INFORMATION *first_information, + const LARGE_INTEGER *first_size, + const BY_HANDLE_FILE_INFORMATION *second_information, + const LARGE_INTEGER *second_size) { + return first_information && first_size && second_information && second_size && + first_information->dwVolumeSerialNumber == + second_information->dwVolumeSerialNumber && + first_information->nFileIndexHigh == second_information->nFileIndexHigh && + first_information->nFileIndexLow == second_information->nFileIndexLow && + first_size->QuadPart == second_size->QuadPart && + CompareFileTime(&first_information->ftLastWriteTime, + &second_information->ftLastWriteTime) == 0; +} + +#elif defined(__APPLE__) + +static bool runtime_mac_process_instance(int process_id, + struct proc_bsdinfo *info) { + if (!info) { + return false; + } + memset(info, 0, sizeof(*info)); + return proc_pidinfo(process_id, PROC_PIDTBSDINFO, 0, info, + (int)sizeof(*info)) == (int)sizeof(*info) && + info->pbi_pid == (uint32_t)process_id; +} + +static bool runtime_mac_process_instance_same(const struct proc_bsdinfo *first, + const struct proc_bsdinfo *second) { + return first && second && first->pbi_pid == second->pbi_pid && + first->pbi_start_tvsec == second->pbi_start_tvsec && + first->pbi_start_tvusec == second->pbi_start_tvusec; +} + +static bool runtime_mac_stat_matches_vnode( + const struct stat *status, const struct vinfo_stat *vnode) { + return status && vnode && S_ISREG(vnode->vst_mode) && + vnode->vst_dev == (uint32_t)status->st_dev && + vnode->vst_ino == (uint64_t)status->st_ino && + vnode->vst_size == status->st_size && + vnode->vst_mtime == status->st_mtimespec.tv_sec && + vnode->vst_mtimensec == status->st_mtimespec.tv_nsec && + vnode->vst_ctime == status->st_ctimespec.tv_sec && + vnode->vst_ctimensec == status->st_ctimespec.tv_nsec; +} + +static bool runtime_mac_stat_same(const struct stat *first, + const struct stat *second) { + return first && second && first->st_dev == second->st_dev && + first->st_ino == second->st_ino && first->st_size == second->st_size && + first->st_mtimespec.tv_sec == second->st_mtimespec.tv_sec && + first->st_mtimespec.tv_nsec == second->st_mtimespec.tv_nsec && + first->st_ctimespec.tv_sec == second->st_ctimespec.tv_sec && + first->st_ctimespec.tv_nsec == second->st_ctimespec.tv_nsec; +} + +static bool runtime_mac_process_maps_file_executable(int process_id, + const struct stat *status) { + uint64_t address = 0; + for (size_t region_count = 0; region_count < 65536; region_count++) { + struct proc_regionwithpathinfo region; + memset(®ion, 0, sizeof(region)); + int received = proc_pidinfo(process_id, PROC_PIDREGIONPATHINFO, + address, ®ion, (int)sizeof(region)); + if (received != (int)sizeof(region)) { + return false; + } + if ((region.prp_prinfo.pri_protection & VM_PROT_EXECUTE) != 0 && + runtime_mac_stat_matches_vnode( + status, ®ion.prp_vip.vip_vi.vi_stat)) { + return true; + } + uint64_t region_address = region.prp_prinfo.pri_address; + uint64_t region_size = region.prp_prinfo.pri_size; + if (region_size == 0 || region_address > UINT64_MAX - region_size) { + return false; + } + uint64_t next = region_address + region_size; + if (next <= address) { + return false; + } + address = next; + } + return false; +} + +#elif defined(__linux__) + +static bool runtime_linux_stat_same_image(const struct stat *first, + const struct stat *second) { + return first && second && S_ISREG(first->st_mode) && S_ISREG(second->st_mode) && + first->st_dev == second->st_dev && first->st_ino == second->st_ino && + first->st_size == second->st_size && + first->st_mtim.tv_sec == second->st_mtim.tv_sec && + first->st_mtim.tv_nsec == second->st_mtim.tv_nsec && + first->st_ctim.tv_sec == second->st_ctim.tv_sec && + first->st_ctim.tv_nsec == second->st_ctim.tv_nsec; +} + +#endif + +/* Acquire one process instance's mapped image as a native object. Supplying a + * fingerprint hashes that same held object; NULL performs metadata-only + * acquisition for the HELLO fast path. Every platform brackets the process + * identity/image lookup before returning the retained reference. */ +static bool runtime_process_image_reference_acquire( + uint64_t process_id, runtime_process_image_reference_t *reference, + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!reference || process_id == 0) { + return false; + } + runtime_process_image_reference_init(reference); + if (fingerprint) { + fingerprint[0] = '\0'; + } +#ifdef _WIN32 + if (process_id > MAXDWORD) { + return false; + } + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, + (DWORD)process_id); + if (!process) { + return false; + } + runtime_windows_process_image_snapshot_t process_before; + runtime_windows_process_image_snapshot_t process_after; + BY_HANDLE_FILE_INFORMATION file_before; + BY_HANDLE_FILE_INFORMATION file_after; + LARGE_INTEGER size_before; + LARGE_INTEGER size_after; + bool ok = + runtime_windows_process_image_snapshot(process, &process_before); + HANDLE file = ok ? CreateFileW(process_before.path, GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | + FILE_FLAG_SEQUENTIAL_SCAN, + NULL) + : INVALID_HANDLE_VALUE; + ok = ok && runtime_windows_file_snapshot(file, &file_before, &size_before) && + (!fingerprint || cbm_daemon_build_fingerprint_native_file( + (uintptr_t)file, fingerprint)) && + runtime_windows_file_snapshot(file, &file_after, &size_after) && + runtime_windows_file_snapshot_same(&file_before, &size_before, + &file_after, &size_after) && + runtime_windows_process_image_snapshot(process, &process_after) && + runtime_windows_process_image_snapshot_same(&process_before, + &process_after); + if (!CloseHandle(process)) { + ok = false; + } + if (ok) { + reference->held = true; + reference->file = file; + reference->information = file_after; + reference->size = size_after; + } else if (file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(file); + } +#elif defined(__APPLE__) + if (process_id > INT_MAX) { + return false; + } + int pid = (int)process_id; + struct proc_bsdinfo process_before; + struct proc_bsdinfo process_after; + char path[PROC_PIDPATHINFO_MAXSIZE]; + int path_length = 0; + bool ok = runtime_mac_process_instance(pid, &process_before) && + (path_length = proc_pidpath(pid, path, sizeof(path))) > 0 && + path_length < (int)sizeof(path); + if (ok) { + path[path_length] = '\0'; + } + int fd = ok ? open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) + : -1; + struct stat file_before; + struct stat file_after; + ok = ok && fd >= 0 && fstat(fd, &file_before) == 0 && + S_ISREG(file_before.st_mode) && + runtime_mac_process_maps_file_executable(pid, &file_before) && + (!fingerprint || cbm_daemon_build_fingerprint_native_file( + (uintptr_t)fd, fingerprint)) && + fstat(fd, &file_after) == 0 && + runtime_mac_stat_same(&file_before, &file_after) && + runtime_mac_process_maps_file_executable(pid, &file_after) && + runtime_mac_process_instance(pid, &process_after) && + runtime_mac_process_instance_same(&process_before, &process_after); + if (ok) { + reference->held = true; + reference->fd = fd; + reference->status = file_after; + } else if (fd >= 0) { + (void)close(fd); + } +#elif defined(__linux__) + char proc_path[64]; + int written = snprintf(proc_path, sizeof(proc_path), "/proc/%llu", + (unsigned long long)process_id); + if (written <= 0 || written >= (int)sizeof(proc_path)) { + return false; + } + int process_fd = open(proc_path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | + O_NOFOLLOW | O_NONBLOCK); + int image_fd = process_fd >= 0 + ? openat(process_fd, "exe", O_RDONLY | O_CLOEXEC) + : -1; + struct stat image_before; + struct stat image_after; + bool ok = image_fd >= 0 && fstat(image_fd, &image_before) == 0 && + S_ISREG(image_before.st_mode) && + (!fingerprint || cbm_daemon_build_fingerprint_native_file( + (uintptr_t)image_fd, fingerprint)) && + fstat(image_fd, &image_after) == 0 && + runtime_linux_stat_same_image(&image_before, &image_after); + int verify_fd = ok ? openat(process_fd, "exe", O_RDONLY | O_CLOEXEC) : -1; + struct stat verify_status; + ok = ok && verify_fd >= 0 && fstat(verify_fd, &verify_status) == 0 && + runtime_linux_stat_same_image(&image_after, &verify_status); + if (verify_fd >= 0 && close(verify_fd) != 0) { + ok = false; + } + if (process_fd >= 0 && close(process_fd) != 0) { + ok = false; + } + if (ok) { + reference->held = true; + reference->fd = image_fd; + reference->status = image_after; + } else if (image_fd >= 0) { + (void)close(image_fd); + } +#else + (void)process_id; + bool ok = false; +#endif + if (!ok && fingerprint) { + fingerprint[0] = '\0'; + } + return ok; +} + +/* A false result means only "not proven identical". The caller must use the + * full fingerprint fallback before rejecting the peer. */ +static bool runtime_process_image_reference_matches_process( + const runtime_process_image_reference_t *active, uint64_t process_id) { + if (!active || !active->held || process_id == 0) { + return false; + } +#ifdef _WIN32 + runtime_process_image_reference_t peer; + runtime_process_image_reference_init(&peer); + bool same = runtime_process_image_reference_acquire(process_id, &peer, NULL); + BY_HANDLE_FILE_INFORMATION active_now; + LARGE_INTEGER active_size_now; + same = same && + runtime_windows_file_snapshot(active->file, &active_now, + &active_size_now) && + runtime_windows_file_snapshot_same( + &active->information, &active->size, &active_now, + &active_size_now) && + runtime_windows_file_snapshot_same( + &active->information, &active->size, &peer.information, + &peer.size); + bool released = runtime_process_image_reference_release(&peer); + return same && released; +#elif defined(__APPLE__) + runtime_process_image_reference_t peer; + runtime_process_image_reference_init(&peer); + bool same = runtime_process_image_reference_acquire(process_id, &peer, NULL); + struct stat active_now; + same = same && fstat(active->fd, &active_now) == 0 && + runtime_mac_stat_same(&active->status, &active_now) && + runtime_mac_stat_same(&active->status, &peer.status); + bool released = runtime_process_image_reference_release(&peer); + return same && released; +#elif defined(__linux__) + runtime_process_image_reference_t peer; + runtime_process_image_reference_init(&peer); + bool same = runtime_process_image_reference_acquire(process_id, &peer, NULL); + struct stat active_now; + same = same && fstat(active->fd, &active_now) == 0 && + runtime_linux_stat_same_image(&active->status, &active_now) && + runtime_linux_stat_same_image(&active->status, &peer.status); + bool released = runtime_process_image_reference_release(&peer); + return same && released; +#else + (void)process_id; + return false; +#endif +} + +bool cbm_daemon_runtime_process_build_fingerprint( + uint64_t process_id, char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!out) { + return false; + } + out[0] = '\0'; + runtime_process_image_reference_t reference; + runtime_process_image_reference_init(&reference); + bool ok = runtime_process_image_reference_acquire(process_id, &reference, out); + if (!runtime_process_image_reference_release(&reference)) { + ok = false; + } + if (!ok) { + out[0] = '\0'; + } + return ok; +} + +static bool runtime_hello_response_encode( + uint8_t out[CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE], + const cbm_daemon_runtime_connect_result_t *result) { + if (!out || !result) { + return false; + } + memset(out, 0, CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE); + runtime_put_u32(out + RENDEZVOUS_RESPONSE_CONNECT_STATUS_OFFSET, + (uint32_t)result->status); + runtime_put_u32(out + RENDEZVOUS_RESPONSE_HELLO_STATUS_OFFSET, + (uint32_t)result->hello_status); + runtime_put_u64(out + RENDEZVOUS_RESPONSE_CLIENT_ID_OFFSET, + result->client_id); + runtime_put_u64(out + RENDEZVOUS_RESPONSE_PROCESS_ID_OFFSET, + result->authenticated_process_id); + runtime_put_u32(out + RENDEZVOUS_RESPONSE_CONFLICT_STATUS_OFFSET, + (uint32_t)result->conflict.status); + + if (!runtime_wire_string_encode( + out + RENDEZVOUS_RESPONSE_ACTIVE_VERSION_OFFSET, + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + result->conflict.active_version, true) || + !runtime_wire_string_encode( + out + RENDEZVOUS_RESPONSE_ACTIVE_BUILD_OFFSET, + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + result->conflict.active_build_fingerprint, true) || + !runtime_wire_string_encode( + out + RENDEZVOUS_RESPONSE_REQUESTED_VERSION_OFFSET, + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + result->conflict.requested_version, true) || + !runtime_wire_string_encode( + out + RENDEZVOUS_RESPONSE_REQUESTED_BUILD_OFFSET, + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + result->conflict.requested_build_fingerprint, true) || + !runtime_wire_string_encode( + out + RENDEZVOUS_RESPONSE_MESSAGE_OFFSET, + CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP, result->message, true)) { + return false; + } + return true; +} + +static bool runtime_hello_response_decode( + const uint8_t payload[CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE], + cbm_daemon_runtime_connect_result_t *result) { + if (!payload || !result) { + return false; + } + memset(result, 0, sizeof(*result)); + result->status = + (cbm_daemon_runtime_connect_status_t)runtime_get_u32( + payload + RENDEZVOUS_RESPONSE_CONNECT_STATUS_OFFSET); + result->hello_status = + (cbm_daemon_hello_status_t)runtime_get_u32( + payload + RENDEZVOUS_RESPONSE_HELLO_STATUS_OFFSET); + result->client_id = runtime_get_u64( + payload + RENDEZVOUS_RESPONSE_CLIENT_ID_OFFSET); + result->authenticated_process_id = + runtime_get_u64(payload + RENDEZVOUS_RESPONSE_PROCESS_ID_OFFSET); + result->conflict.status = + (cbm_daemon_hello_status_t)runtime_get_u32( + payload + RENDEZVOUS_RESPONSE_CONFLICT_STATUS_OFFSET); + if (!runtime_wire_string_decode( + payload + RENDEZVOUS_RESPONSE_ACTIVE_VERSION_OFFSET, + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + result->conflict.active_version, true) || + !runtime_wire_string_decode( + payload + RENDEZVOUS_RESPONSE_ACTIVE_BUILD_OFFSET, + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + result->conflict.active_build_fingerprint, true) || + !runtime_wire_string_decode( + payload + RENDEZVOUS_RESPONSE_REQUESTED_VERSION_OFFSET, + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + result->conflict.requested_version, true) || + !runtime_wire_string_decode( + payload + RENDEZVOUS_RESPONSE_REQUESTED_BUILD_OFFSET, + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + result->conflict.requested_build_fingerprint, true) || + !runtime_wire_string_decode( + payload + RENDEZVOUS_RESPONSE_MESSAGE_OFFSET, + CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP, result->message, true)) { + return false; + } + if (result->status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED) { + return result->hello_status == CBM_DAEMON_HELLO_COMPATIBLE && + result->conflict.status == CBM_DAEMON_HELLO_COMPATIBLE && + result->client_id != CBM_DAEMON_CLIENT_ID_INVALID && + result->authenticated_process_id != 0; + } + if (result->status == CBM_DAEMON_RUNTIME_CONNECT_CONFLICT) { + char expected[CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP]; + return result->hello_status >= CBM_DAEMON_HELLO_VERSION_CONFLICT && + result->hello_status <= CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT && + result->conflict.status == result->hello_status && + result->client_id == CBM_DAEMON_CLIENT_ID_INVALID && + result->authenticated_process_id == 0 && + cbm_daemon_conflict_format(&result->conflict, expected, + sizeof(expected)) && + strcmp(result->message, expected) == 0; + } + return result->status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED && + result->hello_status == CBM_DAEMON_HELLO_INVALID && + result->conflict.status == CBM_DAEMON_HELLO_INVALID && + result->client_id == CBM_DAEMON_CLIENT_ID_INVALID && + result->authenticated_process_id == 0; +} + +static bool runtime_send_hello_response( + cbm_daemon_ipc_connection_t *connection, + const cbm_daemon_runtime_connect_result_t *result) { + uint8_t payload[CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE]; + return runtime_hello_response_encode(payload, result) && + cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_RESPONSE, + CBM_DAEMON_RUNTIME_OP_HELLO, payload, + (uint32_t)sizeof(payload)); +} + +static bool runtime_worker_send_frame(cbm_daemon_runtime_worker_t *worker, + cbm_daemon_frame_type_t type, + cbm_daemon_runtime_operation_t operation, + const void *payload, uint32_t length) { + if (!worker) { + return false; + } + cbm_mutex_lock(&worker->send_mutex); + bool sent = worker->connection && + cbm_daemon_ipc_send_frame(worker->connection, type, + (uint16_t)operation, payload, length); + cbm_mutex_unlock(&worker->send_mutex); + return sent; +} + +static bool runtime_worker_send_status(cbm_daemon_runtime_worker_t *worker, + cbm_daemon_runtime_operation_t operation, + bool success) { + uint8_t payload[STATUS_RESPONSE_SIZE]; + runtime_put_u32(payload, success ? 1U : 0U); + return runtime_worker_send_frame(worker, CBM_DAEMON_FRAME_RESPONSE, + operation, payload, + (uint32_t)sizeof(payload)); +} + +static bool runtime_application_status_is_callback_result( + cbm_daemon_runtime_application_status_t status) { + return status == CBM_DAEMON_RUNTIME_APPLICATION_OK || + status == CBM_DAEMON_RUNTIME_APPLICATION_REJECTED || + status == CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR || + status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED; +} + +static bool runtime_worker_send_application_response( + cbm_daemon_runtime_worker_t *worker, + cbm_daemon_runtime_application_token_t request_token, + cbm_daemon_runtime_application_status_t status, + const uint8_t *response, uint32_t response_length, + bool suppress_when_disconnecting) { + if (!worker || + request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID || + status <= CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR || + status > CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED || + response_length > CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX || + (response_length > 0 && !response)) { + return false; + } + uint64_t wire_length = APPLICATION_RESPONSE_PREFIX_SIZE + + (uint64_t)response_length; + if (wire_length > CBM_DAEMON_MAX_FRAME_SIZE || wire_length > UINT32_MAX) { + return false; + } + uint8_t *wire = malloc((size_t)wire_length); + if (!wire) { + return false; + } + runtime_put_u64(wire, request_token); + runtime_put_u32(wire + 8, (uint32_t)status); + runtime_put_u32(wire + 12, response_length); + if (response_length > 0) { + memcpy(wire + APPLICATION_RESPONSE_PREFIX_SIZE, response, + response_length); + } + + cbm_mutex_lock(&worker->send_mutex); + bool disconnected = suppress_when_disconnecting && + atomic_load_explicit(&worker->disconnecting, + memory_order_acquire); + bool sent = !disconnected && worker->connection && + cbm_daemon_ipc_send_frame( + worker->connection, CBM_DAEMON_FRAME_RESPONSE, + CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST, wire, + (uint32_t)wire_length); + cbm_mutex_unlock(&worker->send_mutex); + free(wire); + return sent; +} + +static void runtime_result_rejected(cbm_daemon_runtime_connect_result_t *result, + const char *message) { + memset(result, 0, sizeof(*result)); + result->status = CBM_DAEMON_RUNTIME_CONNECT_REJECTED; + result->hello_status = CBM_DAEMON_HELLO_INVALID; + result->conflict.status = CBM_DAEMON_HELLO_INVALID; + (void)snprintf(result->message, sizeof(result->message), "%s", message); +} + +static void runtime_service_begin_stopping_locked( + cbm_daemon_runtime_service_t *service, uint64_t deadline, bool emergency) { + if (service->state == CBM_DAEMON_RUNTIME_SERVICE_RUNNING) { + service->state = CBM_DAEMON_RUNTIME_SERVICE_STOPPING; + service->stop_deadline_ms = deadline; + } else if (service->state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING && + (service->stop_deadline_ms == 0 || deadline < service->stop_deadline_ms)) { + service->stop_deadline_ms = deadline; + } + if (emergency) { + service->emergency_stop = true; + } +} + +static void runtime_service_begin_stopping(cbm_daemon_runtime_service_t *service, + uint32_t timeout_ms, bool emergency) { + uint64_t deadline = runtime_deadline_after(timeout_ms); + cbm_mutex_lock(&service->mutex); + runtime_service_begin_stopping_locked(service, deadline, emergency); + cbm_mutex_unlock(&service->mutex); +} + +static void runtime_service_interrupt_connections_except( + cbm_daemon_runtime_service_t *service, + cbm_daemon_runtime_worker_t *except_worker, + bool activation_owner) { + uint64_t now_ms = cbm_now_ms(); + cbm_mutex_lock(&service->mutex); + if (service->activation_response_inflight && !activation_owner) { + cbm_mutex_unlock(&service->mutex); + return; + } + bool force = service->emergency_stop || + (service->stop_deadline_ms != 0 && now_ms >= service->stop_deadline_ms); + for (size_t i = 0; i < service->worker_capacity; i++) { + cbm_daemon_runtime_worker_t *worker = &service->workers[i]; + if (worker != except_worker && worker->in_use && worker->connection && + (activation_owner || force || !worker->final_response_inflight)) { + cbm_daemon_ipc_connection_interrupt(worker->connection); + } + } + cbm_mutex_unlock(&service->mutex); +} + +static void runtime_service_interrupt_connections( + cbm_daemon_runtime_service_t *service) { + runtime_service_interrupt_connections_except(service, NULL, false); +} + +static void runtime_worker_disconnect(cbm_daemon_runtime_worker_t *worker) { + cbm_daemon_runtime_service_t *service = worker->service; + cbm_daemon_client_id_t client_id = CBM_DAEMON_CLIENT_ID_INVALID; + uint64_t shutdown_deadline = + runtime_deadline_after(service->shutdown_timeout_ms); + atomic_store_explicit(&worker->disconnecting, true, memory_order_release); + cbm_mutex_lock(&service->mutex); + if (worker->admitted) { + client_id = worker->client_id; + worker->admitted = false; + } + if (worker->admission_committed) { + worker->admission_committed = false; + if (service->committed_clients > 0) { + service->committed_clients--; + } + if (service->committed_clients == 0) { + /* A HELLO whose application session is still opening is only a + * provisional coordinator client. It cannot keep the generation + * alive after the final fully committed frontend disconnects. */ + runtime_service_begin_stopping_locked( + service, shutdown_deadline, false); + } + } + cbm_mutex_unlock(&service->mutex); + if (client_id == CBM_DAEMON_CLIENT_ID_INVALID) { + return; + } + (void)cbm_daemon_client_disconnected(service->coordinator, client_id, cbm_now_ms()); + if (worker->application_session_opened && !worker->application_cancelled) { + worker->application_cancelled = true; + service->application.session_cancel( + service->application.context, worker->application_session); + } + if (cbm_daemon_coordinator_state(service->coordinator) == + CBM_DAEMON_COORDINATOR_STOPPING) { + runtime_service_begin_stopping(service, service->shutdown_timeout_ms, false); + } +} + +static bool runtime_worker_service_running(cbm_daemon_runtime_worker_t *worker) { + cbm_daemon_runtime_service_t *service = worker->service; + cbm_mutex_lock(&service->mutex); + bool running = service->state == CBM_DAEMON_RUNTIME_SERVICE_RUNNING; + cbm_mutex_unlock(&service->mutex); + return running; +} + +static bool runtime_worker_admit(cbm_daemon_runtime_worker_t *worker, + cbm_daemon_client_id_t *client_id_out) { + cbm_daemon_runtime_service_t *service = worker->service; + cbm_daemon_client_id_t client_id = CBM_DAEMON_CLIENT_ID_INVALID; + cbm_mutex_lock(&service->mutex); + if (service->state == CBM_DAEMON_RUNTIME_SERVICE_RUNNING) { + client_id = cbm_daemon_client_connected(service->coordinator, cbm_now_ms()); + if (client_id != CBM_DAEMON_CLIENT_ID_INVALID) { + worker->client_id = client_id; + worker->admitted = true; + worker->admission_committed = false; + } + } + cbm_mutex_unlock(&service->mutex); + *client_id_out = client_id; + return client_id != CBM_DAEMON_CLIENT_ID_INVALID; +} + +static bool runtime_worker_commit_admission( + cbm_daemon_runtime_worker_t *worker) { + cbm_daemon_runtime_service_t *service = worker->service; + cbm_mutex_lock(&service->mutex); + bool committed = service->state == CBM_DAEMON_RUNTIME_SERVICE_RUNNING && + worker->admitted && !worker->admission_committed; + if (committed) { + worker->admission_committed = true; + service->committed_clients++; + } + cbm_mutex_unlock(&service->mutex); + return committed; +} + +static bool runtime_worker_handle_subscribe(cbm_daemon_runtime_worker_t *worker, + const uint8_t *payload, uint32_t length) { + if (!runtime_worker_service_running(worker) || !payload || + length < SUBSCRIBE_REQUEST_PREFIX_SIZE) { + return false; + } + uint32_t key_length = runtime_get_u32(payload); + if (key_length == 0 || key_length > CBM_DAEMON_RUNTIME_PROJECT_KEY_MAX || + length != SUBSCRIBE_REQUEST_PREFIX_SIZE + key_length) { + return false; + } + char project_key[CBM_DAEMON_RUNTIME_PROJECT_KEY_MAX + 1]; + memcpy(project_key, payload + SUBSCRIBE_REQUEST_PREFIX_SIZE, key_length); + project_key[key_length] = '\0'; + if (memchr(project_key, '\0', key_length) != NULL) { + return false; + } + cbm_daemon_subscription_id_t subscription_id = + CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + cbm_daemon_subscription_result_t result = cbm_daemon_job_subscribe( + worker->service->coordinator, worker->client_id, project_key, + &subscription_id); + uint8_t response[SUBSCRIBE_RESPONSE_SIZE]; + runtime_put_u32(response, (uint32_t)result); + runtime_put_u64(response + 4, subscription_id); + return runtime_worker_send_frame(worker, CBM_DAEMON_FRAME_RESPONSE, + CBM_DAEMON_RUNTIME_OP_JOB_SUBSCRIBE, + response, (uint32_t)sizeof(response)); +} + +static bool runtime_worker_handle_unsubscribe(cbm_daemon_runtime_worker_t *worker, + const uint8_t *payload, + uint32_t length) { + if (!runtime_worker_service_running(worker) || !payload || + length != UNSUBSCRIBE_REQUEST_SIZE) { + return false; + } + cbm_daemon_subscription_id_t subscription_id = runtime_get_u64(payload); + bool removed = cbm_daemon_job_unsubscribe(worker->service->coordinator, + worker->client_id, subscription_id); + return runtime_worker_send_status( + worker, CBM_DAEMON_RUNTIME_OP_JOB_UNSUBSCRIBE, removed); +} + +static void *runtime_application_worker(void *opaque) { + cbm_daemon_runtime_worker_t *worker = opaque; + cbm_daemon_runtime_service_t *service = worker->service; + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t status = + service->application.request( + service->application.context, worker->application_session, + worker->application_request_token, + worker->application_request, worker->application_request_length, + &response, &response_length); + + bool valid_status = runtime_application_status_is_callback_result(status); + bool valid_response = + response_length <= CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX && + (response_length == 0 || response != NULL) && + (status == CBM_DAEMON_RUNTIME_APPLICATION_OK || + (response == NULL && response_length == 0)); + if (!valid_status || !valid_response) { + free(response); + response = NULL; + response_length = 0; + status = CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + + bool sent = runtime_worker_send_application_response( + worker, worker->application_request_token, status, response, + response_length, true); + free(response); + if (!sent && !atomic_load_explicit(&worker->disconnecting, + memory_order_acquire)) { + cbm_daemon_ipc_connection_interrupt(worker->connection); + } + atomic_store_explicit(&worker->application_thread_done, true, + memory_order_release); + return NULL; +} + +static bool runtime_worker_reap_application( + cbm_daemon_runtime_worker_t *worker, bool wait) { + if (!worker->application_thread_started) { + return true; + } + if (!wait && + !atomic_load_explicit(&worker->application_thread_done, + memory_order_acquire)) { + return false; + } + if (cbm_thread_join(&worker->application_thread) != 0) { + return false; + } + worker->application_thread_started = false; + free(worker->application_request); + worker->application_request = NULL; + worker->application_request_length = 0; + worker->application_request_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + atomic_store_explicit(&worker->application_thread_done, false, + memory_order_release); + return true; +} + +static bool runtime_worker_handle_application( + cbm_daemon_runtime_worker_t *worker, const uint8_t *payload, + uint32_t length) { + if (!payload || length < APPLICATION_REQUEST_PREFIX_SIZE) { + return false; + } + cbm_daemon_runtime_application_token_t request_token = + runtime_get_u64(payload); + uint32_t request_length = runtime_get_u32(payload + 8); + if (request_length > CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX || + length != APPLICATION_REQUEST_PREFIX_SIZE + request_length || + request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID || + request_token <= worker->last_application_request_token) { + return false; + } + /* A structurally valid token is consumed at admission, including BUSY, + * UNAVAILABLE, and local allocation/thread failures. Reusing a token after + * any terminal response would make late cancellation controls ambiguous. */ + worker->last_application_request_token = request_token; + cbm_daemon_runtime_service_t *service = worker->service; + if (!service->application.request) { + return runtime_worker_send_application_response( + worker, request_token, + CBM_DAEMON_RUNTIME_APPLICATION_UNAVAILABLE, NULL, 0, false); + } + (void)runtime_worker_reap_application(worker, false); + if (worker->application_thread_started) { + return runtime_worker_send_application_response( + worker, request_token, CBM_DAEMON_RUNTIME_APPLICATION_BUSY, NULL, + 0, false); + } + + uint8_t *request_copy = NULL; + if (request_length > 0) { + request_copy = malloc(request_length); + if (!request_copy) { + return runtime_worker_send_application_response( + worker, request_token, + CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR, NULL, 0, false); + } + memcpy(request_copy, payload + APPLICATION_REQUEST_PREFIX_SIZE, + request_length); + } + worker->application_request_token = request_token; + worker->application_request = request_copy; + worker->application_request_length = request_length; + atomic_store_explicit(&worker->application_thread_done, false, + memory_order_release); + if (cbm_thread_create(&worker->application_thread, + RUNTIME_WORKER_STACK_SIZE, + runtime_application_worker, worker) != 0) { + free(worker->application_request); + worker->application_request = NULL; + worker->application_request_length = 0; + return runtime_worker_send_application_response( + worker, request_token, + CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR, NULL, 0, false); + } + worker->application_thread_started = true; + return true; +} + +static bool runtime_worker_handle_application_cancel( + cbm_daemon_runtime_worker_t *worker, const uint8_t *payload, + uint32_t length) { + if (!worker || !payload || length != APPLICATION_CANCEL_REQUEST_SIZE) { + return false; + } + cbm_daemon_runtime_application_token_t request_token = + runtime_get_u64(payload); + if (request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { + return false; + } + if (worker->application_thread_started && + worker->application_request_token == request_token && + !atomic_load_explicit(&worker->application_thread_done, + memory_order_acquire)) { + worker->service->application.request_cancel( + worker->service->application.context, + worker->application_session, request_token); + } + /* Cancellation is deliberately one-way. A stale exact-sized token is a + * harmless no-op and never changes stream response ordering. */ + return true; +} + +static bool runtime_worker_handle_disconnect(cbm_daemon_runtime_worker_t *worker, + uint32_t length) { + if (length != 0) { + return false; + } + cbm_daemon_runtime_service_t *service = worker->service; + cbm_mutex_lock(&service->mutex); + worker->final_response_inflight = true; + cbm_mutex_unlock(&service->mutex); + runtime_worker_disconnect(worker); + bool sent = runtime_worker_send_status( + worker, CBM_DAEMON_RUNTIME_OP_DISCONNECT, true); + cbm_mutex_lock(&service->mutex); + worker->final_response_inflight = false; + cbm_mutex_unlock(&service->mutex); + return sent; +} + +static void runtime_worker_finish(cbm_daemon_runtime_worker_t *worker) { + cbm_daemon_runtime_service_t *service = worker->service; + runtime_worker_disconnect(worker); + if (!runtime_worker_reap_application(worker, true)) { + /* Fail closed on an impossible/invalid join rather than closing the + * session or freeing storage a callback could still reference. The + * service intentionally remains non-terminal for diagnosis. */ + return; + } + if (worker->application_session_opened) { + service->application.session_close(service->application.context, + worker->application_session); + worker->application_session = NULL; + worker->application_session_opened = false; + } + cbm_mutex_lock(&service->mutex); + if (worker->connection) { + cbm_daemon_ipc_connection_close(worker->connection); + worker->connection = NULL; + } + if (service->active_connections > 0) { + service->active_connections--; + } + atomic_store_explicit(&worker->done, true, memory_order_release); + cbm_mutex_unlock(&service->mutex); +} + +static bool runtime_activation_peer_matches_claim( + cbm_daemon_runtime_service_t *service, uint64_t process_id, + const char *claimed_build) { + if (!service || process_id == 0 || !claimed_build) { + return false; + } + bool active_image = runtime_process_image_reference_matches_process( + &service->active_image, process_id); + if (active_image && + strcmp(claimed_build, service->identity.build_fingerprint) == 0) { + return true; + } + char peer_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + return cbm_daemon_runtime_process_build_fingerprint( + process_id, peer_fingerprint) && + strcmp(peer_fingerprint, claimed_build) == 0; +} + +static void runtime_worker_handle_activation_shutdown( + cbm_daemon_runtime_worker_t *worker, const uint8_t *payload, + uint32_t length) { + cbm_daemon_runtime_service_t *service = worker->service; + cbm_daemon_runtime_activation_result_t result = {0}; + cbm_daemon_runtime_activation_action_t action = 0; + char requested_version[CBM_DAEMON_VERSION_TEXT_SIZE] = {0}; + char requested_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + bool request_valid = + length == CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE && payload && + runtime_activation_request_decode(payload, &action, + requested_version, + requested_build); + bool peer_verified = request_valid && + runtime_activation_peer_matches_claim( + service, worker->peer_process_id, + requested_build); + if (peer_verified) { + uint64_t deadline = + runtime_deadline_after(service->shutdown_timeout_ms); + cbm_mutex_lock(&service->mutex); + result.active_clients = (uint64_t)service->committed_clients; + /* Report only connections this request will drain. The authenticated + * one-shot requester is never a session and excludes itself. */ + result.active_connections = + service->active_connections > 0 + ? (uint64_t)(service->active_connections - 1U) + : 0; + if (service->state == CBM_DAEMON_RUNTIME_SERVICE_RUNNING) { + runtime_service_begin_stopping_locked(service, deadline, false); + service->activation_shutdown_requested = true; + service->activation_response_inflight = true; + worker->final_response_inflight = true; + result.accepted = true; + } + cbm_mutex_unlock(&service->mutex); + } + + uint8_t response[CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE]; + bool encoded = runtime_activation_response_encode(response, &result); + if (result.accepted) { + char requester_pid[32]; + char active_clients[32]; + char active_connections[32]; + (void)snprintf(requester_pid, sizeof(requester_pid), "%llu", + (unsigned long long)worker->peer_process_id); + (void)snprintf(active_clients, sizeof(active_clients), "%llu", + (unsigned long long)result.active_clients); + (void)snprintf(active_connections, sizeof(active_connections), "%llu", + (unsigned long long)result.active_connections); + cbm_log_info("daemon.activation_shutdown", "requester_pid", + requester_pid, "requester_build", requested_build, + "action", runtime_activation_action_text(action), + "active_clients", active_clients, + "active_connections", active_connections); + } + bool response_sent = encoded && runtime_worker_send_frame( + worker, CBM_DAEMON_FRAME_RESPONSE, + CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN, + response, + (uint32_t)sizeof(response)); + if (result.accepted && !response_sent) { + cbm_log_error("daemon.activation_shutdown_ack_failed", "shutdown", + "accepted", "requester_build", requested_build, + "action", runtime_activation_action_text(action)); + } + + if (result.accepted) { + /* Keep the activation ACK protected while interrupting every other + * connection. Close this requester normally, then release the global + * ACK gate so the accept loop can finish any stragglers. */ + runtime_service_interrupt_connections_except(service, worker, true); + runtime_worker_finish(worker); + cbm_mutex_lock(&service->mutex); + worker->final_response_inflight = false; + service->activation_response_inflight = false; + cbm_mutex_unlock(&service->mutex); + runtime_service_interrupt_connections(service); + return; + } + runtime_worker_finish(worker); +} + +static void *runtime_connection_worker(void *opaque) { + cbm_daemon_runtime_worker_t *worker = opaque; + cbm_daemon_runtime_service_t *service = worker->service; + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + char requested_version[CBM_DAEMON_VERSION_TEXT_SIZE]; + char requested_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + + int received = cbm_daemon_ipc_receive_frame_bounded( + worker->connection, service->request_timeout_ms, + CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE, &frame, &payload); + if (received != 1 || frame.type != CBM_DAEMON_FRAME_REQUEST) { + free(payload); + runtime_worker_finish(worker); + return NULL; + } + if (frame.flags == CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN) { + runtime_worker_handle_activation_shutdown(worker, payload, + frame.length); + free(payload); + return NULL; + } + if (frame.flags != CBM_DAEMON_RUNTIME_OP_HELLO || + frame.length != CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE || + !runtime_rendezvous_request_decode(payload, requested_version, + requested_build)) { + free(payload); + runtime_worker_finish(worker); + return NULL; + } + free(payload); + payload = NULL; + + cbm_daemon_runtime_connect_result_t hello_result; + memset(&hello_result, 0, sizeof(hello_result)); + cbm_daemon_hello_status_t hello_status = runtime_rendezvous_compare( + service->identity.semantic_version, + service->identity.build_fingerprint, requested_version, + requested_build, &hello_result.conflict); + if (hello_status != CBM_DAEMON_HELLO_COMPATIBLE) { + if (hello_status == CBM_DAEMON_HELLO_INVALID) { + runtime_worker_finish(worker); + return NULL; + } + hello_result.status = CBM_DAEMON_RUNTIME_CONNECT_CONFLICT; + hello_result.hello_status = hello_status; + (void)cbm_daemon_conflict_format(&hello_result.conflict, + hello_result.message, + sizeof(hello_result.message)); + if (!cbm_daemon_conflict_log_append(service->conflict_log_path, + &hello_result.conflict, + service->conflict_log_cap_bytes)) { + /* This goes through the daemon operation-log sink, never back + * through the failed conflict-log path. Do not silently lose the + * durable startup-conflict diagnostic. */ + cbm_log_error("daemon.conflict_log_append_failed", "path", + service->conflict_log_path); + } + (void)runtime_send_hello_response(worker->connection, &hello_result); + runtime_worker_finish(worker); + return NULL; + } + + bool peer_image_verified = + runtime_process_image_reference_matches_process( + &service->active_image, worker->peer_process_id); + bool peer_image_fingerprinted = false; + if (!peer_image_verified) { + char peer_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + peer_image_fingerprinted = + cbm_daemon_runtime_process_build_fingerprint( + worker->peer_process_id, peer_fingerprint); + peer_image_verified = peer_image_fingerprinted && + strcmp(peer_fingerprint, requested_build) == 0 && + strcmp(peer_fingerprint, + service->identity.build_fingerprint) == 0; + } + if (!peer_image_verified) { + cbm_log_error("daemon.client_image_rejected", "reason", + peer_image_fingerprinted ? "fingerprint_mismatch" + : "image_unverifiable"); + runtime_worker_finish(worker); + return NULL; + } + + cbm_daemon_client_id_t client_id = CBM_DAEMON_CLIENT_ID_INVALID; + if (!runtime_worker_admit(worker, &client_id)) { + runtime_result_rejected(&hello_result, "CBM daemon is stopping"); + (void)runtime_send_hello_response(worker->connection, &hello_result); + runtime_worker_finish(worker); + return NULL; + } + if (service->application.request) { + worker->application_session = service->application.session_open( + service->application.context, client_id, worker->peer_process_id); + if (!worker->application_session) { + runtime_result_rejected(&hello_result, + "CBM daemon session initialization failed"); + cbm_mutex_lock(&service->mutex); + worker->final_response_inflight = true; + cbm_mutex_unlock(&service->mutex); + runtime_worker_disconnect(worker); + (void)runtime_send_hello_response(worker->connection, + &hello_result); + cbm_mutex_lock(&service->mutex); + worker->final_response_inflight = false; + cbm_mutex_unlock(&service->mutex); + runtime_worker_finish(worker); + return NULL; + } + worker->application_session_opened = true; + } + if (!runtime_worker_commit_admission(worker)) { + /* session_open() returns a provisional allocation. STOPPING and this + * commit linearize on service->mutex; a losing provisional session is + * cancelled and closed exactly like any other connection-owned state, + * but is never exposed to the frontend as admitted. */ + runtime_result_rejected(&hello_result, "CBM daemon is stopping"); + cbm_mutex_lock(&service->mutex); + worker->final_response_inflight = true; + cbm_mutex_unlock(&service->mutex); + runtime_worker_disconnect(worker); + (void)runtime_send_hello_response(worker->connection, &hello_result); + cbm_mutex_lock(&service->mutex); + worker->final_response_inflight = false; + cbm_mutex_unlock(&service->mutex); + runtime_worker_finish(worker); + return NULL; + } + hello_result.status = CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED; + hello_result.hello_status = CBM_DAEMON_HELLO_COMPATIBLE; + hello_result.conflict.status = CBM_DAEMON_HELLO_COMPATIBLE; + hello_result.client_id = client_id; + hello_result.authenticated_process_id = worker->peer_process_id; + if (!runtime_send_hello_response(worker->connection, &hello_result)) { + runtime_worker_finish(worker); + return NULL; + } + + bool keep_running = true; + while (keep_running && runtime_worker_service_running(worker)) { + memset(&frame, 0, sizeof(frame)); + payload = NULL; + received = cbm_daemon_ipc_receive_frame(worker->connection, + CBM_DAEMON_IPC_WAIT_FOREVER, + &frame, &payload); + if (received != 1 || frame.type != CBM_DAEMON_FRAME_REQUEST) { + free(payload); + break; + } + switch (frame.flags) { + case CBM_DAEMON_RUNTIME_OP_HEARTBEAT: { + bool valid = frame.length == 0 && + cbm_daemon_client_heartbeat(service->coordinator, + client_id, cbm_now_ms()); + keep_running = valid && runtime_worker_send_status( + worker, + CBM_DAEMON_RUNTIME_OP_HEARTBEAT, + true); + break; + } + case CBM_DAEMON_RUNTIME_OP_JOB_SUBSCRIBE: + keep_running = runtime_worker_handle_subscribe( + worker, payload, frame.length); + break; + case CBM_DAEMON_RUNTIME_OP_JOB_UNSUBSCRIBE: + keep_running = runtime_worker_handle_unsubscribe( + worker, payload, frame.length); + break; + case CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST: + keep_running = runtime_worker_handle_application( + worker, payload, frame.length); + break; + case CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL: + keep_running = runtime_worker_handle_application_cancel( + worker, payload, frame.length); + break; + case CBM_DAEMON_RUNTIME_OP_DISCONNECT: + keep_running = false; + (void)runtime_worker_handle_disconnect(worker, frame.length); + break; + default: + keep_running = false; + break; + } + free(payload); + payload = NULL; + } + free(payload); + runtime_worker_finish(worker); + return NULL; +} + +static void runtime_worker_reset_after_join(cbm_daemon_runtime_worker_t *worker) { + free(worker->application_request); + worker->thread_started = false; + worker->in_use = false; + worker->joining = false; + worker->connection = NULL; + worker->client_id = CBM_DAEMON_CLIENT_ID_INVALID; + worker->peer_process_id = 0; + worker->admitted = false; + worker->admission_committed = false; + worker->final_response_inflight = false; + worker->application_session = NULL; + worker->application_session_opened = false; + worker->application_cancelled = false; + worker->application_thread_started = false; + worker->application_request_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + worker->last_application_request_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + worker->application_request = NULL; + worker->application_request_length = 0; + atomic_store_explicit(&worker->done, false, memory_order_release); + atomic_store_explicit(&worker->disconnecting, false, memory_order_release); + atomic_store_explicit(&worker->application_thread_done, false, + memory_order_release); +} + +static void runtime_reap_completed_workers(cbm_daemon_runtime_service_t *service) { + for (size_t i = 0; i < service->worker_capacity; i++) { + cbm_daemon_runtime_worker_t *worker = &service->workers[i]; + cbm_mutex_lock(&service->mutex); + bool reap = worker->in_use && worker->thread_started && !worker->joining && + atomic_load_explicit(&worker->done, memory_order_acquire); + if (reap) { + worker->joining = true; + } + cbm_mutex_unlock(&service->mutex); + if (!reap) { + continue; + } + int joined = cbm_thread_join(&worker->thread); + cbm_mutex_lock(&service->mutex); + if (joined == 0) { + runtime_worker_reset_after_join(worker); + } else { + worker->joining = false; + } + cbm_mutex_unlock(&service->mutex); + } +} + +static cbm_daemon_runtime_worker_t *runtime_find_free_worker_locked( + cbm_daemon_runtime_service_t *service) { + for (size_t i = 0; i < service->worker_capacity; i++) { + if (!service->workers[i].in_use) { + return &service->workers[i]; + } + } + return NULL; +} + +static void runtime_reject_inline(cbm_daemon_ipc_connection_t *connection, + const char *message) { + cbm_daemon_runtime_connect_result_t result; + runtime_result_rejected(&result, message); + (void)runtime_send_hello_response(connection, &result); + cbm_daemon_ipc_connection_close(connection); +} + +static void runtime_accept_connection(cbm_daemon_runtime_service_t *service, + cbm_daemon_ipc_connection_t *connection) { + uint64_t peer_pid = cbm_daemon_ipc_connection_peer_pid(connection); + if (peer_pid == 0) { + cbm_daemon_ipc_connection_close(connection); + return; + } + + cbm_mutex_lock(&service->mutex); + bool running = service->state == CBM_DAEMON_RUNTIME_SERVICE_RUNNING; + bool capacity = service->active_connections >= service->worker_capacity; + cbm_daemon_runtime_worker_t *worker = + running && !capacity ? runtime_find_free_worker_locked(service) : NULL; + if (worker) { + worker->connection = connection; + worker->peer_process_id = peer_pid; + worker->client_id = CBM_DAEMON_CLIENT_ID_INVALID; + worker->admitted = false; + worker->admission_committed = false; + worker->final_response_inflight = false; + worker->application_session = NULL; + worker->application_session_opened = false; + worker->application_cancelled = false; + worker->application_thread_started = false; + free(worker->application_request); + worker->application_request = NULL; + worker->application_request_length = 0; + worker->joining = false; + worker->in_use = true; + atomic_store_explicit(&worker->done, false, memory_order_release); + atomic_store_explicit(&worker->disconnecting, false, + memory_order_release); + atomic_store_explicit(&worker->application_thread_done, false, + memory_order_release); + service->active_connections++; + int created = cbm_thread_create(&worker->thread, + RUNTIME_WORKER_STACK_SIZE, + runtime_connection_worker, worker); + if (created == 0) { + worker->thread_started = true; + cbm_mutex_unlock(&service->mutex); + return; + } + service->active_connections--; + runtime_worker_reset_after_join(worker); + } + cbm_mutex_unlock(&service->mutex); + + if (!running) { + runtime_reject_inline(connection, "CBM daemon is stopping"); + } else { + runtime_reject_inline(connection, "CBM daemon connection capacity reached"); + } +} + +static bool runtime_service_can_exit(cbm_daemon_runtime_service_t *service, + uint64_t now_ms) { + bool can_exit = false; + cbm_mutex_lock(&service->mutex); + if (service->state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING && + service->active_connections == 0) { + bool drained = cbm_daemon_should_exit(service->coordinator, now_ms); + if (!drained && service->activation_shutdown_requested) { + /* Activation can arrive while the daemon has no committed + * coordinator client, so no final disconnect exists to flip the + * coordinator's terminal bit. Resource emptiness is equivalent + * once the runtime has atomically stopped all new admission. */ + drained = cbm_daemon_active_clients(service->coordinator) == 0 && + cbm_daemon_active_jobs(service->coordinator) == 0 && + cbm_daemon_active_watches(service->coordinator) == 0; + } + bool deadline_reached = service->stop_deadline_ms != 0 && + now_ms >= service->stop_deadline_ms; + can_exit = drained || deadline_reached || service->emergency_stop; + if (can_exit) { + service->state = CBM_DAEMON_RUNTIME_SERVICE_EXITED; + } + } + cbm_mutex_unlock(&service->mutex); + return can_exit; +} + +static void *runtime_accept_loop(void *opaque) { + cbm_daemon_runtime_service_t *service = opaque; + for (;;) { + runtime_reap_completed_workers(service); + cbm_mutex_lock(&service->mutex); + cbm_daemon_runtime_service_state_t state = service->state; + cbm_mutex_unlock(&service->mutex); + if (state == CBM_DAEMON_RUNTIME_SERVICE_EXITED) { + break; + } + if (state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING) { + runtime_service_interrupt_connections(service); + if (runtime_service_can_exit(service, cbm_now_ms())) { + break; + } + uint64_t tick_deadline = runtime_deadline_after(1); + runtime_wait_tick(tick_deadline); + continue; + } + + cbm_daemon_ipc_connection_t *connection = NULL; + int accepted = cbm_daemon_ipc_accept(service->listener, + RUNTIME_ACCEPT_POLL_MS, + &connection); + if (accepted == 1 && connection) { + runtime_accept_connection(service, connection); + } else if (accepted < 0) { + /* IPC intentionally combines invalid-peer and listener errors. + * Retry while RUNNING so one hostile peer cannot stop the daemon. */ + uint64_t tick_deadline = runtime_deadline_after(1); + runtime_wait_tick(tick_deadline); + } + } + runtime_reap_completed_workers(service); + atomic_store_explicit(&service->accept_thread_done, true, memory_order_release); + return NULL; +} + +static char *runtime_string_copy_bounded(const char *value, size_t capacity) { + size_t length = 0; + if (!runtime_bounded_length(value, capacity, &length)) { + return NULL; + } + char *copy = malloc(length + 1); + if (copy) { + memcpy(copy, value, length + 1); + } + return copy; +} + +static bool runtime_application_callbacks_valid( + const cbm_daemon_runtime_application_callbacks_t *application) { + bool any = application->session_open || application->request || + application->request_cancel || application->session_cancel || + application->session_close; + bool all = application->session_open && application->request && + application->request_cancel && + application->session_cancel && application->session_close; + return !any || all; +} + +static bool runtime_participant_guard_release_complete( + cbm_daemon_ipc_participant_guard_t **guard_io, uint32_t timeout_ms) { + uint64_t deadline = runtime_deadline_after(timeout_ms); + while (guard_io && *guard_io) { + (void)cbm_daemon_ipc_participant_guard_release(guard_io); + if (!*guard_io) { + return true; + } + if (cbm_now_ms() >= deadline) { + return false; + } + cbm_usleep(1000); + } + return guard_io != NULL; +} + +static _Noreturn void runtime_cleanup_fail_stop(const char *component) { + /* Convenience start() has no outward cleanup handle on failure. Losing a + * live participant guard would permit later code to misreport teardown, + * so terminate the process and let the kernel release native ownership. */ + cbm_log_error("daemon.forced_shutdown", "component", component); + (void)fflush(stdout); + (void)fflush(stderr); +#ifdef _WIN32 + (void)TerminateProcess(GetCurrentProcess(), EXIT_FAILURE); + abort(); +#else + _Exit(EXIT_FAILURE); +#endif +} + +static void runtime_startup_lock_release_complete( + cbm_daemon_ipc_startup_lock_t **lock_io, uint32_t timeout_ms) { + uint64_t deadline = runtime_deadline_after(timeout_ms); + while (lock_io && *lock_io) { + (void)cbm_daemon_ipc_startup_lock_release(lock_io); + if (!*lock_io) { + return; + } + if (cbm_now_ms() >= deadline) { + runtime_cleanup_fail_stop("startup_lock_cleanup"); + } + cbm_usleep(1000); + } +} + +static void runtime_service_destroy_unstarted( + cbm_daemon_runtime_service_t *service) { + if (!service) { + return; + } + cbm_daemon_ipc_listener_close(service->listener); + cbm_daemon_coordinator_free(service->coordinator); + (void)runtime_process_image_reference_release(&service->active_image); + free(service->conflict_log_path); + for (size_t i = 0; i < service->worker_mutexes_initialized; i++) { + cbm_mutex_destroy(&service->workers[i].send_mutex); + } + free(service->workers); + cbm_mutex_destroy(&service->mutex); + free(service); +} + +cbm_daemon_runtime_service_t *cbm_daemon_runtime_service_start_reserved( + const cbm_daemon_runtime_service_config_t *config, + cbm_daemon_ipc_lifetime_reservation_t **reservation_io) { + uint8_t validation[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE]; + char active_process_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + runtime_process_image_reference_t active_image; + runtime_process_image_reference_init(&active_image); + if (!reservation_io || !*reservation_io || !config || !config->endpoint || + !config->conflict_log_path || + config->conflict_log_cap_bytes == 0 || config->max_clients == 0 || + config->max_clients > RUNTIME_MAX_CLIENTS_HARD || + config->lease_timeout_ms == 0 || config->request_timeout_ms == 0 || + config->request_timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || + config->shutdown_timeout_ms == 0 || + config->shutdown_timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || + !runtime_application_callbacks_valid(&config->application) || + !cbm_daemon_runtime_hello_request_encode(validation, &config->identity)) { + cbm_log_error("daemon.runtime.start_failed", "stage", + "config_validation"); + return NULL; + } + /* WHY: cppcheck evaluates the unsupported-platform fail-closed branch + * because its invocation defines no host OS macro. Production compilers + * select one of the Windows/macOS/Linux native-image implementations. */ + // cppcheck-suppress knownConditionTrueFalse + if (!runtime_process_image_reference_acquire( + runtime_current_process_id(), &active_image, + active_process_fingerprint) || + strcmp(active_process_fingerprint, + config->identity.build_fingerprint) != 0) { + cbm_log_error("daemon.runtime.start_failed", "stage", + "active_image_identity"); + (void)runtime_process_image_reference_release(&active_image); + return NULL; + } + + cbm_daemon_runtime_service_t *service = calloc(1, sizeof(*service)); + if (!service) { + cbm_log_error("daemon.runtime.start_failed", "stage", + "service_allocation"); + (void)runtime_process_image_reference_release(&active_image); + return NULL; + } + service->active_image = active_image; + runtime_process_image_reference_init(&active_image); + cbm_mutex_init(&service->mutex); + atomic_init(&service->accept_thread_done, false); + service->worker_capacity = config->max_clients; + service->workers = calloc(service->worker_capacity, sizeof(*service->workers)); + service->coordinator = cbm_daemon_coordinator_new(config->lease_timeout_ms); + service->conflict_log_path = + runtime_string_copy_bounded(config->conflict_log_path, RUNTIME_PATH_CAP); + size_t version_length = 0; + size_t build_length = 0; + bool copied_identity = runtime_bounded_length(config->identity.semantic_version, + sizeof(service->semantic_version), + &version_length) && + runtime_bounded_length(config->identity.build_fingerprint, + sizeof(service->build_fingerprint), + &build_length); + if (copied_identity) { + memcpy(service->semantic_version, config->identity.semantic_version, + version_length + 1); + memcpy(service->build_fingerprint, config->identity.build_fingerprint, + build_length + 1); + } + service->identity.semantic_version = service->semantic_version; + service->identity.build_fingerprint = service->build_fingerprint; + service->identity.protocol_abi = config->identity.protocol_abi; + service->identity.store_abi = config->identity.store_abi; + service->identity.feature_abi = config->identity.feature_abi; + service->conflict_log_cap_bytes = config->conflict_log_cap_bytes; + service->lease_timeout_ms = config->lease_timeout_ms; + service->request_timeout_ms = config->request_timeout_ms; + service->shutdown_timeout_ms = config->shutdown_timeout_ms; + service->application = config->application; + service->state = CBM_DAEMON_RUNTIME_SERVICE_STARTING; + + if (!service->workers || !service->coordinator || !service->conflict_log_path || + !copied_identity) { + cbm_log_error("daemon.runtime.start_failed", "stage", + "service_initialization"); + runtime_service_destroy_unstarted(service); + return NULL; + } + for (size_t i = 0; i < service->worker_capacity; i++) { + service->workers[i].service = service; + cbm_mutex_init(&service->workers[i].send_mutex); + service->worker_mutexes_initialized++; + atomic_init(&service->workers[i].done, false); + atomic_init(&service->workers[i].disconnecting, false); + atomic_init(&service->workers[i].application_thread_done, false); + } + service->listener = + cbm_daemon_ipc_listen_reserved(config->endpoint, reservation_io); + if (!service->listener) { + cbm_log_error("daemon.runtime.start_failed", "stage", + "listener_handoff"); + runtime_service_destroy_unstarted(service); + return NULL; + } + service->state = CBM_DAEMON_RUNTIME_SERVICE_RUNNING; + if (cbm_thread_create(&service->accept_thread, RUNTIME_WORKER_STACK_SIZE, + runtime_accept_loop, service) != 0) { + cbm_log_error("daemon.runtime.start_failed", "stage", + "accept_thread"); + service->state = CBM_DAEMON_RUNTIME_SERVICE_EXITED; + runtime_service_destroy_unstarted(service); + return NULL; + } + service->accept_thread_started = true; + return service; +} + +cbm_daemon_runtime_service_t *cbm_daemon_runtime_service_start( + const cbm_daemon_runtime_service_config_t *config) { + if (!config || !config->endpoint) { + return NULL; + } + cbm_daemon_ipc_startup_lock_t *startup_lock = NULL; + if (cbm_daemon_ipc_startup_lock_try_acquire( + config->endpoint, &startup_lock) != 1) { + return NULL; + } + int generation = cbm_daemon_ipc_generation_probe_under_startup_lock( + config->endpoint, startup_lock); + if (generation != 0 || + !cbm_daemon_ipc_startup_lock_prepare_handoff(startup_lock)) { + runtime_startup_lock_release_complete( + &startup_lock, config->shutdown_timeout_ms); + return NULL; + } + cbm_daemon_ipc_participant_guard_t *participant_guard = NULL; + if (cbm_daemon_ipc_participant_guard_try_join( + config->endpoint, &participant_guard) != 1) { + runtime_startup_lock_release_complete( + &startup_lock, config->shutdown_timeout_ms); + return NULL; + } + cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL; + if (cbm_daemon_ipc_lifetime_reservation_try_acquire( + config->endpoint, &reservation) != 1) { + if (!runtime_participant_guard_release_complete( + &participant_guard, config->shutdown_timeout_ms)) { + runtime_cleanup_fail_stop("participant_guard_cleanup"); + } + runtime_startup_lock_release_complete( + &startup_lock, config->shutdown_timeout_ms); + return NULL; + } + cbm_daemon_runtime_service_t *service = + cbm_daemon_runtime_service_start_reserved(config, &reservation); + cbm_daemon_ipc_lifetime_reservation_release(reservation); + if (!service) { + if (!runtime_participant_guard_release_complete( + &participant_guard, config->shutdown_timeout_ms)) { + runtime_cleanup_fail_stop("participant_guard_cleanup"); + } + runtime_startup_lock_release_complete( + &startup_lock, config->shutdown_timeout_ms); + return NULL; + } + service->owned_participant_guard = participant_guard; + runtime_startup_lock_release_complete( + &startup_lock, config->shutdown_timeout_ms); + return service; +} + +cbm_daemon_runtime_service_state_t +cbm_daemon_runtime_service_state(cbm_daemon_runtime_service_t *service) { + if (!service) { + return CBM_DAEMON_RUNTIME_SERVICE_EXITED; + } + cbm_mutex_lock(&service->mutex); + cbm_daemon_runtime_service_state_t state = service->state; + cbm_mutex_unlock(&service->mutex); + return state; +} + +size_t cbm_daemon_runtime_service_active_clients( + cbm_daemon_runtime_service_t *service) { + if (!service) { + return 0; + } + cbm_mutex_lock(&service->mutex); + size_t count = service->committed_clients; + cbm_mutex_unlock(&service->mutex); + return count; +} + +size_t cbm_daemon_runtime_service_active_connections( + cbm_daemon_runtime_service_t *service) { + if (!service) { + return 0; + } + cbm_mutex_lock(&service->mutex); + size_t count = service->active_connections; + cbm_mutex_unlock(&service->mutex); + return count; +} + +size_t cbm_daemon_runtime_service_job_subscribers( + cbm_daemon_runtime_service_t *service, const char *project_key) { + return service ? cbm_daemon_job_subscribers(service->coordinator, project_key) : 0; +} + +uint64_t cbm_daemon_runtime_service_client_process_id( + cbm_daemon_runtime_service_t *service, cbm_daemon_client_id_t client_id) { + if (!service || client_id == CBM_DAEMON_CLIENT_ID_INVALID) { + return 0; + } + uint64_t process_id = 0; + cbm_mutex_lock(&service->mutex); + for (size_t i = 0; i < service->worker_capacity; i++) { + cbm_daemon_runtime_worker_t *worker = &service->workers[i]; + if (worker->in_use && worker->admitted && + worker->admission_committed && worker->client_id == client_id) { + process_id = worker->peer_process_id; + break; + } + } + cbm_mutex_unlock(&service->mutex); + return process_id; +} + +static bool runtime_wait_for_count(cbm_daemon_runtime_service_t *service, + size_t expected, uint32_t timeout_ms, + bool connections) { + if (!service) { + return false; + } + uint64_t deadline = runtime_deadline_after(timeout_ms); + for (;;) { + size_t actual = connections + ? cbm_daemon_runtime_service_active_connections(service) + : cbm_daemon_runtime_service_active_clients(service); + if (actual == expected) { + return true; + } + if (cbm_now_ms() >= deadline) { + return false; + } + runtime_wait_tick(deadline); + } +} + +bool cbm_daemon_runtime_service_wait_for_clients( + cbm_daemon_runtime_service_t *service, size_t expected, uint32_t timeout_ms) { + return runtime_wait_for_count(service, expected, timeout_ms, false); +} + +bool cbm_daemon_runtime_service_wait_for_connections( + cbm_daemon_runtime_service_t *service, size_t expected, uint32_t timeout_ms) { + return runtime_wait_for_count(service, expected, timeout_ms, true); +} + +bool cbm_daemon_runtime_service_wait_exited( + cbm_daemon_runtime_service_t *service, uint32_t timeout_ms) { + if (!service) { + return false; + } + uint64_t deadline = runtime_deadline_after(timeout_ms); + for (;;) { + if (cbm_daemon_runtime_service_state(service) == + CBM_DAEMON_RUNTIME_SERVICE_EXITED && + atomic_load_explicit(&service->accept_thread_done, + memory_order_acquire)) { + return true; + } + if (cbm_now_ms() >= deadline) { + return false; + } + runtime_wait_tick(deadline); + } +} + +bool cbm_daemon_runtime_service_job_reaped( + cbm_daemon_runtime_service_t *service, const char *project_key) { + return service && cbm_daemon_job_reaped(service->coordinator, project_key, + cbm_now_ms()); +} + +bool cbm_daemon_runtime_service_stop(cbm_daemon_runtime_service_t *service, + uint32_t timeout_ms) { + if (!service) { + return false; + } + if (cbm_daemon_runtime_service_state(service) == + CBM_DAEMON_RUNTIME_SERVICE_EXITED) { + return cbm_daemon_runtime_service_wait_exited(service, timeout_ms); + } + runtime_service_begin_stopping(service, timeout_ms, true); + runtime_service_interrupt_connections(service); + return cbm_daemon_runtime_service_wait_exited(service, timeout_ms); +} + +bool cbm_daemon_runtime_service_free(cbm_daemon_runtime_service_t *service) { + if (!service || cbm_daemon_runtime_service_state(service) != + CBM_DAEMON_RUNTIME_SERVICE_EXITED) { + return false; + } + if (service->accept_thread_started && !service->accept_thread_joined) { + if (cbm_thread_join(&service->accept_thread) != 0) { + return false; + } + service->accept_thread_joined = true; + } + for (size_t i = 0; i < service->worker_capacity; i++) { + cbm_daemon_runtime_worker_t *worker = &service->workers[i]; + if (worker->thread_started) { + if (!atomic_load_explicit(&worker->done, memory_order_acquire) || + cbm_thread_join(&worker->thread) != 0) { + return false; + } + runtime_worker_reset_after_join(worker); + } + } + cbm_daemon_ipc_listener_close(service->listener); + service->listener = NULL; + if (!runtime_participant_guard_release_complete( + &service->owned_participant_guard, + service->shutdown_timeout_ms)) { + /* Preserve the service allocation so the caller can retry or force + * process exit without dropping the only live cleanup handle. */ + return false; + } + cbm_daemon_coordinator_free(service->coordinator); + (void)runtime_process_image_reference_release(&service->active_image); + free(service->conflict_log_path); + for (size_t i = 0; i < service->worker_mutexes_initialized; i++) { + cbm_mutex_destroy(&service->workers[i].send_mutex); + } + free(service->workers); + cbm_mutex_destroy(&service->mutex); + free(service); + return true; +} + +bool cbm_daemon_runtime_request_activation_shutdown( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, + cbm_daemon_runtime_activation_action_t action, uint32_t timeout_ms, + cbm_daemon_runtime_activation_result_t *result_out) { + if (result_out) { + memset(result_out, 0, sizeof(*result_out)); + } + uint8_t request[CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE]; + if (!endpoint || !identity || !result_out || + timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || + !runtime_activation_request_encode(request, action, identity)) { + return false; + } + cbm_daemon_ipc_connection_t *connection = + cbm_daemon_ipc_connect(endpoint, timeout_ms); + bool sent = connection && cbm_daemon_ipc_send_frame( + connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN, + request, (uint32_t)sizeof(request)); + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + int received = sent ? cbm_daemon_ipc_receive_frame_bounded( + connection, timeout_ms, + CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE, + &frame, &payload) + : 0; + bool valid = + received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN && + frame.length == CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE && + runtime_activation_response_decode(payload, result_out); + free(payload); + cbm_daemon_ipc_connection_close(connection); + if (!valid) { + memset(result_out, 0, sizeof(*result_out)); + } + return valid; +} + +cbm_daemon_runtime_client_t *cbm_daemon_runtime_client_connect( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, uint32_t timeout_ms, + cbm_daemon_runtime_connect_result_t *result_out) { + if (result_out) { + memset(result_out, 0, sizeof(*result_out)); + } + uint8_t request[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE]; + if (!endpoint || !identity || !result_out || + timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || + !cbm_daemon_runtime_hello_request_encode(request, identity)) { + return NULL; + } + cbm_daemon_ipc_connection_t *connection = + cbm_daemon_ipc_connect(endpoint, timeout_ms); + if (!connection || + !cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_HELLO, request, + (uint32_t)sizeof(request))) { + cbm_daemon_ipc_connection_close(connection); + return NULL; + } + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + int received = cbm_daemon_ipc_receive_frame_bounded( + connection, timeout_ms, CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE, + &frame, &payload); + bool valid = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == CBM_DAEMON_RUNTIME_OP_HELLO && + frame.length == CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE && + runtime_hello_response_decode(payload, result_out); + free(payload); + if (!valid || result_out->status != CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED) { + cbm_daemon_ipc_connection_close(connection); + return NULL; + } + + cbm_daemon_runtime_client_t *client = calloc(1, sizeof(*client)); + if (!client) { + cbm_daemon_ipc_connection_close(connection); + memset(result_out, 0, sizeof(*result_out)); + return NULL; + } + cbm_mutex_init(&client->exchange_mutex); + cbm_mutex_init(&client->send_mutex); + cbm_mutex_init(&client->state_mutex); + client->connection = connection; + client->client_id = result_out->client_id; + client->authenticated_process_id = result_out->authenticated_process_id; + client->usable = true; + return client; +} + +cbm_daemon_client_id_t +cbm_daemon_runtime_client_id(const cbm_daemon_runtime_client_t *client) { + return client ? client->client_id : CBM_DAEMON_CLIENT_ID_INVALID; +} + +uint64_t cbm_daemon_runtime_client_process_id( + const cbm_daemon_runtime_client_t *client) { + return client ? client->authenticated_process_id : 0; +} + +static bool runtime_client_exchange(cbm_daemon_runtime_client_t *client, + cbm_daemon_runtime_operation_t operation, + const void *request, uint32_t request_length, + uint32_t timeout_ms, uint8_t **response_out, + uint32_t expected_response_length) { + *response_out = NULL; + if (timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER) { + return false; + } + cbm_mutex_lock(&client->exchange_mutex); + cbm_mutex_lock(&client->state_mutex); + if (!client->usable || client->closing || !client->connection) { + cbm_mutex_unlock(&client->state_mutex); + cbm_mutex_unlock(&client->exchange_mutex); + return false; + } + cbm_daemon_ipc_connection_t *connection = client->connection; + client->exchange_active = true; + cbm_mutex_unlock(&client->state_mutex); + + cbm_mutex_lock(&client->send_mutex); + bool sent = cbm_daemon_ipc_send_frame( + connection, CBM_DAEMON_FRAME_REQUEST, (uint16_t)operation, request, + request_length); + cbm_mutex_unlock(&client->send_mutex); + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + int received = sent ? cbm_daemon_ipc_receive_frame( + connection, timeout_ms, &frame, &payload) + : -1; + bool valid = sent && received == 1 && + frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == (uint16_t)operation && + frame.length == expected_response_length; + cbm_mutex_lock(&client->state_mutex); + client->exchange_active = false; + if (!valid) { + client->usable = false; + } + cbm_mutex_unlock(&client->state_mutex); + if (!valid) { + free(payload); + } else { + *response_out = payload; + } + cbm_mutex_unlock(&client->exchange_mutex); + return valid; +} + +cbm_daemon_subscription_result_t cbm_daemon_runtime_client_job_subscribe( + cbm_daemon_runtime_client_t *client, const char *project_key, + cbm_daemon_subscription_id_t *subscription_id_out, uint32_t timeout_ms) { + if (subscription_id_out) { + *subscription_id_out = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + } + size_t key_length = 0; + if (!client || !subscription_id_out || + !runtime_bounded_length(project_key, + CBM_DAEMON_RUNTIME_PROJECT_KEY_MAX + 1, + &key_length) || + key_length == 0 || key_length > CBM_DAEMON_RUNTIME_PROJECT_KEY_MAX) { + return CBM_DAEMON_SUBSCRIPTION_REJECTED; + } + uint8_t request[SUBSCRIBE_REQUEST_PREFIX_SIZE + + CBM_DAEMON_RUNTIME_PROJECT_KEY_MAX]; + runtime_put_u32(request, (uint32_t)key_length); + memcpy(request + SUBSCRIBE_REQUEST_PREFIX_SIZE, project_key, key_length); + uint8_t *response = NULL; + if (!runtime_client_exchange(client, CBM_DAEMON_RUNTIME_OP_JOB_SUBSCRIBE, + request, + (uint32_t)(SUBSCRIBE_REQUEST_PREFIX_SIZE + key_length), + timeout_ms, &response, SUBSCRIBE_RESPONSE_SIZE)) { + return CBM_DAEMON_SUBSCRIPTION_REJECTED; + } + cbm_daemon_subscription_result_t result = + (cbm_daemon_subscription_result_t)runtime_get_u32(response); + cbm_daemon_subscription_id_t subscription_id = runtime_get_u64(response + 4); + free(response); + if ((result != CBM_DAEMON_SUBSCRIPTION_STARTED && + result != CBM_DAEMON_SUBSCRIPTION_JOINED) || + subscription_id == CBM_DAEMON_SUBSCRIPTION_ID_INVALID) { + return CBM_DAEMON_SUBSCRIPTION_REJECTED; + } + *subscription_id_out = subscription_id; + return result; +} + +bool cbm_daemon_runtime_client_job_unsubscribe( + cbm_daemon_runtime_client_t *client, + cbm_daemon_subscription_id_t subscription_id, uint32_t timeout_ms) { + if (!client || subscription_id == CBM_DAEMON_SUBSCRIPTION_ID_INVALID) { + return false; + } + uint8_t request[UNSUBSCRIBE_REQUEST_SIZE]; + runtime_put_u64(request, subscription_id); + uint8_t *response = NULL; + if (!runtime_client_exchange(client, + CBM_DAEMON_RUNTIME_OP_JOB_UNSUBSCRIBE, + request, (uint32_t)sizeof(request), timeout_ms, + &response, STATUS_RESPONSE_SIZE)) { + return false; + } + bool removed = runtime_get_u32(response) == 1; + free(response); + return removed; +} + +bool cbm_daemon_runtime_client_heartbeat(cbm_daemon_runtime_client_t *client, + uint32_t timeout_ms) { + if (!client) { + return false; + } + uint8_t *response = NULL; + if (!runtime_client_exchange(client, CBM_DAEMON_RUNTIME_OP_HEARTBEAT, + NULL, 0, timeout_ms, &response, + STATUS_RESPONSE_SIZE)) { + return false; + } + bool valid = runtime_get_u32(response) == 1; + free(response); + return valid; +} + +bool cbm_daemon_runtime_client_application_token_reserve( + cbm_daemon_runtime_client_t *client, + cbm_daemon_runtime_application_token_t *token_out) { + if (token_out) { + *token_out = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + } + if (!client || !token_out) { + return false; + } + cbm_mutex_lock(&client->state_mutex); + bool valid = client->usable && !client->closing && client->connection && + client->active_application_token == + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && + client->next_application_token == + client->last_started_application_token && + client->next_application_token != UINT64_MAX; + if (valid) { + client->next_application_token++; + *token_out = client->next_application_token; + } + cbm_mutex_unlock(&client->state_mutex); + return valid; +} + +cbm_daemon_runtime_cancel_result_t +cbm_daemon_runtime_client_application_cancel( + cbm_daemon_runtime_client_t *client, + cbm_daemon_runtime_application_token_t request_token) { + if (!client || + request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { + return CBM_DAEMON_RUNTIME_CANCEL_ERROR; + } + + bool dispatch = false; + cbm_mutex_lock(&client->state_mutex); + if (!client->usable || client->closing || !client->connection) { + cbm_mutex_unlock(&client->state_mutex); + return CBM_DAEMON_RUNTIME_CANCEL_ERROR; + } + if (client->active_application_token == request_token) { + client->pending_cancel_token = request_token; + dispatch = client->application_request_sent && + !client->application_cancel_sent; + } else if (client->active_application_token == + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && + request_token == client->next_application_token && + request_token > client->last_started_application_token) { + /* The frontend publishes its active identity immediately after token + * reservation. Preserve this one pre-send cancellation until the + * tagged exchange emits REQUEST then CANCEL under send_mutex. */ + client->pending_cancel_token = request_token; + } else { + cbm_mutex_unlock(&client->state_mutex); + return CBM_DAEMON_RUNTIME_CANCEL_STALE; + } + cbm_mutex_unlock(&client->state_mutex); + if (!dispatch) { + return CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED; + } + + uint8_t wire[APPLICATION_CANCEL_REQUEST_SIZE]; + runtime_put_u64(wire, request_token); + cbm_daemon_ipc_connection_t *connection = NULL; + bool already_sent = false; + bool unavailable = false; + cbm_mutex_lock(&client->send_mutex); + cbm_mutex_lock(&client->state_mutex); + dispatch = client->usable && !client->closing && client->connection && + client->active_application_token == request_token && + client->application_request_sent && + !client->application_cancel_sent; + if (dispatch) { + client->application_cancel_sent = true; + connection = client->connection; + } else { + unavailable = !client->usable || client->closing || + !client->connection; + already_sent = client->usable && !client->closing && + client->connection && + client->active_application_token == request_token && + client->application_cancel_sent; + } + cbm_mutex_unlock(&client->state_mutex); + bool sent = !dispatch || cbm_daemon_ipc_send_frame( + connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, + wire, (uint32_t)sizeof(wire)); + cbm_mutex_unlock(&client->send_mutex); + if (!dispatch) { + /* The request path may have observed pending_cancel_token and emitted + * the control while this caller waited for send_mutex. That is still + * an accepted cancellation, not a stale target. */ + return already_sent + ? CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED + : (unavailable ? CBM_DAEMON_RUNTIME_CANCEL_ERROR + : CBM_DAEMON_RUNTIME_CANCEL_STALE); + } + if (!sent) { + cbm_mutex_lock(&client->state_mutex); + client->usable = false; + cbm_mutex_unlock(&client->state_mutex); + cbm_daemon_ipc_connection_interrupt(connection); + return CBM_DAEMON_RUNTIME_CANCEL_ERROR; + } + return CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED; +} + +cbm_daemon_runtime_application_status_t +cbm_daemon_runtime_client_application_request_tagged( + cbm_daemon_runtime_client_t *client, + cbm_daemon_runtime_application_token_t request_token, + const void *request, uint32_t request_length, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms) { + if (response_out) { + *response_out = NULL; + } + if (response_length_out) { + *response_length_out = 0; + } + if (!client || !response_out || !response_length_out || + request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID || + timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || + request_length > CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX || + (request_length > 0 && !request)) { + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + uint64_t wire_length = APPLICATION_REQUEST_PREFIX_SIZE + + (uint64_t)request_length; + if (wire_length > CBM_DAEMON_MAX_FRAME_SIZE || wire_length > UINT32_MAX) { + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + uint8_t *wire = malloc((size_t)wire_length); + if (!wire) { + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + runtime_put_u64(wire, request_token); + runtime_put_u32(wire + 8, request_length); + if (request_length > 0) { + memcpy(wire + APPLICATION_REQUEST_PREFIX_SIZE, request, + request_length); + } + + cbm_mutex_lock(&client->exchange_mutex); + cbm_mutex_lock(&client->state_mutex); + if (!client->usable || client->closing || !client->connection || + request_token != client->next_application_token || + request_token <= client->last_started_application_token || + client->active_application_token != + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { + cbm_mutex_unlock(&client->state_mutex); + cbm_mutex_unlock(&client->exchange_mutex); + free(wire); + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + cbm_daemon_ipc_connection_t *connection = client->connection; + client->exchange_active = true; + client->last_started_application_token = request_token; + client->active_application_token = request_token; + client->application_request_sent = false; + client->application_cancel_sent = false; + if (client->pending_cancel_token != request_token) { + client->pending_cancel_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + } + cbm_mutex_unlock(&client->state_mutex); + + uint8_t cancel_wire[APPLICATION_CANCEL_REQUEST_SIZE]; + runtime_put_u64(cancel_wire, request_token); + cbm_mutex_lock(&client->send_mutex); + bool sent = cbm_daemon_ipc_send_frame( + connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST, wire, + (uint32_t)wire_length); + bool send_cancel = false; + cbm_mutex_lock(&client->state_mutex); + client->application_request_sent = sent; + send_cancel = sent && !client->closing && + client->pending_cancel_token == request_token && + !client->application_cancel_sent; + if (send_cancel) { + client->application_cancel_sent = true; + } + cbm_mutex_unlock(&client->state_mutex); + if (send_cancel) { + sent = cbm_daemon_ipc_send_frame( + connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, cancel_wire, + (uint32_t)sizeof(cancel_wire)); + if (!sent) { + cbm_mutex_lock(&client->state_mutex); + client->usable = false; + cbm_mutex_unlock(&client->state_mutex); + } + } + cbm_mutex_unlock(&client->send_mutex); + free(wire); + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + int received = sent ? cbm_daemon_ipc_receive_frame( + connection, timeout_ms, &frame, &payload) + : -1; + bool protocol_valid = + sent && received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST && + frame.length >= APPLICATION_RESPONSE_PREFIX_SIZE && payload; + cbm_daemon_runtime_application_status_t status = + CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + uint32_t response_length = 0; + if (protocol_valid) { + cbm_daemon_runtime_application_token_t response_token = + runtime_get_u64(payload); + status = + (cbm_daemon_runtime_application_status_t)runtime_get_u32(payload + 8); + response_length = runtime_get_u32(payload + 12); + protocol_valid = + response_token == request_token && + status >= CBM_DAEMON_RUNTIME_APPLICATION_OK && + status <= CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && + response_length <= CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX && + frame.length == APPLICATION_RESPONSE_PREFIX_SIZE + response_length && + (status == CBM_DAEMON_RUNTIME_APPLICATION_OK || response_length == 0); + } + + uint8_t *response_copy = NULL; + bool copied = true; + if (protocol_valid && response_length > 0) { + response_copy = malloc(response_length); + copied = response_copy != NULL; + if (copied) { + memcpy(response_copy, payload + APPLICATION_RESPONSE_PREFIX_SIZE, + response_length); + } + } + free(payload); + + cbm_mutex_lock(&client->state_mutex); + client->exchange_active = false; + if (client->active_application_token == request_token) { + client->active_application_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + client->application_request_sent = false; + client->application_cancel_sent = false; + } + if (client->pending_cancel_token == request_token) { + client->pending_cancel_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + } + if (!protocol_valid) { + client->usable = false; + } + cbm_mutex_unlock(&client->state_mutex); + cbm_mutex_unlock(&client->exchange_mutex); + + if (!protocol_valid || !copied) { + free(response_copy); + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + *response_out = response_copy; + *response_length_out = response_length; + return status; +} + +cbm_daemon_runtime_application_status_t +cbm_daemon_runtime_client_application_request( + cbm_daemon_runtime_client_t *client, const void *request, + uint32_t request_length, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms) { + if (response_out) { + *response_out = NULL; + } + if (response_length_out) { + *response_length_out = 0; + } + if (!client || !response_out || !response_length_out || + timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || + request_length > CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX || + (request_length > 0 && !request)) { + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + cbm_daemon_runtime_application_token_t request_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + if (!cbm_daemon_runtime_client_application_token_reserve( + client, &request_token)) { + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + return cbm_daemon_runtime_client_application_request_tagged( + client, request_token, request, request_length, response_out, + response_length_out, timeout_ms); +} + +bool cbm_daemon_runtime_client_close_begin(cbm_daemon_runtime_client_t *client) { + if (!client) { + return false; + } + cbm_mutex_lock(&client->state_mutex); + if (client->closing) { + cbm_mutex_unlock(&client->state_mutex); + return false; + } + client->closing = true; + client->close_interrupted_exchange = client->exchange_active; + cbm_daemon_ipc_connection_t *connection = client->connection; + if (client->close_interrupted_exchange && connection) { + cbm_daemon_ipc_connection_interrupt(connection); + } + cbm_mutex_unlock(&client->state_mutex); + return true; +} + +bool cbm_daemon_runtime_client_close_finish(cbm_daemon_runtime_client_t *client, + uint32_t timeout_ms) { + if (!client) { + return false; + } + cbm_mutex_lock(&client->state_mutex); + bool closing = client->closing; + cbm_mutex_unlock(&client->state_mutex); + if (!closing) { + return false; + } + + cbm_mutex_lock(&client->exchange_mutex); + bool acknowledged = false; + cbm_mutex_lock(&client->state_mutex); + bool can_disconnect = timeout_ms != CBM_DAEMON_IPC_WAIT_FOREVER && + !client->close_interrupted_exchange && client->usable && + client->connection != NULL; + cbm_daemon_ipc_connection_t *connection = client->connection; + cbm_mutex_unlock(&client->state_mutex); + bool disconnect_sent = false; + if (can_disconnect) { + cbm_mutex_lock(&client->send_mutex); + disconnect_sent = cbm_daemon_ipc_send_frame( + connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_DISCONNECT, NULL, 0); + cbm_mutex_unlock(&client->send_mutex); + } + if (disconnect_sent) { + cbm_daemon_frame_t frame = {0}; + uint8_t *response = NULL; + int received = cbm_daemon_ipc_receive_frame( + connection, timeout_ms, &frame, &response); + acknowledged = + received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == CBM_DAEMON_RUNTIME_OP_DISCONNECT && + frame.length == STATUS_RESPONSE_SIZE && response && + runtime_get_u32(response) == 1; + free(response); + } + + cbm_mutex_lock(&client->state_mutex); + connection = client->connection; + client->connection = NULL; + client->usable = false; + client->exchange_active = false; + cbm_mutex_unlock(&client->state_mutex); + cbm_daemon_ipc_connection_close(connection); + cbm_mutex_unlock(&client->exchange_mutex); + cbm_mutex_destroy(&client->state_mutex); + cbm_mutex_destroy(&client->send_mutex); + cbm_mutex_destroy(&client->exchange_mutex); + free(client); + return acknowledged; +} + +bool cbm_daemon_runtime_client_close(cbm_daemon_runtime_client_t *client, + uint32_t timeout_ms) { + if (!cbm_daemon_runtime_client_close_begin(client)) { + return false; + } + return cbm_daemon_runtime_client_close_finish(client, timeout_ms); +} diff --git a/src/daemon/runtime.h b/src/daemon/runtime.h new file mode 100644 index 000000000..838a18a98 --- /dev/null +++ b/src/daemon/runtime.h @@ -0,0 +1,370 @@ +/* + * runtime.h — Mandatory per-account CBM daemon runtime. + * + * This layer combines the authenticated local IPC transport, exact-build + * HELLO policy, and connection-owned coordinator leases. It deliberately + * does not spawn the daemon process; executable bootstrap is a caller concern. + */ +#ifndef CBM_DAEMON_RUNTIME_H +#define CBM_DAEMON_RUNTIME_H + +#include "daemon/daemon.h" +#include "daemon/ipc.h" +#include "daemon/service.h" + +#include +#include +#include + +/* Detailed post-admission operation ABI. It is deliberately absent from the + * stable endpoint HELLO: an exact executable fingerprint already selects this + * layout, while conflicting generations must remain able to diagnose each + * other even when this value and every detailed payload have changed. */ +#define CBM_DAEMON_RUNTIME_WIRE_ABI 1U + +/* Permanent account-wide rendezvous envelope, generation zero. These numeric + * capacities and byte sizes are frozen independently of service/runtime data + * structures. A future generation must preserve both request and response + * layouts exactly at the stable endpoint. + * + * Request: u32 ABI @0, version[64] @4, build[65] @68. + * Response: u32 connect @0, u32 hello @4, u64 client @8, u64 PID @16, + * u32 conflict @24, active version[64] @28, active build[65] @92, + * requested version[64] @157, requested build[65] @221, + * message[512] @286. All integers use network byte order. */ +#define CBM_DAEMON_RENDEZVOUS_ABI 1U +#define CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP 64U +#define CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP 65U +#define CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP 512U +#define CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE 133U +#define CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE 798U +/* Cross-version activation shutdown is a separate first-frame protocol which + * remains parseable when normal HELLO would report a version/build conflict. + * Request: u32 action @0 followed by the unchanged 133-byte rendezvous + * identity @4. Response: u32 rendezvous ABI @0, u32 accepted @4, u64 active + * client snapshot @8, u64 drained-connection snapshot @16 (the activation + * requester excludes itself). */ +#define CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE 137U +#define CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE 24U +#define CBM_DAEMON_RUNTIME_PROJECT_KEY_MAX 4096U +#define CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX \ + (CBM_DAEMON_MAX_FRAME_SIZE - 16U) + +/* Frame flags are operation codes. Every operation has one exact payload + * length (or an explicitly length-prefixed payload); trailing bytes are a + * protocol violation and close the connection without registering a client. + * Multi-byte integers are encoded in network byte order. */ +typedef enum { + CBM_DAEMON_RUNTIME_OP_HELLO = 1, + CBM_DAEMON_RUNTIME_OP_HEARTBEAT = 2, + CBM_DAEMON_RUNTIME_OP_JOB_SUBSCRIBE = 3, + CBM_DAEMON_RUNTIME_OP_JOB_UNSUBSCRIBE = 4, + CBM_DAEMON_RUNTIME_OP_DISCONNECT = 5, + CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST = 6, + CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL = 7, + CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN = 8, +} cbm_daemon_runtime_operation_t; + +typedef enum { + CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL = 1, + CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE = 2, + CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL = 3, +} cbm_daemon_runtime_activation_action_t; + +typedef struct { + bool accepted; + uint64_t active_clients; + uint64_t active_connections; +} cbm_daemon_runtime_activation_result_t; + +typedef struct cbm_daemon_runtime_service cbm_daemon_runtime_service_t; +typedef struct cbm_daemon_runtime_client cbm_daemon_runtime_client_t; + +typedef enum { + CBM_DAEMON_RUNTIME_SERVICE_STARTING = 1, + CBM_DAEMON_RUNTIME_SERVICE_RUNNING = 2, + CBM_DAEMON_RUNTIME_SERVICE_STOPPING = 3, + CBM_DAEMON_RUNTIME_SERVICE_EXITED = 4, +} cbm_daemon_runtime_service_state_t; + +/* Application requests are binary-safe and bounded. TRANSPORT_ERROR is local + * to the client API and is never emitted as a valid wire response. BUSY is + * generated by the runtime when a connection already has an in-flight request; + * CANCELLED is the correlated, request-scoped cancellation result. */ +typedef enum { + CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR = 0, + CBM_DAEMON_RUNTIME_APPLICATION_OK = 1, + CBM_DAEMON_RUNTIME_APPLICATION_BUSY = 2, + CBM_DAEMON_RUNTIME_APPLICATION_UNAVAILABLE = 3, + CBM_DAEMON_RUNTIME_APPLICATION_REJECTED = 4, + CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR = 5, + CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED = 6, +} cbm_daemon_runtime_application_status_t; + +typedef uint64_t cbm_daemon_runtime_application_token_t; +#define CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID UINT64_C(0) + +typedef enum { + CBM_DAEMON_RUNTIME_CANCEL_ERROR = -1, + CBM_DAEMON_RUNTIME_CANCEL_STALE = 0, + CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED = 1, +} cbm_daemon_runtime_cancel_result_t; + +typedef void cbm_daemon_runtime_application_session_t; + +typedef cbm_daemon_runtime_application_session_t * +(*cbm_daemon_runtime_application_session_open_fn)( + void *context, cbm_daemon_client_id_t client_id, + uint64_t authenticated_process_id); + +/* For OK, response_out may receive a malloc-owned binary buffer which the + * runtime frees after sending; NULL is valid only for a zero-length response. + * Non-OK results must leave an empty response. The request buffer is an owned + * runtime copy and remains valid only for the duration of this callback. */ +typedef cbm_daemon_runtime_application_status_t +(*cbm_daemon_runtime_application_request_fn)( + void *context, cbm_daemon_runtime_application_session_t *session, + cbm_daemon_runtime_application_token_t request_token, + const uint8_t *request, uint32_t request_length, uint8_t **response_out, + uint32_t *response_length_out); + +/* Request cancellation is non-terminal and may arrive before request() enters. + * The exact token must therefore remain sticky until that request observes it. + * The callback must be nonblocking and safe while request() is running. */ +typedef void (*cbm_daemon_runtime_application_request_cancel_fn)( + void *context, cbm_daemon_runtime_application_session_t *session, + cbm_daemon_runtime_application_token_t request_token); + +/* cancel is terminal for the session and is invoked after coordinator + * ownership is released. It may run before a newly-created request thread + * enters request(), so the cancellation state must remain sticky. It must be + * nonblocking, safe to invoke while request is running, and arrange for that + * request to return promptly. open and close are synchronous and must also + * return promptly; close is invoked exactly once after any request thread has + * joined. Runtime teardown is necessarily cooperative at this callback + * boundary; it never detaches or frees a still-running callback. */ +typedef void (*cbm_daemon_runtime_application_session_cancel_fn)( + void *context, cbm_daemon_runtime_application_session_t *session); +typedef void (*cbm_daemon_runtime_application_session_close_fn)( + void *context, cbm_daemon_runtime_application_session_t *session); + +typedef struct { + void *context; + cbm_daemon_runtime_application_session_open_fn session_open; + cbm_daemon_runtime_application_request_fn request; + cbm_daemon_runtime_application_request_cancel_fn request_cancel; + cbm_daemon_runtime_application_session_cancel_fn session_cancel; + cbm_daemon_runtime_application_session_close_fn session_close; +} cbm_daemon_runtime_application_callbacks_t; + +typedef struct { + const cbm_daemon_ipc_endpoint_t *endpoint; + cbm_daemon_build_identity_t identity; + const char *conflict_log_path; + size_t conflict_log_cap_bytes; + /* Hard cap on accepted connection threads, including sockets that have not + * completed HELLO. request_timeout_ms bounds every unauthenticated slot + * and therefore must be finite, not CBM_DAEMON_IPC_WAIT_FOREVER. */ + uint32_t max_clients; + uint64_t lease_timeout_ms; + uint32_t request_timeout_ms; + /* Bounds cooperative stop and retained participant-guard cleanup. Must be + * finite, not CBM_DAEMON_IPC_WAIT_FOREVER. */ + uint32_t shutdown_timeout_ms; + /* Disabled when all handlers are NULL. Otherwise all five handlers are + * required. Function pointers and context are copied; the context's owner + * must keep it alive until service_free returns true. */ + cbm_daemon_runtime_application_callbacks_t application; +} cbm_daemon_runtime_service_config_t; + +typedef enum { + CBM_DAEMON_RUNTIME_CONNECT_ERROR = 0, + CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED = 1, + CBM_DAEMON_RUNTIME_CONNECT_CONFLICT = 2, + CBM_DAEMON_RUNTIME_CONNECT_REJECTED = 3, +} cbm_daemon_runtime_connect_status_t; + +typedef struct { + cbm_daemon_runtime_connect_status_t status; + cbm_daemon_hello_status_t hello_status; + cbm_daemon_client_id_t client_id; + /* Kernel-authenticated PID of this client as observed by the daemon. */ + uint64_t authenticated_process_id; + cbm_daemon_conflict_t conflict; + char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; +} cbm_daemon_runtime_connect_result_t; + +/* The fixed HELLO request contains only rendezvous ABI, semantic version, and + * exact executable SHA-256. It has no detailed ABI, client-ID, PID, user, or + * endpoint fields. Strings are NUL-terminated and zero padded. Encoding and + * validation depend only on these stable fields; detailed identity fields may + * be zero or unknown without changing the bytes. */ +bool cbm_daemon_runtime_hello_request_encode( + uint8_t out[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE], + const cbm_daemon_build_identity_t *identity); + +/* Resolve process_id through the OS kernel's process metadata and hash the + * executable image rather than reopening an unbound pathname. Linux hashes an + * open /proc//exe handle anchored to one process instance; macOS accepts + * an opened path only while its vnode is present in that process's executable + * mappings before and after hashing. Windows holds one process instance and a + * stable, non-share-write image-file handle across the hash, with path and + * creation-time snapshots around it (the Windows loader's image-section + * replacement restriction supplies the final path-to-mapping binding). + * Unsupported, inaccessible, replaced, or otherwise raced images fail closed. + * The daemon hashes and retains its stable native image object at startup; + * HELLO first compares a bracketed peer's mapped-image file identity with that + * retained object. A provable identity match avoids re-hashing. Different or + * unverifiable identities fall back to this full process-image fingerprint so + * byte-identical copies remain compatible while changed copies are rejected. + * Stateful HELLO admission requires the proven or computed fingerprint to + * match both the claimed and active build fingerprints. */ +bool cbm_daemon_runtime_process_build_fingerprint( + uint64_t process_id, char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]); + +/* Ask any current daemon generation to drain before install/update/uninstall. + * This is not a normal HELLO and never creates an application session. The + * kernel-authenticated peer image must match identity->build_fingerprint, but + * that build is deliberately allowed to differ from the active daemon. A true + * return means a fixed response was received; inspect result_out->accepted. + * timeout_ms must be finite. */ +bool cbm_daemon_runtime_request_activation_shutdown( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, + cbm_daemon_runtime_activation_action_t action, uint32_t timeout_ms, + cbm_daemon_runtime_activation_result_t *result_out); + +/* Performs the complete guarded first-participant handoff, starts listening + * synchronously, then owns both that participant claim and its + * accept/connection threads. All config scalar/text data is copied. endpoint + * is borrowed only for this synchronous call and may be freed after a + * successful return. Only one service may listen at an endpoint. */ +cbm_daemon_runtime_service_t *cbm_daemon_runtime_service_start( + const cbm_daemon_runtime_service_config_t *config); + +/* Host-start variant for preparing inert application resources before the + * listener. The caller must acquire the endpoint lifetime reservation first; + * this lets the host defer watcher/UI/diagnostic threads until the call has + * succeeded. A successful start transfers the reservation into the service + * and clears *reservation_io. On failure it remains owned by the caller unless + * the service had already adopted it and released it during rollback. */ +cbm_daemon_runtime_service_t *cbm_daemon_runtime_service_start_reserved( + const cbm_daemon_runtime_service_config_t *config, + cbm_daemon_ipc_lifetime_reservation_t **reservation_io); + +cbm_daemon_runtime_service_state_t +cbm_daemon_runtime_service_state(cbm_daemon_runtime_service_t *service); +size_t cbm_daemon_runtime_service_active_clients(cbm_daemon_runtime_service_t *service); +/* Includes accepted connections still waiting for HELLO. Never exceeds the + * configured max_clients; over-cap peers receive REJECTED before close. */ +size_t cbm_daemon_runtime_service_active_connections( + cbm_daemon_runtime_service_t *service); +size_t cbm_daemon_runtime_service_job_subscribers(cbm_daemon_runtime_service_t *service, + const char *project_key); +uint64_t cbm_daemon_runtime_service_client_process_id( + cbm_daemon_runtime_service_t *service, cbm_daemon_client_id_t client_id); + +/* Wait functions use a monotonic deadline and never sleep past timeout_ms. */ +bool cbm_daemon_runtime_service_wait_for_clients(cbm_daemon_runtime_service_t *service, + size_t expected, uint32_t timeout_ms); +bool cbm_daemon_runtime_service_wait_for_connections( + cbm_daemon_runtime_service_t *service, size_t expected, uint32_t timeout_ms); +bool cbm_daemon_runtime_service_wait_exited(cbm_daemon_runtime_service_t *service, + uint32_t timeout_ms); + +/* Worker supervisors report the end of two-phase cancellation through this + * API. The service cannot exit while a cancelled job remains unreaped. */ +bool cbm_daemon_runtime_service_job_reaped(cbm_daemon_runtime_service_t *service, + const char *project_key); + +/* Emergency/test teardown only. Normal lifetime is connection-owned: the + * final disconnect makes STOPPING terminal, drains/reaps within the configured + * bound, and exits automatically. stop is itself bounded by timeout_ms. */ +bool cbm_daemon_runtime_service_stop(cbm_daemon_runtime_service_t *service, + uint32_t timeout_ms); +/* Before wait_exited or a successful stop this returns false without teardown. + * Otherwise it returns true only after every thread is joined, the owned + * participant guard is released, and the service allocation is destroyed. A + * false return retains the service and all retry authority; the caller must + * retry or fail-stop the owning process. */ +bool cbm_daemon_runtime_service_free(cbm_daemon_runtime_service_t *service); + +/* A successful return is connection-bound. The daemon delivers an explicit + * mismatch result (and persists it) before closing a rejected connection. + * Client exchange timeouts must be finite, not CBM_DAEMON_IPC_WAIT_FOREVER. */ +cbm_daemon_runtime_client_t *cbm_daemon_runtime_client_connect( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, uint32_t timeout_ms, + cbm_daemon_runtime_connect_result_t *result_out); + +cbm_daemon_client_id_t +cbm_daemon_runtime_client_id(const cbm_daemon_runtime_client_t *client); +uint64_t cbm_daemon_runtime_client_process_id( + const cbm_daemon_runtime_client_t *client); + +cbm_daemon_subscription_result_t cbm_daemon_runtime_client_job_subscribe( + cbm_daemon_runtime_client_t *client, const char *project_key, + cbm_daemon_subscription_id_t *subscription_id_out, uint32_t timeout_ms); +bool cbm_daemon_runtime_client_job_unsubscribe( + cbm_daemon_runtime_client_t *client, + cbm_daemon_subscription_id_t subscription_id, uint32_t timeout_ms); +bool cbm_daemon_runtime_client_heartbeat(cbm_daemon_runtime_client_t *client, + uint32_t timeout_ms); + +/* Reserve a monotonically increasing token for the next application request. + * Only one unstarted reservation may exist per client. It must be consumed by + * request_tagged() (or the client must be closed) before another reservation. + * Tokens are never reused within a client connection. */ +bool cbm_daemon_runtime_client_application_token_reserve( + cbm_daemon_runtime_client_t *client, + cbm_daemon_runtime_application_token_t *token_out); + +/* Send a one-way cancellation control for an exact reserved/active request. + * ACCEPTED means the control was queued or written; callback completion is the + * final cancellation linearization point. Stale/wrong tokens are harmless. */ +cbm_daemon_runtime_cancel_result_t +cbm_daemon_runtime_client_application_cancel( + cbm_daemon_runtime_client_t *client, + cbm_daemon_runtime_application_token_t request_token); + +/* Executes one binary application request. Calls on one client are serialized. + * response_out is malloc-owned on OK and must be freed by the caller; an empty + * OK response is represented by NULL/0. Invalid/oversized requests are rejected + * locally without poisoning an otherwise usable client. */ +cbm_daemon_runtime_application_status_t +cbm_daemon_runtime_client_application_request( + cbm_daemon_runtime_client_t *client, const void *request, + uint32_t request_length, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms); + +/* Execute using the sole outstanding token returned by token_reserve(). This + * is the cancellable frontend path; the legacy helper above reserves + * internally. */ +cbm_daemon_runtime_application_status_t +cbm_daemon_runtime_client_application_request_tagged( + cbm_daemon_runtime_client_t *client, + cbm_daemon_runtime_application_token_t request_token, + const void *request, uint32_t request_length, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms); + +/* Begin a two-phase close without freeing the client. This atomically rejects + * future exchanges and interrupts an exchange already in flight. Exactly one + * caller may begin close. After all threads that could have entered a client + * API are joined, close_finish closes/frees the retained handle. */ +bool cbm_daemon_runtime_client_close_begin(cbm_daemon_runtime_client_t *client); + +/* Finish a close begun by close_begin. Sends DISCONNECT when the transport was + * not interrupted, always closes/frees the local handle, and returns whether + * the daemon acknowledged DISCONNECT. The client is invalid after this call. */ +bool cbm_daemon_runtime_client_close_finish(cbm_daemon_runtime_client_t *client, + uint32_t timeout_ms); + +/* One-call convenience wrapper around close_begin + close_finish. Ownership on + * the server is tied to EOF as well as the explicit operation. This remains + * safe with one exchange that was already in flight, but callers with a worker + * that may be immediately about to enter an exchange must use the two-phase API + * and join that worker before close_finish. */ +bool cbm_daemon_runtime_client_close(cbm_daemon_runtime_client_t *client, + uint32_t timeout_ms); + +#endif /* CBM_DAEMON_RUNTIME_H */ diff --git a/src/daemon/service.c b/src/daemon/service.c new file mode 100644 index 000000000..6219c38f4 --- /dev/null +++ b/src/daemon/service.c @@ -0,0 +1,978 @@ +/* + * service.c — Stable daemon rendezvous, exact-build policy, and conflict log. + */ +#include "daemon/service.h" + +#include "daemon/service_internal.h" +#ifdef _WIN32 +#include "daemon/ipc.h" +#endif + +#include "foundation/sha256.h" + +#include +#include +#include +#include +#include +#include + +enum { + DAEMON_SERVICE_PATH_CAP = 4096, + DAEMON_SERVICE_IO_CAP = 64 * 1024, + DAEMON_SERVICE_ESCAPED_VERSION_CAP = + (CBM_DAEMON_VERSION_TEXT_SIZE - 1) * 6 + 1, + DAEMON_SERVICE_LOG_RECORD_CAP = 1536, +}; + +static cbm_daemon_conflict_log_test_hook_fn g_conflict_log_test_hook; +static void *g_conflict_log_test_context; + +void cbm_daemon_conflict_log_set_test_hook( + cbm_daemon_conflict_log_test_hook_fn hook, void *context) { + g_conflict_log_test_context = context; + g_conflict_log_test_hook = hook; +} + +static void conflict_log_test_hook(cbm_daemon_conflict_log_test_stage_t stage) { + if (g_conflict_log_test_hook) { + g_conflict_log_test_hook(g_conflict_log_test_context, stage); + } +} + +static bool bounded_length(const char *value, size_t cap, size_t *length_out) { + if (!value || cap == 0) { + return false; + } + for (size_t i = 0; i < cap; i++) { + if (value[i] == '\0') { + if (length_out) { + *length_out = i; + } + return true; + } + } + return false; +} + +static bool version_valid(const char *version) { + size_t length = 0; + if (!bounded_length(version, CBM_DAEMON_VERSION_TEXT_SIZE, &length) || length == 0) { + return false; + } + /* Version text reaches stderr and a persistent JSON log. Keep it printable + * ASCII so an untrusted HELLO cannot inject terminal control sequences or + * malformed UTF-8 into either diagnostic channel. */ + for (size_t i = 0; i < length; i++) { + unsigned char ch = (unsigned char)version[i]; + if (ch < 0x20 || ch > 0x7e) { + return false; + } + } + return true; +} + +static bool fingerprint_valid(const char *fingerprint) { + size_t length = 0; + if (!bounded_length(fingerprint, CBM_DAEMON_BUILD_FINGERPRINT_SIZE, &length) || + length != CBM_SHA256_HEX_LEN) { + return false; + } + for (size_t i = 0; i < length; i++) { + char ch = fingerprint[i]; + if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) { + return false; + } + } + return true; +} + +static bool identity_valid(const cbm_daemon_build_identity_t *identity) { + return identity && version_valid(identity->semantic_version) && + fingerprint_valid(identity->build_fingerprint) && identity->protocol_abi != 0 && + identity->store_abi != 0 && identity->feature_abi != 0; +} + +static bool conflict_status_valid(cbm_daemon_hello_status_t status) { + return status >= CBM_DAEMON_HELLO_VERSION_CONFLICT && + status <= CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT; +} + +static bool conflict_valid(const cbm_daemon_conflict_t *conflict) { + return conflict && conflict_status_valid(conflict->status) && + version_valid(conflict->active_version) && + fingerprint_valid(conflict->active_build_fingerprint) && + version_valid(conflict->requested_version) && + fingerprint_valid(conflict->requested_build_fingerprint); +} + +static const char *conflict_reason(cbm_daemon_hello_status_t status) { + switch (status) { + case CBM_DAEMON_HELLO_VERSION_CONFLICT: + return "version"; + case CBM_DAEMON_HELLO_BUILD_CONFLICT: + return "build"; + case CBM_DAEMON_HELLO_PROTOCOL_ABI_CONFLICT: + return "protocol_abi"; + case CBM_DAEMON_HELLO_STORE_ABI_CONFLICT: + return "store_abi"; + case CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT: + return "feature_abi"; + default: + return NULL; + } +} + +static void digest_to_hex(const uint8_t digest[CBM_SHA256_DIGEST_LEN], + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + static const char hex[] = "0123456789abcdef"; + for (size_t i = 0; i < CBM_SHA256_DIGEST_LEN; i++) { + out[i * 2] = hex[digest[i] >> 4]; + out[i * 2 + 1] = hex[digest[i] & 0x0f]; + } + out[CBM_SHA256_HEX_LEN] = '\0'; +} + +bool cbm_daemon_rendezvous_key(char out[CBM_DAEMON_KEY_SIZE]) { + if (!out) { + return false; + } + /* This product-domain string is intentionally the only key input. Account + * isolation comes from the authenticated IPC runtime, not spoofable text. */ + static const unsigned char domain[] = "codebase-memory-mcp:coordination-daemon"; + uint64_t hash = 14695981039346656037ULL; + for (size_t i = 0; i < sizeof(domain) - 1; i++) { + hash ^= domain[i]; + hash *= 1099511628211ULL; + } + int written = snprintf(out, CBM_DAEMON_KEY_SIZE, "%016llx", (unsigned long long)hash); + if (written != (int)CBM_DAEMON_KEY_SIZE - 1) { + out[0] = '\0'; + return false; + } + return true; +} + +cbm_daemon_hello_status_t +cbm_daemon_hello_compare(const cbm_daemon_build_identity_t *active, + const cbm_daemon_build_identity_t *requested, + cbm_daemon_conflict_t *conflict_out) { + if (conflict_out) { + memset(conflict_out, 0, sizeof(*conflict_out)); + conflict_out->status = CBM_DAEMON_HELLO_INVALID; + } + if (!conflict_out || !identity_valid(active) || !identity_valid(requested)) { + return CBM_DAEMON_HELLO_INVALID; + } + + (void)snprintf(conflict_out->active_version, sizeof(conflict_out->active_version), "%s", + active->semantic_version); + (void)snprintf(conflict_out->active_build_fingerprint, + sizeof(conflict_out->active_build_fingerprint), "%s", + active->build_fingerprint); + (void)snprintf(conflict_out->requested_version, sizeof(conflict_out->requested_version), "%s", + requested->semantic_version); + (void)snprintf(conflict_out->requested_build_fingerprint, + sizeof(conflict_out->requested_build_fingerprint), "%s", + requested->build_fingerprint); + + cbm_daemon_hello_status_t status = CBM_DAEMON_HELLO_COMPATIBLE; + if (strcmp(active->semantic_version, requested->semantic_version) != 0) { + status = CBM_DAEMON_HELLO_VERSION_CONFLICT; + } else if (strcmp(active->build_fingerprint, requested->build_fingerprint) != 0) { + status = CBM_DAEMON_HELLO_BUILD_CONFLICT; + } else if (active->protocol_abi != requested->protocol_abi) { + status = CBM_DAEMON_HELLO_PROTOCOL_ABI_CONFLICT; + } else if (active->store_abi != requested->store_abi) { + status = CBM_DAEMON_HELLO_STORE_ABI_CONFLICT; + } else if (active->feature_abi != requested->feature_abi) { + status = CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT; + } + conflict_out->status = status; + return status; +} + +bool cbm_daemon_conflict_format(const cbm_daemon_conflict_t *conflict, char *out, + size_t out_size) { + if (!out || out_size == 0) { + return false; + } + out[0] = '\0'; + const char *reason = conflict ? conflict_reason(conflict->status) : NULL; + if (!reason || !conflict_valid(conflict)) { + return false; + } + int written = snprintf( + out, out_size, + "CBM could not start because a conflicting CBM process is active " + "(%s; active version %s, build %s; requested version %s, build %s). " + "Close all CBM sessions and commands, then retry.", + reason, conflict->active_version, conflict->active_build_fingerprint, + conflict->requested_version, conflict->requested_build_fingerprint); + if (written < 0 || (size_t)written >= out_size) { + out[0] = '\0'; + return false; + } + return true; +} + +static bool json_escape_version(const char *value, + char out[DAEMON_SERVICE_ESCAPED_VERSION_CAP]) { + static const char hex[] = "0123456789abcdef"; + size_t length = 0; + if (!version_valid(value) || + !bounded_length(value, CBM_DAEMON_VERSION_TEXT_SIZE, &length)) { + return false; + } + size_t used = 0; + for (size_t i = 0; i < length; i++) { + unsigned char ch = (unsigned char)value[i]; + if (ch == '"' || ch == '\\') { + if (used + 2 >= DAEMON_SERVICE_ESCAPED_VERSION_CAP) { + return false; + } + out[used++] = '\\'; + out[used++] = (char)ch; + } else if (ch < 0x20) { + if (used + 6 >= DAEMON_SERVICE_ESCAPED_VERSION_CAP) { + return false; + } + out[used++] = '\\'; + out[used++] = 'u'; + out[used++] = '0'; + out[used++] = '0'; + out[used++] = hex[ch >> 4]; + out[used++] = hex[ch & 0x0f]; + } else { + if (used + 1 >= DAEMON_SERVICE_ESCAPED_VERSION_CAP) { + return false; + } + out[used++] = (char)ch; + } + } + out[used] = '\0'; + return true; +} + +static bool conflict_log_record(const cbm_daemon_conflict_t *conflict, + char out[DAEMON_SERVICE_LOG_RECORD_CAP], size_t *length_out) { + char active[DAEMON_SERVICE_ESCAPED_VERSION_CAP]; + char requested[DAEMON_SERVICE_ESCAPED_VERSION_CAP]; + const char *reason = conflict ? conflict_reason(conflict->status) : NULL; + if (!length_out || !reason || !conflict_valid(conflict) || + !json_escape_version(conflict->active_version, active) || + !json_escape_version(conflict->requested_version, requested)) { + return false; + } + time_t timestamp = time(NULL); + if (timestamp == (time_t)-1) { + return false; + } + int written = snprintf( + out, DAEMON_SERVICE_LOG_RECORD_CAP, + "{\"event\":\"daemon.version_conflict\",\"timestamp_unix_s\":%lld," + "\"reason\":\"%s\"," + "\"active_version\":\"%s\",\"active_build\":\"%s\"," + "\"requested_version\":\"%s\",\"requested_build\":\"%s\"}\n", + (long long)timestamp, reason, active, conflict->active_build_fingerprint, requested, + conflict->requested_build_fingerprint); + if (written < 0 || written >= DAEMON_SERVICE_LOG_RECORD_CAP) { + return false; + } + *length_out = (size_t)written; + return true; +} + +#ifndef _WIN32 + +#include +#include +#include +#include +#include + +static bool fd_cloexec(int fd) { + int flags = fcntl(fd, F_GETFD); + return flags >= 0 && (flags & FD_CLOEXEC || fcntl(fd, F_SETFD, flags | FD_CLOEXEC) == 0); +} + +static bool fd_regular_current_user(int fd, struct stat *status_out) { + struct stat status; + if (fstat(fd, &status) != 0 || !S_ISREG(status.st_mode) || status.st_uid != geteuid() || + status.st_nlink != 1) { + return false; + } + if (status_out) { + *status_out = status; + } + return true; +} + +static bool fd_regular(int fd, struct stat *status_out) { + struct stat status; + if (fstat(fd, &status) != 0 || !S_ISREG(status.st_mode)) { + return false; + } + if (status_out) { + *status_out = status; + } + return true; +} + +bool cbm_daemon_build_fingerprint_native_file( + uintptr_t native_file, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!out) { + return false; + } + out[0] = '\0'; + if (native_file > INT_MAX) { + return false; + } + int fd = (int)native_file; + struct stat before; + if (fd < 0 || !fd_regular(fd, &before) || before.st_size < 0) { + return false; + } + + cbm_sha256_ctx context; + cbm_sha256_init(&context); + unsigned char buffer[DAEMON_SERVICE_IO_CAP]; + off_t offset = 0; + bool ok = true; + while (offset < before.st_size) { + off_t remaining = before.st_size - offset; + size_t request = remaining < (off_t)sizeof(buffer) + ? (size_t)remaining + : sizeof(buffer); + ssize_t count = pread(fd, buffer, request, offset); + if (count > 0) { + cbm_sha256_update(&context, buffer, (size_t)count); + offset += (off_t)count; + } else if (count == 0) { + ok = false; + break; + } else if (errno != EINTR) { + ok = false; + break; + } + } + struct stat after; + ok = ok && fstat(fd, &after) == 0 && before.st_dev == after.st_dev && + before.st_ino == after.st_ino && before.st_size == after.st_size && + offset == after.st_size; + if (!ok) { + return false; + } + uint8_t digest[CBM_SHA256_DIGEST_LEN]; + cbm_sha256_final(&context, digest); + digest_to_hex(digest, out); + return true; +} + +bool cbm_daemon_build_fingerprint_file( + const char *path, char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!out) { + return false; + } + out[0] = '\0'; + size_t path_length = 0; + if (!bounded_length(path, DAEMON_SERVICE_PATH_CAP, &path_length) || path_length == 0) { + return false; + } + int fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + bool ok = fd >= 0 && fd_cloexec(fd) && + cbm_daemon_build_fingerprint_native_file((uintptr_t)fd, out); + if (fd >= 0 && close(fd) != 0) { + ok = false; + } + if (!ok) { + out[0] = '\0'; + } + return ok; +} + +typedef struct { + int dir_fd; + char storage[DAEMON_SERVICE_PATH_CAP]; + char *base; + char rotated[NAME_MAX + 3]; + char lock[NAME_MAX + 1]; +} posix_log_path_t; + +static void posix_log_path_close(posix_log_path_t *path) { + if (path && path->dir_fd >= 0) { + (void)close(path->dir_fd); + path->dir_fd = -1; + } +} + +static bool posix_log_path_open(const char *log_path, posix_log_path_t *path) { + size_t length = 0; + if (!path || !bounded_length(log_path, sizeof(path->storage), &length) || length == 0) { + return false; + } + memset(path, 0, sizeof(*path)); + path->dir_fd = -1; + memcpy(path->storage, log_path, length + 1); + char *slash = strrchr(path->storage, '/'); + const char *parent = "."; + if (slash) { + path->base = slash + 1; + if (slash == path->storage) { + parent = "/"; + } else { + *slash = '\0'; + parent = path->storage; + } + } else { + path->base = path->storage; + } + size_t base_length = strlen(path->base); + if (base_length == 0 || base_length > NAME_MAX - 5 || strcmp(path->base, ".") == 0 || + strcmp(path->base, "..") == 0) { + return false; + } + int rotated_written = snprintf(path->rotated, sizeof(path->rotated), "%s.1", path->base); + int lock_written = snprintf(path->lock, sizeof(path->lock), "%s.lock", path->base); + if (rotated_written < 0 || (size_t)rotated_written >= sizeof(path->rotated) || + lock_written < 0 || (size_t)lock_written >= sizeof(path->lock)) { + return false; + } + path->dir_fd = open(parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + struct stat parent_status; + if (path->dir_fd < 0 || !fd_cloexec(path->dir_fd) || + fstat(path->dir_fd, &parent_status) != 0 || !S_ISDIR(parent_status.st_mode) || + parent_status.st_uid != geteuid() || (parent_status.st_mode & 0022) != 0) { + posix_log_path_close(path); + return false; + } + return true; +} + +static int posix_lock_file_open(const posix_log_path_t *path, bool *created_out) { + int flags = O_RDWR | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK; + int fd = openat(path->dir_fd, path->lock, flags | O_CREAT | O_EXCL, 0600); + bool created = fd >= 0; + if (fd < 0 && errno == EEXIST) { + fd = openat(path->dir_fd, path->lock, flags); + } + struct stat status; + struct stat by_path; + if (fd < 0 || !fd_cloexec(fd) || !fd_regular_current_user(fd, &status) || + fchmod(fd, 0600) != 0 || + fstatat(path->dir_fd, path->lock, &by_path, AT_SYMLINK_NOFOLLOW) != 0 || + !S_ISREG(by_path.st_mode) || by_path.st_uid != geteuid() || + by_path.st_nlink != 1 || status.st_dev != by_path.st_dev || + status.st_ino != by_path.st_ino) { + if (fd >= 0) { + (void)close(fd); + } + return -1; + } + *created_out = created; + return fd; +} + +static bool posix_lock_exclusive(int fd) { + while (flock(fd, LOCK_EX) != 0) { + if (errno != EINTR) { + return false; + } + } + return true; +} + +static int posix_log_file_open(const posix_log_path_t *path, struct stat *status_out, + bool *created_out) { + int flags = O_RDWR | O_APPEND | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK; + int fd = openat(path->dir_fd, path->base, flags | O_CREAT | O_EXCL, 0600); + bool created = fd >= 0; + if (fd < 0 && errno == EEXIST) { + fd = openat(path->dir_fd, path->base, flags); + } + struct stat status; + struct stat by_path; + if (fd < 0 || !fd_cloexec(fd) || !fd_regular_current_user(fd, &status) || + fchmod(fd, 0600) != 0 || + fstatat(path->dir_fd, path->base, &by_path, AT_SYMLINK_NOFOLLOW) != 0 || + !S_ISREG(by_path.st_mode) || status.st_dev != by_path.st_dev || + status.st_ino != by_path.st_ino) { + if (fd >= 0) { + (void)close(fd); + } + return -1; + } + *status_out = status; + *created_out = created; + return fd; +} + +static bool posix_complete_write(int fd, const char *record, size_t record_length, + off_t original_size) { + size_t written = 0; + while (written < record_length) { + ssize_t count = write(fd, record + written, record_length - written); + if (count > 0) { + written += (size_t)count; + } else if (count < 0 && errno == EINTR) { + continue; + } else { + (void)ftruncate(fd, original_size); + return false; + } + } + while (fsync(fd) != 0) { + if (errno != EINTR) { + (void)ftruncate(fd, original_size); + return false; + } + } + return true; +} + +static bool posix_sync(int fd) { + for (;;) { + if (fsync(fd) == 0) { + return true; + } + if (errno != EINTR) { + return false; + } + } +} + +static bool posix_log_refresh_locked(const posix_log_path_t *path, int fd, + struct stat *status_out) { + struct stat current; + struct stat by_path; + if (!fd_regular_current_user(fd, ¤t) || current.st_size < 0 || + fstatat(path->dir_fd, path->base, &by_path, AT_SYMLINK_NOFOLLOW) != 0 || + !S_ISREG(by_path.st_mode) || current.st_dev != by_path.st_dev || + current.st_ino != by_path.st_ino) { + return false; + } + *status_out = current; + return true; +} + +static bool posix_log_append(const char *log_path, const char *record, size_t record_length, + size_t cap_bytes) { + posix_log_path_t path; + if (!posix_log_path_open(log_path, &path)) { + return false; + } + bool lock_created = false; + int lock_fd = posix_lock_file_open(&path, &lock_created); + if (lock_fd >= 0) { + conflict_log_test_hook(CBM_DAEMON_CONFLICT_LOG_BEFORE_SERIALIZATION_LOCK); + } + bool ok = lock_fd >= 0 && posix_lock_exclusive(lock_fd); + if (ok) { + conflict_log_test_hook(CBM_DAEMON_CONFLICT_LOG_AFTER_SERIALIZATION_LOCK); + } + struct stat status; + bool created = false; + int fd = ok ? posix_log_file_open(&path, &status, &created) : -1; + ok = ok && fd >= 0 && posix_log_refresh_locked(&path, fd, &status); + bool directory_changed = lock_created || created; + bool rotate = false; + if (ok && status.st_size > 0) { + uintmax_t size = (uintmax_t)status.st_size; + rotate = size > cap_bytes || record_length > cap_bytes - (size_t)size; + } + if (ok && rotate) { + ok = renameat(path.dir_fd, path.base, path.dir_fd, path.rotated) == 0; + ok = close(fd) == 0 && ok; + fd = -1; + directory_changed = directory_changed || ok; + if (ok) { + fd = posix_log_file_open(&path, &status, &created); + ok = fd >= 0 && posix_log_refresh_locked(&path, fd, &status); + directory_changed = directory_changed || created; + } + } + if (ok) { + char last = '\n'; + if (status.st_size > 0) { + ssize_t count; + do { + count = pread(fd, &last, 1, status.st_size - 1); + } while (count < 0 && errno == EINTR); + ok = count == 1 && last == '\n'; + } + if (ok) { + ok = posix_complete_write(fd, record, record_length, status.st_size); + } + } + if (fd >= 0) { + if (close(fd) != 0) { + ok = false; + } + } + if (lock_fd >= 0) { + (void)flock(lock_fd, LOCK_UN); + if (close(lock_fd) != 0) { + ok = false; + } + } + if (ok && directory_changed) { + ok = posix_sync(path.dir_fd); + } + posix_log_path_close(&path); + return ok; +} + +#else /* _WIN32 */ + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include + +#include "foundation/win_utf8.h" + +static bool windows_same_file_identity(const BY_HANDLE_FILE_INFORMATION *first, + const BY_HANDLE_FILE_INFORMATION *second) { + return first && second && + first->dwVolumeSerialNumber == second->dwVolumeSerialNumber && + first->nFileIndexHigh == second->nFileIndexHigh && + first->nFileIndexLow == second->nFileIndexLow; +} + +bool cbm_daemon_build_fingerprint_native_file( + uintptr_t native_file, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!out) { + return false; + } + out[0] = '\0'; + HANDLE original = (HANDLE)native_file; + if (!original || original == INVALID_HANDLE_VALUE) { + return false; + } + BY_HANDLE_FILE_INFORMATION original_info; + HANDLE file = ReOpenFile(original, GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_DELETE, + FILE_FLAG_SEQUENTIAL_SCAN); + BY_HANDLE_FILE_INFORMATION info; + LARGE_INTEGER before; + bool ok = GetFileType(original) == FILE_TYPE_DISK && + GetFileInformationByHandle(original, &original_info) != 0 && + (original_info.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + file != INVALID_HANDLE_VALUE && GetFileType(file) == FILE_TYPE_DISK && + GetFileInformationByHandle(file, &info) != 0 && + (info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == + 0 && + windows_same_file_identity(&original_info, &info) && + GetFileSizeEx(file, &before) != 0 && before.QuadPart >= 0; + cbm_sha256_ctx context; + cbm_sha256_init(&context); + unsigned char buffer[DAEMON_SERVICE_IO_CAP]; + uint64_t total = 0; + while (ok) { + DWORD count = 0; + if (!ReadFile(file, buffer, sizeof(buffer), &count, NULL)) { + ok = false; + } else if (count == 0) { + break; + } else { + cbm_sha256_update(&context, buffer, count); + total += count; + } + } + LARGE_INTEGER after; + BY_HANDLE_FILE_INFORMATION after_info; + ok = ok && GetFileSizeEx(file, &after) != 0 && + GetFileInformationByHandle(file, &after_info) != 0 && + windows_same_file_identity(&info, &after_info) && + before.QuadPart == after.QuadPart && total == (uint64_t)after.QuadPart; + if (file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(file); + } + if (!ok) { + return false; + } + uint8_t digest[CBM_SHA256_DIGEST_LEN]; + cbm_sha256_final(&context, digest); + digest_to_hex(digest, out); + return true; +} + +bool cbm_daemon_build_fingerprint_file( + const char *path, char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!out) { + return false; + } + out[0] = '\0'; + size_t path_length = 0; + if (!bounded_length(path, DAEMON_SERVICE_PATH_CAP, &path_length) || path_length == 0) { + return false; + } + wchar_t *wide = cbm_utf8_to_wide(path); + if (!wide) { + return false; + } + HANDLE file = CreateFileW(wide, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, NULL); + free(wide); + bool ok = file != INVALID_HANDLE_VALUE && + cbm_daemon_build_fingerprint_native_file((uintptr_t)file, out); + if (file != INVALID_HANDLE_VALUE && !CloseHandle(file)) { + ok = false; + } + if (!ok) { + out[0] = '\0'; + } + return ok; +} + +static bool windows_log_handle_valid(HANDLE file, LARGE_INTEGER *size_out) { + BY_HANDLE_FILE_INFORMATION info; + return file != INVALID_HANDLE_VALUE && GetFileType(file) == FILE_TYPE_DISK && + GetFileInformationByHandle(file, &info) != 0 && + (info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == + 0 && info.nNumberOfLinks == 1 && + GetFileSizeEx(file, size_out) != 0 && size_out->QuadPart >= 0; +} + +static HANDLE windows_log_open(const wchar_t *path, LARGE_INTEGER *size_out) { + /* windows_private_file_prepare creates or validates the file with IPC's + * explicit protected current-user DACL before this handle is opened. */ + HANDLE file = CreateFileW(path, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, NULL, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, + NULL); + if (!windows_log_handle_valid(file, size_out)) { + if (file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(file); + } + return INVALID_HANDLE_VALUE; + } + return file; +} + +typedef struct { + char storage[DAEMON_SERVICE_PATH_CAP]; + char *base; + char lock_base[DAEMON_SERVICE_PATH_CAP]; + wchar_t *wide; + wchar_t *rotated; + wchar_t *lock; +} windows_log_path_t; + +static void windows_log_path_close(windows_log_path_t *path) { + if (!path) { + return; + } + free(path->wide); + free(path->rotated); + free(path->lock); + memset(path, 0, sizeof(*path)); +} + +static bool windows_log_path_open(const char *log_path, windows_log_path_t *path) { + size_t length = 0; + if (!path || !bounded_length(log_path, sizeof(path->storage), &length) || length == 0 || + length + 5 >= sizeof(path->storage)) { + return false; + } + memset(path, 0, sizeof(*path)); + memcpy(path->storage, log_path, length + 1); + char *forward = strrchr(path->storage, '/'); + char *backward = strrchr(path->storage, '\\'); + char *slash = forward; + if (!slash || (backward && backward > slash)) { + slash = backward; + } + if (!slash || slash == path->storage || slash[1] == '\0') { + return false; + } + path->base = slash + 1; + *slash = '\0'; + size_t base_length = strlen(path->base); + int lock_written = snprintf(path->lock_base, sizeof(path->lock_base), "%s.lock", + path->base); + if (base_length == 0 || base_length > 248 || lock_written <= 0 || + (size_t)lock_written >= sizeof(path->lock_base)) { + return false; + } + + path->wide = cbm_utf8_to_wide(log_path); + if (!path->wide) { + return false; + } + size_t wide_length = wcslen(path->wide); + path->rotated = malloc((wide_length + 3) * sizeof(*path->rotated)); + path->lock = malloc((wide_length + 6) * sizeof(*path->lock)); + if (!path->rotated || !path->lock) { + windows_log_path_close(path); + return false; + } + memcpy(path->rotated, path->wide, wide_length * sizeof(*path->rotated)); + memcpy(path->rotated + wide_length, L".1", 3 * sizeof(*path->rotated)); + memcpy(path->lock, path->wide, wide_length * sizeof(*path->lock)); + memcpy(path->lock + wide_length, L".lock", 6 * sizeof(*path->lock)); + return true; +} + +static bool windows_private_file_prepare(const char *directory, const char *base) { + /* cbm_daemon_ipc_private_log_open supplies the same explicit protected + * current-SID DACL and reparse/owner checks as the daemon's operation log. + * Its brief FILE_SHARE_READ handle can collide with a concurrent opener, + * so retry that transient window rather than dropping a conflict event. */ + for (unsigned int attempt = 0; attempt < 100; attempt++) { + FILE *file = cbm_daemon_ipc_private_log_open(directory, base, SIZE_MAX); + if (file) { + return fclose(file) == 0; + } + Sleep(2); + } + return false; +} + +static HANDLE windows_lock_open(const wchar_t *path) { + for (unsigned int attempt = 0; attempt < 100; attempt++) { + HANDLE file = CreateFileW( + /* Read access is sufficient for LockFileEx. Keeping this handle + * read-only makes it share-compatible with the brief DACL repair + * handle, whose FILE_SHARE_READ still admits every live locker. */ + path, GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (file != INVALID_HANDLE_VALUE) { + LARGE_INTEGER size; + if (windows_log_handle_valid(file, &size)) { + (void)SetHandleInformation(file, HANDLE_FLAG_INHERIT, 0); + return file; + } + (void)CloseHandle(file); + return INVALID_HANDLE_VALUE; + } + DWORD error = GetLastError(); + if (error != ERROR_SHARING_VIOLATION && error != ERROR_LOCK_VIOLATION) { + return INVALID_HANDLE_VALUE; + } + Sleep(2); + } + return INVALID_HANDLE_VALUE; +} + +static bool windows_lock_exclusive(HANDLE file, OVERLAPPED *range) { + memset(range, 0, sizeof(*range)); + return LockFileEx(file, LOCKFILE_EXCLUSIVE_LOCK, 0, 1, 0, range) != 0; +} + +static bool windows_complete_write(HANDLE file, const char *record, size_t length, + LARGE_INTEGER original_size) { + LARGE_INTEGER end = {.QuadPart = 0}; + if (!SetFilePointerEx(file, end, NULL, FILE_END)) { + return false; + } + size_t written = 0; + while (written < length) { + DWORD chunk = length - written > MAXDWORD ? MAXDWORD : (DWORD)(length - written); + DWORD count = 0; + if (!WriteFile(file, record + written, chunk, &count, NULL) || count == 0) { + LARGE_INTEGER rollback = original_size; + (void)SetFilePointerEx(file, rollback, NULL, FILE_BEGIN); + (void)SetEndOfFile(file); + return false; + } + written += count; + } + return FlushFileBuffers(file) != 0; +} + +static bool windows_log_append(const char *log_path, const char *record, size_t record_length, + size_t cap_bytes) { + windows_log_path_t path; + if (!windows_log_path_open(log_path, &path)) { + return false; + } + bool prepared = windows_private_file_prepare(path.storage, path.lock_base); + HANDLE lock = prepared ? windows_lock_open(path.lock) : INVALID_HANDLE_VALUE; + if (lock != INVALID_HANDLE_VALUE) { + conflict_log_test_hook(CBM_DAEMON_CONFLICT_LOG_BEFORE_SERIALIZATION_LOCK); + } + OVERLAPPED lock_range; + bool lock_acquired = lock != INVALID_HANDLE_VALUE && + windows_lock_exclusive(lock, &lock_range); + if (lock_acquired) { + conflict_log_test_hook(CBM_DAEMON_CONFLICT_LOG_AFTER_SERIALIZATION_LOCK); + } + + LARGE_INTEGER size; + bool ok = lock_acquired && + windows_private_file_prepare(path.storage, path.base); + HANDLE file = ok ? windows_log_open(path.wide, &size) : INVALID_HANDLE_VALUE; + ok = ok && file != INVALID_HANDLE_VALUE; + bool rotate = false; + if (ok && size.QuadPart > 0) { + uint64_t current = (uint64_t)size.QuadPart; + rotate = current > cap_bytes || record_length > cap_bytes - (size_t)current; + } + if (ok && rotate) { + (void)CloseHandle(file); + file = INVALID_HANDLE_VALUE; + DWORD rotated_attributes = GetFileAttributesW(path.rotated); + if (rotated_attributes != INVALID_FILE_ATTRIBUTES && + (rotated_attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + ok = false; + } + if (ok) { + ok = MoveFileExW(path.wide, path.rotated, + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; + } + if (ok) { + ok = windows_private_file_prepare(path.storage, path.base); + } + if (ok) { + file = windows_log_open(path.wide, &size); + ok = file != INVALID_HANDLE_VALUE; + } + } + if (ok && size.QuadPart > 0) { + LARGE_INTEGER offset = {.QuadPart = size.QuadPart - 1}; + char last = '\0'; + DWORD count = 0; + ok = SetFilePointerEx(file, offset, NULL, FILE_BEGIN) != 0 && + ReadFile(file, &last, 1, &count, NULL) != 0 && count == 1 && last == '\n'; + } + if (ok) { + ok = windows_complete_write(file, record, record_length, size); + } + if (file != INVALID_HANDLE_VALUE) { + if (!CloseHandle(file)) { + ok = false; + } + } + if (lock_acquired && !UnlockFileEx(lock, 0, 1, 0, &lock_range)) { + ok = false; + } + if (lock != INVALID_HANDLE_VALUE && !CloseHandle(lock)) { + ok = false; + } + windows_log_path_close(&path); + return ok; +} + +#endif /* _WIN32 */ + +bool cbm_daemon_conflict_log_append(const char *log_path, + const cbm_daemon_conflict_t *conflict, + size_t cap_bytes) { + char record[DAEMON_SERVICE_LOG_RECORD_CAP]; + size_t record_length = 0; + if (cap_bytes == 0 || !conflict_log_record(conflict, record, &record_length)) { + return false; + } +#ifdef _WIN32 + return windows_log_append(log_path, record, record_length, cap_bytes); +#else + return posix_log_append(log_path, record, record_length, cap_bytes); +#endif +} diff --git a/src/daemon/service.h b/src/daemon/service.h new file mode 100644 index 000000000..cd77826b5 --- /dev/null +++ b/src/daemon/service.h @@ -0,0 +1,73 @@ +/* + * service.h — Stable daemon rendezvous and build-compatibility policy. + * + * The rendezvous key deliberately excludes executable path, release version, + * build fingerprint, cache directory, and ABI values. Every stateful CBM + * frontend for one OS account must meet at one endpoint; the HELLO comparison + * then either admits the exact build or returns an explicit conflict. + */ +#ifndef CBM_DAEMON_SERVICE_H +#define CBM_DAEMON_SERVICE_H + +#include "daemon/daemon.h" + +#include +#include +#include + +#define CBM_DAEMON_VERSION_TEXT_SIZE 64U +#define CBM_DAEMON_BUILD_FINGERPRINT_SIZE 65U +#define CBM_DAEMON_CONFLICT_MESSAGE_SIZE 512U + +typedef struct { + const char *semantic_version; + const char *build_fingerprint; + uint32_t protocol_abi; + uint32_t store_abi; + uint32_t feature_abi; +} cbm_daemon_build_identity_t; + +typedef enum { + CBM_DAEMON_HELLO_INVALID = 0, + CBM_DAEMON_HELLO_COMPATIBLE, + CBM_DAEMON_HELLO_VERSION_CONFLICT, + CBM_DAEMON_HELLO_BUILD_CONFLICT, + CBM_DAEMON_HELLO_PROTOCOL_ABI_CONFLICT, + CBM_DAEMON_HELLO_STORE_ABI_CONFLICT, + CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT, +} cbm_daemon_hello_status_t; + +typedef struct { + cbm_daemon_hello_status_t status; + char active_version[CBM_DAEMON_VERSION_TEXT_SIZE]; + char active_build_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + char requested_version[CBM_DAEMON_VERSION_TEXT_SIZE]; + char requested_build_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; +} cbm_daemon_conflict_t; + +/* Stable product key. OS-account isolation is supplied by the IPC runtime + * directory / current-user ACL, never by caller-provided identity text. */ +bool cbm_daemon_rendezvous_key(char out[CBM_DAEMON_KEY_SIZE]); + +/* SHA-256 of the exact executable bytes, encoded as 64 lowercase hex + * characters plus NUL. This is captured once at process startup. */ +bool cbm_daemon_build_fingerprint_file( + const char *path, char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]); + +cbm_daemon_hello_status_t +cbm_daemon_hello_compare(const cbm_daemon_build_identity_t *active, + const cbm_daemon_build_identity_t *requested, + cbm_daemon_conflict_t *conflict_out); + +bool cbm_daemon_conflict_format(const cbm_daemon_conflict_t *conflict, char *out, + size_t out_size); + +/* Append one secret-free NDJSON conflict event. A persistent owner-only + * .lock serializes validation, rotation, and append across daemon + * processes; cap_bytes rotates one complete prior generation to .1 + * before appending a record that would cross the cap. */ +bool cbm_daemon_conflict_log_append(const char *log_path, + const cbm_daemon_conflict_t *conflict, + size_t cap_bytes); + +#endif /* CBM_DAEMON_SERVICE_H */ diff --git a/src/daemon/service_internal.h b/src/daemon/service_internal.h new file mode 100644 index 000000000..05f9edfd1 --- /dev/null +++ b/src/daemon/service_internal.h @@ -0,0 +1,36 @@ +/* + * service_internal.h — Internal daemon service helpers and test hooks. + * + * Production callers outside the daemon implementation must use + * daemon/service.h. Runtime identity verification hashes an already-open, + * kernel-bound file handle through this internal boundary; the test hook lets + * concurrency tests establish an exact interleaving without scheduler luck. + */ +#ifndef CBM_DAEMON_SERVICE_INTERNAL_H +#define CBM_DAEMON_SERVICE_INTERNAL_H + +#include "daemon/service.h" + +#include +#include + +/* Hash an already-open regular file without closing it. native_file is an int + * file descriptor on POSIX and a HANDLE cast through uintptr_t on Windows. + * Callers use this after binding the handle to kernel process-image metadata, + * avoiding a second lookup through a replaceable pathname. */ +bool cbm_daemon_build_fingerprint_native_file( + uintptr_t native_file, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]); + +typedef enum { + CBM_DAEMON_CONFLICT_LOG_BEFORE_SERIALIZATION_LOCK = 1, + CBM_DAEMON_CONFLICT_LOG_AFTER_SERIALIZATION_LOCK, +} cbm_daemon_conflict_log_test_stage_t; + +typedef void (*cbm_daemon_conflict_log_test_hook_fn)( + void *context, cbm_daemon_conflict_log_test_stage_t stage); + +void cbm_daemon_conflict_log_set_test_hook( + cbm_daemon_conflict_log_test_hook_fn hook, void *context); + +#endif /* CBM_DAEMON_SERVICE_INTERNAL_H */ diff --git a/src/daemon/version_cohort.c b/src/daemon/version_cohort.c new file mode 100644 index 000000000..4c2d987fe --- /dev/null +++ b/src/daemon/version_cohort.c @@ -0,0 +1,975 @@ +/* version_cohort.c — Short transition lock + process-lifetime SH/EX barrier. */ +#include "daemon/version_cohort.h" + +#include "foundation/compat.h" +#include "foundation/compat_thread.h" +#include "foundation/log.h" +#include "foundation/platform.h" +#include "foundation/private_file_lock_internal.h" + +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#endif + +enum { + VERSION_COHORT_RETRY_US = 1000, + VERSION_COHORT_CLEANUP_TIMEOUT_MS = 500, + VERSION_COHORT_RECORD_MAGIC_SIZE = 8, + VERSION_COHORT_RECORD_VERSION_OFFSET = VERSION_COHORT_RECORD_MAGIC_SIZE, + VERSION_COHORT_RECORD_BUILD_OFFSET = + VERSION_COHORT_RECORD_VERSION_OFFSET + CBM_DAEMON_VERSION_TEXT_SIZE, + VERSION_COHORT_RECORD_PROTOCOL_OFFSET = + VERSION_COHORT_RECORD_BUILD_OFFSET + CBM_DAEMON_BUILD_FINGERPRINT_SIZE, + VERSION_COHORT_RECORD_STORE_OFFSET = VERSION_COHORT_RECORD_PROTOCOL_OFFSET + 4, + VERSION_COHORT_RECORD_FEATURE_OFFSET = VERSION_COHORT_RECORD_STORE_OFFSET + 4, + VERSION_COHORT_RECORD_SIZE = VERSION_COHORT_RECORD_FEATURE_OFFSET + 4, + VERSION_COHORT_LOG_CAP = 1024 * 1024, + VERSION_COHORT_PATH_CAP = 4096, +}; + +static const unsigned char VERSION_COHORT_RECORD_MAGIC[VERSION_COHORT_RECORD_MAGIC_SIZE] = { + 'C', 'B', 'M', 'C', 'O', 'H', 1, 0, +}; +static const char VERSION_COHORT_ADMISSION_FILE[] = + "cbm-version-cohort-admission-v1.lock"; +static const char VERSION_COHORT_LIFETIME_FILE[] = + "cbm-version-cohort-lifetime-v1.lock"; +static const char VERSION_COHORT_MAINTENANCE_FILE[] = + "cbm-version-cohort-maintenance-v1.lock"; +static const char VERSION_COHORT_DAEMON_FILE[] = + "cbm-version-cohort-daemon-v1.lock"; + +struct cbm_version_cohort_manager { + cbm_private_lock_directory_t *directory; + cbm_mutex_t mutex; + size_t lease_count; + uint64_t owner_pid; + bool closing; +}; + +struct cbm_version_cohort_lease { + cbm_version_cohort_manager_t *manager; + cbm_private_file_lock_t *maintenance; + cbm_private_file_lock_t *admission; + cbm_private_file_lock_t *lifetime; + bool registered; +}; + +struct cbm_version_cohort_daemon_claim { + cbm_version_cohort_manager_t *manager; + cbm_private_file_lock_t *marker; + bool registered; +}; + +static uint64_t version_cohort_current_pid(void) { +#ifdef _WIN32 + return (uint64_t)GetCurrentProcessId(); +#else + return (uint64_t)getpid(); +#endif +} + +static uint64_t version_cohort_deadline_after(uint32_t timeout_ms) { + uint64_t now = cbm_now_ms(); + return now > UINT64_MAX - timeout_ms ? UINT64_MAX : now + timeout_ms; +} + +static cbm_private_file_lock_status_t version_cohort_lock_release_until( + cbm_private_file_lock_t **lock_io, uint64_t deadline_ms) { + cbm_private_file_lock_status_t result = CBM_PRIVATE_FILE_LOCK_OK; + if (!lock_io) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + while (*lock_io) { + cbm_private_file_lock_status_t status = + cbm_private_file_lock_release(lock_io); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + result = status; + } + if (!*lock_io) { + break; + } + if (cbm_now_ms() >= deadline_ms) { + return result == CBM_PRIVATE_FILE_LOCK_OK + ? CBM_PRIVATE_FILE_LOCK_IO + : result; + } + cbm_usleep(VERSION_COHORT_RETRY_US); + } + return result; +} + +static _Noreturn void version_cohort_cleanup_fail_stop( + const char *component) { + /* Observer APIs cannot return an opaque cleanup handle. Once their finite + * retry budget is exhausted, process exit is the only way to avoid losing + * retry authority while a coordination marker remains owned. */ + cbm_log_error("daemon.forced_shutdown", "component", component, + "action", "coordination_cleanup"); + (void)fflush(stdout); + (void)fflush(stderr); +#ifdef _WIN32 + (void)TerminateProcess(GetCurrentProcess(), EXIT_FAILURE); + abort(); +#else + _exit(EXIT_FAILURE); +#endif +} + +#ifndef _WIN32 +static void version_cohort_startup_lock_release_complete( + cbm_daemon_ipc_startup_lock_t **lock_io) { + uint64_t deadline = version_cohort_deadline_after( + VERSION_COHORT_CLEANUP_TIMEOUT_MS); + while (lock_io && *lock_io) { + (void)cbm_daemon_ipc_startup_lock_release(lock_io); + if (!*lock_io) { + return; + } + if (cbm_now_ms() >= deadline) { + version_cohort_cleanup_fail_stop("startup_lock_cleanup"); + } + cbm_usleep(VERSION_COHORT_RETRY_US); + } +} +#endif + +static bool version_cohort_identity_valid( + const cbm_daemon_build_identity_t *identity) { + cbm_daemon_conflict_t comparison; + return cbm_daemon_hello_compare(identity, identity, &comparison) == + CBM_DAEMON_HELLO_COMPATIBLE; +} + +static void version_cohort_u32_encode(unsigned char out[4], uint32_t value) { + out[0] = (unsigned char)(value & 0xffU); + out[1] = (unsigned char)((value >> 8) & 0xffU); + out[2] = (unsigned char)((value >> 16) & 0xffU); + out[3] = (unsigned char)((value >> 24) & 0xffU); +} + +static uint32_t version_cohort_u32_decode(const unsigned char in[4]) { + return (uint32_t)in[0] | ((uint32_t)in[1] << 8) | + ((uint32_t)in[2] << 16) | ((uint32_t)in[3] << 24); +} + +static bool version_cohort_record_encode( + const cbm_daemon_build_identity_t *identity, + unsigned char out[VERSION_COHORT_RECORD_SIZE]) { + if (!out || !version_cohort_identity_valid(identity)) { + return false; + } + memset(out, 0, VERSION_COHORT_RECORD_SIZE); + memcpy(out, VERSION_COHORT_RECORD_MAGIC, + VERSION_COHORT_RECORD_MAGIC_SIZE); + size_t version_length = strlen(identity->semantic_version); + size_t build_length = strlen(identity->build_fingerprint); + memcpy(out + VERSION_COHORT_RECORD_VERSION_OFFSET, + identity->semantic_version, version_length + 1); + memcpy(out + VERSION_COHORT_RECORD_BUILD_OFFSET, + identity->build_fingerprint, build_length + 1); + version_cohort_u32_encode(out + VERSION_COHORT_RECORD_PROTOCOL_OFFSET, + identity->protocol_abi); + version_cohort_u32_encode(out + VERSION_COHORT_RECORD_STORE_OFFSET, + identity->store_abi); + version_cohort_u32_encode(out + VERSION_COHORT_RECORD_FEATURE_OFFSET, + identity->feature_abi); + return true; +} + +static bool version_cohort_record_decode( + const unsigned char record[VERSION_COHORT_RECORD_SIZE], size_t length, + cbm_daemon_build_identity_t *identity_out, + char version_out[CBM_DAEMON_VERSION_TEXT_SIZE], + char build_out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!record || length != VERSION_COHORT_RECORD_SIZE || !identity_out || + !version_out || !build_out || + memcmp(record, VERSION_COHORT_RECORD_MAGIC, + VERSION_COHORT_RECORD_MAGIC_SIZE) != 0) { + return false; + } + memcpy(version_out, record + VERSION_COHORT_RECORD_VERSION_OFFSET, + CBM_DAEMON_VERSION_TEXT_SIZE); + memcpy(build_out, record + VERSION_COHORT_RECORD_BUILD_OFFSET, + CBM_DAEMON_BUILD_FINGERPRINT_SIZE); + cbm_daemon_build_identity_t identity = { + .semantic_version = version_out, + .build_fingerprint = build_out, + .protocol_abi = version_cohort_u32_decode( + record + VERSION_COHORT_RECORD_PROTOCOL_OFFSET), + .store_abi = version_cohort_u32_decode( + record + VERSION_COHORT_RECORD_STORE_OFFSET), + .feature_abi = version_cohort_u32_decode( + record + VERSION_COHORT_RECORD_FEATURE_OFFSET), + }; + if (!version_cohort_identity_valid(&identity)) { + return false; + } + *identity_out = identity; + return true; +} + +static cbm_version_cohort_status_t version_cohort_status_from_lock( + cbm_private_file_lock_status_t status) { + switch (status) { + case CBM_PRIVATE_FILE_LOCK_OK: + return CBM_VERSION_COHORT_OK; + case CBM_PRIVATE_FILE_LOCK_BUSY: + return CBM_VERSION_COHORT_BUSY; + case CBM_PRIVATE_FILE_LOCK_UNSAFE: + return CBM_VERSION_COHORT_UNSAFE; + case CBM_PRIVATE_FILE_LOCK_IO: + default: + return CBM_VERSION_COHORT_IO; + } +} + +static cbm_private_file_lock_status_t version_cohort_lock_until( + cbm_version_cohort_manager_t *manager, const char *base_name, + cbm_private_file_lock_mode_t mode, uint64_t deadline_ms, + cbm_private_file_lock_t **lock_out) { + for (;;) { + cbm_private_file_lock_status_t status = + cbm_private_file_lock_try_acquire(manager->directory, base_name, + mode, lock_out); + if (status != CBM_PRIVATE_FILE_LOCK_BUSY || + (deadline_ms != UINT64_MAX && cbm_now_ms() >= deadline_ms)) { + return status; + } + cbm_usleep(VERSION_COHORT_RETRY_US); + } +} + +static cbm_version_cohort_lease_t *version_cohort_lease_new( + cbm_version_cohort_manager_t *manager) { + if (!manager) { + return NULL; + } + cbm_version_cohort_lease_t *lease = calloc(1, sizeof(*lease)); + if (!lease) { + return NULL; + } + cbm_mutex_lock(&manager->mutex); + bool admitted = !manager->closing && + manager->owner_pid == version_cohort_current_pid(); + if (admitted) { + manager->lease_count++; + } + cbm_mutex_unlock(&manager->mutex); + if (!admitted) { + free(lease); + return NULL; + } + lease->manager = manager; + lease->registered = true; + return lease; +} + +static bool version_cohort_manager_register( + cbm_version_cohort_manager_t *manager) { + if (!manager) { + return false; + } + cbm_mutex_lock(&manager->mutex); + bool admitted = !manager->closing && + manager->owner_pid == version_cohort_current_pid(); + if (admitted) { + manager->lease_count++; + } + cbm_mutex_unlock(&manager->mutex); + return admitted; +} + +static bool version_cohort_manager_unregister( + cbm_version_cohort_manager_t *manager) { + if (!manager) { + return false; + } + cbm_mutex_lock(&manager->mutex); + bool registered = manager->owner_pid == version_cohort_current_pid() && + manager->lease_count > 0; + if (registered) { + manager->lease_count--; + } + cbm_mutex_unlock(&manager->mutex); + return registered; +} + +cbm_private_file_lock_status_t cbm_version_cohort_lease_release( + cbm_version_cohort_lease_t **lease_io) { + if (!lease_io || !*lease_io) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_version_cohort_lease_t *lease = *lease_io; + cbm_private_file_lock_status_t result = CBM_PRIVATE_FILE_LOCK_OK; + if (lease->lifetime) { + cbm_private_file_lock_status_t status = + cbm_private_file_lock_release(&lease->lifetime); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + result = status; + } + } + if (lease->lifetime) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (lease->admission) { + cbm_private_file_lock_status_t status = + cbm_private_file_lock_release(&lease->admission); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + result = status; + } + } + if (lease->admission) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (lease->maintenance) { + cbm_private_file_lock_status_t status = + cbm_private_file_lock_release(&lease->maintenance); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + result = status; + } + } + if (lease->maintenance) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (lease->registered && lease->manager) { + cbm_mutex_lock(&lease->manager->mutex); + if (lease->manager->lease_count > 0) { + lease->manager->lease_count--; + } else { + result = CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_mutex_unlock(&lease->manager->mutex); + lease->registered = false; + } + free(lease); + *lease_io = NULL; + return result; +} + +static cbm_version_cohort_status_t version_cohort_failed( + cbm_version_cohort_lease_t *lease, cbm_version_cohort_status_t status, + cbm_version_cohort_lease_t **lease_out) { + cbm_version_cohort_lease_t *cleanup = lease; + cbm_private_file_lock_status_t cleanup_status = + cbm_version_cohort_lease_release(&cleanup); + if (cleanup) { + *lease_out = cleanup; + return CBM_VERSION_COHORT_IO; + } + return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK ? status + : CBM_VERSION_COHORT_IO; +} + +cbm_version_cohort_manager_t *cbm_version_cohort_manager_new( + const cbm_daemon_ipc_endpoint_t *endpoint) { + cbm_private_lock_directory_t *directory = NULL; + cbm_private_file_lock_status_t directory_status = + cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory); + if (directory_status != CBM_PRIVATE_FILE_LOCK_OK || !directory) { + if (directory) { + cbm_private_lock_directory_close(directory); + } + return NULL; + } + cbm_version_cohort_manager_t *manager = calloc(1, sizeof(*manager)); + if (!manager) { + cbm_private_lock_directory_close(directory); + return NULL; + } + manager->directory = directory; + manager->owner_pid = version_cohort_current_pid(); + cbm_mutex_init(&manager->mutex); + return manager; +} + +static cbm_version_cohort_status_t version_cohort_claim_new( + cbm_version_cohort_lease_t *lease, + const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms) { + unsigned char record[VERSION_COHORT_RECORD_SIZE]; + if (!version_cohort_record_encode(identity, record) || + cbm_private_file_lock_payload_write(lease->lifetime, record, + sizeof(record)) != + CBM_PRIVATE_FILE_LOCK_OK) { + return CBM_VERSION_COHORT_IO; + } + cbm_private_file_lock_status_t release_status = + cbm_private_file_lock_release(&lease->lifetime); + if (release_status != CBM_PRIVATE_FILE_LOCK_OK) { + return CBM_VERSION_COHORT_IO; + } + return version_cohort_status_from_lock(version_cohort_lock_until( + lease->manager, VERSION_COHORT_LIFETIME_FILE, + CBM_PRIVATE_FILE_LOCK_SH, deadline_ms, &lease->lifetime)); +} + +cbm_version_cohort_status_t cbm_version_cohort_acquire( + cbm_version_cohort_manager_t *manager, + const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms, + cbm_version_cohort_lease_t **lease_out, + cbm_daemon_conflict_t *conflict_out) { + if (lease_out) { + *lease_out = NULL; + } + if (conflict_out) { + memset(conflict_out, 0, sizeof(*conflict_out)); + } + if (!manager || !identity || !lease_out || !conflict_out || + !version_cohort_identity_valid(identity)) { + return CBM_VERSION_COHORT_UNSAFE; + } + cbm_version_cohort_lease_t *lease = version_cohort_lease_new(manager); + if (!lease) { + return CBM_VERSION_COHORT_IO; + } + /* Global order is maintenance -> admission -> lifetime. Retaining SH + * through admission closes the probe/admission race: an activation cannot + * publish EX intent until this already-started transition has finished, + * while every later participant fails its non-blocking SH attempt. */ + cbm_private_file_lock_status_t lock_status = + cbm_private_file_lock_try_acquire( + manager->directory, VERSION_COHORT_MAINTENANCE_FILE, + CBM_PRIVATE_FILE_LOCK_SH, &lease->maintenance); + if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) { + return version_cohort_failed( + lease, version_cohort_status_from_lock(lock_status), lease_out); + } + lock_status = version_cohort_lock_until( + manager, VERSION_COHORT_ADMISSION_FILE, CBM_PRIVATE_FILE_LOCK_EX, + deadline_ms, &lease->admission); + if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) { + return version_cohort_failed( + lease, version_cohort_status_from_lock(lock_status), lease_out); + } + + lock_status = cbm_private_file_lock_try_acquire( + manager->directory, VERSION_COHORT_LIFETIME_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &lease->lifetime); + cbm_version_cohort_status_t status = + version_cohort_status_from_lock(lock_status); + if (status == CBM_VERSION_COHORT_OK) { + status = version_cohort_claim_new(lease, identity, deadline_ms); + } else if (status == CBM_VERSION_COHORT_BUSY) { + lock_status = cbm_private_file_lock_try_acquire( + manager->directory, VERSION_COHORT_LIFETIME_FILE, + CBM_PRIVATE_FILE_LOCK_SH, &lease->lifetime); + status = version_cohort_status_from_lock(lock_status); + if (status == CBM_VERSION_COHORT_OK) { + unsigned char record[VERSION_COHORT_RECORD_SIZE]; + size_t record_length = 0; + char active_version[CBM_DAEMON_VERSION_TEXT_SIZE]; + char active_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + cbm_daemon_build_identity_t active; + if (cbm_private_file_lock_payload_read( + lease->lifetime, record, sizeof(record), + &record_length) != CBM_PRIVATE_FILE_LOCK_OK || + !version_cohort_record_decode( + record, record_length, &active, active_version, + active_build)) { + status = CBM_VERSION_COHORT_UNSAFE; + } else { + cbm_daemon_hello_status_t comparison = + cbm_daemon_hello_compare(&active, identity, conflict_out); + if (comparison == CBM_DAEMON_HELLO_COMPATIBLE) { + status = CBM_VERSION_COHORT_OK; + } else if (comparison == CBM_DAEMON_HELLO_INVALID) { + status = CBM_VERSION_COHORT_UNSAFE; + } else { + cbm_private_file_lock_status_t release_status = + cbm_private_file_lock_release(&lease->lifetime); + if (release_status != CBM_PRIVATE_FILE_LOCK_OK) { + status = CBM_VERSION_COHORT_IO; + } else { + lock_status = cbm_private_file_lock_try_acquire( + manager->directory, + VERSION_COHORT_LIFETIME_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &lease->lifetime); + status = version_cohort_status_from_lock(lock_status); + if (status == CBM_VERSION_COHORT_OK) { + status = version_cohort_claim_new( + lease, identity, deadline_ms); + } else if (status == CBM_VERSION_COHORT_BUSY) { + status = CBM_VERSION_COHORT_CONFLICT; + } + } + } + } + } + } + + if (status != CBM_VERSION_COHORT_OK) { + return version_cohort_failed(lease, status, lease_out); + } + cbm_private_file_lock_status_t admission_release = + cbm_private_file_lock_release(&lease->admission); + if (admission_release != CBM_PRIVATE_FILE_LOCK_OK) { + return version_cohort_failed(lease, CBM_VERSION_COHORT_IO, + lease_out); + } + cbm_private_file_lock_status_t maintenance_release = + cbm_private_file_lock_release(&lease->maintenance); + if (maintenance_release != CBM_PRIVATE_FILE_LOCK_OK) { + return version_cohort_failed(lease, CBM_VERSION_COHORT_IO, + lease_out); + } + *lease_out = lease; + return CBM_VERSION_COHORT_OK; +} + +static cbm_version_cohort_status_t +version_cohort_reserve_for_mutation_internal( + cbm_version_cohort_manager_t *manager, uint64_t deadline_ms, + cbm_version_cohort_quiesce_fn quiesce, void *quiesce_context, + cbm_version_cohort_quiesce_result_t *quiesce_result_out, + cbm_version_cohort_lease_t **lease_out, bool require_finite_deadline) { + if (lease_out) { + *lease_out = NULL; + } + if (quiesce_result_out) { + *quiesce_result_out = CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + } + if (!manager || !quiesce_result_out || !lease_out || + (require_finite_deadline && deadline_ms == UINT64_MAX)) { + return CBM_VERSION_COHORT_UNSAFE; + } + cbm_version_cohort_lease_t *lease = version_cohort_lease_new(manager); + if (!lease) { + return CBM_VERSION_COHORT_IO; + } + /* Publish crash-safe intent before waiting for an admission already in + * flight. Normal admissions retain maintenance SH until their admission + * transition ends, giving every participant the same deadlock-free native + * order: maintenance -> admission -> lifetime. */ + cbm_private_file_lock_status_t lock_status = version_cohort_lock_until( + manager, VERSION_COHORT_MAINTENANCE_FILE, + CBM_PRIVATE_FILE_LOCK_EX, deadline_ms, &lease->maintenance); + if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) { + return version_cohort_failed( + lease, version_cohort_status_from_lock(lock_status), lease_out); + } + lock_status = version_cohort_lock_until( + manager, VERSION_COHORT_ADMISSION_FILE, CBM_PRIVATE_FILE_LOCK_EX, + deadline_ms, &lease->admission); + if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) { + return version_cohort_failed( + lease, version_cohort_status_from_lock(lock_status), lease_out); + } + lock_status = cbm_private_file_lock_try_acquire( + manager->directory, VERSION_COHORT_LIFETIME_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &lease->lifetime); + if (lock_status == CBM_PRIVATE_FILE_LOCK_BUSY) { + if (!quiesce) { + *quiesce_result_out = CBM_VERSION_COHORT_QUIESCE_REFUSED; + return version_cohort_failed(lease, CBM_VERSION_COHORT_BUSY, + lease_out); + } + + /* Admission remains exclusively locked across the callback and wait. + * Consequently the active lifetime set can only shrink, and no new + * participant can race mutation after accepting the quiesce request. */ + cbm_version_cohort_quiesce_result_t quiesce_result = + quiesce(quiesce_context); + *quiesce_result_out = quiesce_result; + if (quiesce_result == CBM_VERSION_COHORT_QUIESCE_REFUSED) { + return version_cohort_failed(lease, CBM_VERSION_COHORT_BUSY, + lease_out); + } + if (quiesce_result == CBM_VERSION_COHORT_QUIESCE_ERROR) { + return version_cohort_failed(lease, CBM_VERSION_COHORT_IO, + lease_out); + } + if (quiesce_result != CBM_VERSION_COHORT_QUIESCE_REQUESTED) { + return version_cohort_failed(lease, CBM_VERSION_COHORT_UNSAFE, + lease_out); + } + lock_status = version_cohort_lock_until( + manager, VERSION_COHORT_LIFETIME_FILE, + CBM_PRIVATE_FILE_LOCK_EX, deadline_ms, &lease->lifetime); + } + if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) { + return version_cohort_failed( + lease, version_cohort_status_from_lock(lock_status), lease_out); + } + + /* All three locks intentionally remain in the lease. Maintenance makes + * admission fail fast, admission closes the transition, and lifetime + * proves all earlier participants have drained. */ + *lease_out = lease; + return CBM_VERSION_COHORT_OK; +} + +cbm_version_cohort_status_t cbm_version_cohort_reserve_for_mutation( + cbm_version_cohort_manager_t *manager, uint64_t deadline_ms, + cbm_version_cohort_quiesce_fn quiesce, void *quiesce_context, + cbm_version_cohort_quiesce_result_t *quiesce_result_out, + cbm_version_cohort_lease_t **lease_out) { + return version_cohort_reserve_for_mutation_internal( + manager, deadline_ms, quiesce, quiesce_context, quiesce_result_out, + lease_out, true); +} + +cbm_version_cohort_status_t cbm_version_cohort_reserve_exclusive( + cbm_version_cohort_manager_t *manager, uint64_t deadline_ms, + cbm_version_cohort_lease_t **lease_out) { + cbm_version_cohort_quiesce_result_t ignored_quiesce; + return version_cohort_reserve_for_mutation_internal( + manager, deadline_ms, NULL, NULL, &ignored_quiesce, lease_out, false); +} + +cbm_version_cohort_maintenance_presence_t +cbm_version_cohort_maintenance_presence( + cbm_version_cohort_manager_t *manager) { + if (!manager || !version_cohort_manager_register(manager)) { + return CBM_VERSION_COHORT_MAINTENANCE_UNSAFE; + } + + cbm_version_cohort_maintenance_presence_t presence = + CBM_VERSION_COHORT_MAINTENANCE_IO; + cbm_private_file_lock_t *observer = NULL; + cbm_private_file_lock_status_t status = + cbm_private_file_lock_try_acquire( + manager->directory, VERSION_COHORT_MAINTENANCE_FILE, + CBM_PRIVATE_FILE_LOCK_SH, &observer); + if (status == CBM_PRIVATE_FILE_LOCK_OK && observer) { + presence = CBM_VERSION_COHORT_MAINTENANCE_ABSENT; + } else if (status == CBM_PRIVATE_FILE_LOCK_BUSY) { + presence = CBM_VERSION_COHORT_MAINTENANCE_REQUESTED; + } else if (status == CBM_PRIVATE_FILE_LOCK_UNSAFE) { + presence = CBM_VERSION_COHORT_MAINTENANCE_UNSAFE; + } + + cbm_private_file_lock_status_t observer_release = + version_cohort_lock_release_until( + &observer, version_cohort_deadline_after( + VERSION_COHORT_CLEANUP_TIMEOUT_MS)); + if (observer_release != CBM_PRIVATE_FILE_LOCK_OK) { + presence = CBM_VERSION_COHORT_MAINTENANCE_IO; + } + if (observer) { + version_cohort_cleanup_fail_stop("maintenance_observer_cleanup"); + } + if (!version_cohort_manager_unregister(manager)) { + presence = CBM_VERSION_COHORT_MAINTENANCE_IO; + } + return presence; +} + +cbm_version_cohort_status_t cbm_version_cohort_daemon_claim_acquire( + cbm_version_cohort_manager_t *manager, + cbm_version_cohort_daemon_claim_t **claim_out) { + if (claim_out) { + *claim_out = NULL; + } + if (!manager || !claim_out) { + return CBM_VERSION_COHORT_UNSAFE; + } + cbm_version_cohort_daemon_claim_t *claim = calloc(1, sizeof(*claim)); + if (!claim || !version_cohort_manager_register(manager)) { + free(claim); + return CBM_VERSION_COHORT_IO; + } + claim->manager = manager; + claim->registered = true; + cbm_private_file_lock_status_t lock_status = + cbm_private_file_lock_try_acquire( + manager->directory, VERSION_COHORT_DAEMON_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &claim->marker); + if (lock_status == CBM_PRIVATE_FILE_LOCK_OK) { + *claim_out = claim; + return CBM_VERSION_COHORT_OK; + } + + cbm_private_file_lock_status_t cleanup_status = + version_cohort_lock_release_until( + &claim->marker, version_cohort_deadline_after( + VERSION_COHORT_CLEANUP_TIMEOUT_MS)); + if (claim->marker) { + *claim_out = claim; + return CBM_VERSION_COHORT_IO; + } + bool unregistered = version_cohort_manager_unregister(manager); + free(claim); + return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK && unregistered + ? version_cohort_status_from_lock(lock_status) + : CBM_VERSION_COHORT_IO; +} + +cbm_private_file_lock_status_t cbm_version_cohort_daemon_claim_release( + cbm_version_cohort_daemon_claim_t **claim_io) { + if (!claim_io || !*claim_io) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_version_cohort_daemon_claim_t *claim = *claim_io; + cbm_private_file_lock_status_t result = CBM_PRIVATE_FILE_LOCK_OK; + if (claim->marker) { + result = cbm_private_file_lock_release(&claim->marker); + } + if (claim->marker) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (claim->registered) { + if (!version_cohort_manager_unregister(claim->manager)) { + result = CBM_PRIVATE_FILE_LOCK_IO; + } + claim->registered = false; + } + free(claim); + *claim_io = NULL; + return result; +} + +cbm_version_cohort_daemon_presence_t +cbm_version_cohort_daemon_claim_presence( + cbm_version_cohort_manager_t *manager) { + if (!manager || !version_cohort_manager_register(manager)) { + return CBM_VERSION_COHORT_DAEMON_UNSAFE; + } + + cbm_version_cohort_daemon_presence_t presence = + CBM_VERSION_COHORT_DAEMON_IO; + cbm_private_file_lock_t *marker = NULL; + cbm_private_file_lock_status_t marker_status = + cbm_private_file_lock_try_acquire( + manager->directory, VERSION_COHORT_DAEMON_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &marker); + if (marker_status == CBM_PRIVATE_FILE_LOCK_BUSY) { + presence = CBM_VERSION_COHORT_DAEMON_COORDINATED; + } else if (marker_status == CBM_PRIVATE_FILE_LOCK_OK && marker) { + presence = CBM_VERSION_COHORT_DAEMON_ABSENT; + } else if (marker_status == CBM_PRIVATE_FILE_LOCK_UNSAFE) { + presence = CBM_VERSION_COHORT_DAEMON_UNSAFE; + } + + cbm_private_file_lock_status_t marker_release = + version_cohort_lock_release_until( + &marker, version_cohort_deadline_after( + VERSION_COHORT_CLEANUP_TIMEOUT_MS)); + if (marker_release != CBM_PRIVATE_FILE_LOCK_OK) { + presence = CBM_VERSION_COHORT_DAEMON_IO; + } + if (marker) { + version_cohort_cleanup_fail_stop("daemon_marker_observer_cleanup"); + } + if (!version_cohort_manager_unregister(manager)) { + presence = CBM_VERSION_COHORT_DAEMON_IO; + } + return presence; +} + +static cbm_version_cohort_daemon_presence_t +version_cohort_active_daemon_presence( + cbm_version_cohort_manager_t *manager, + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_local_transition_t *transition) { + cbm_version_cohort_daemon_presence_t presence = + CBM_VERSION_COHORT_DAEMON_IO; + cbm_private_file_lock_t *marker = NULL; + cbm_private_file_lock_status_t marker_status = + cbm_private_file_lock_try_acquire( + manager->directory, VERSION_COHORT_DAEMON_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &marker); + if (marker_status == CBM_PRIVATE_FILE_LOCK_BUSY) { + presence = CBM_VERSION_COHORT_DAEMON_COORDINATED; + } else if (marker_status == CBM_PRIVATE_FILE_LOCK_OK) { + if (!transition) { + presence = CBM_VERSION_COHORT_DAEMON_UNCOORDINATED; + } else { + /* The sealed transition prevents a replacement daemon from + * starting. Recheck lifetime while retaining the marker EX lock: + * shutdown may have released lifetime after our first observation + * but before the marker became available. */ + int lifetime = + cbm_daemon_ipc_local_transition_lifetime_probe( + endpoint, transition); + presence = lifetime == 0 + ? CBM_VERSION_COHORT_DAEMON_ABSENT + : lifetime == 1 + ? CBM_VERSION_COHORT_DAEMON_UNCOORDINATED + : CBM_VERSION_COHORT_DAEMON_UNSAFE; + } + } else if (marker_status == CBM_PRIVATE_FILE_LOCK_UNSAFE) { + presence = CBM_VERSION_COHORT_DAEMON_UNSAFE; + } + cbm_private_file_lock_status_t marker_release = + version_cohort_lock_release_until( + &marker, version_cohort_deadline_after( + VERSION_COHORT_CLEANUP_TIMEOUT_MS)); + if (marker_release != CBM_PRIVATE_FILE_LOCK_OK) { + presence = CBM_VERSION_COHORT_DAEMON_IO; + } + if (marker) { + version_cohort_cleanup_fail_stop("active_daemon_marker_cleanup"); + } + return presence; +} + +cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence( + cbm_version_cohort_manager_t *manager, + const cbm_daemon_ipc_endpoint_t *endpoint) { + if (!manager || !endpoint || !version_cohort_manager_register(manager)) { + return CBM_VERSION_COHORT_DAEMON_UNSAFE; + } + + cbm_version_cohort_daemon_presence_t presence = + CBM_VERSION_COHORT_DAEMON_IO; + cbm_version_cohort_daemon_presence_t claim = + cbm_version_cohort_daemon_claim_presence(manager); + if (claim == CBM_VERSION_COHORT_DAEMON_COORDINATED) { + presence = claim; + } else if (claim != CBM_VERSION_COHORT_DAEMON_ABSENT) { + presence = claim; + } else { + int lifetime = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + if (lifetime == 0) { +#ifdef _WIN32 + int legacy = cbm_daemon_ipc_legacy_generation_probe(endpoint); + if (legacy == 0) { + presence = CBM_VERSION_COHORT_DAEMON_ABSENT; + } else if (legacy == 1) { + presence = CBM_VERSION_COHORT_DAEMON_UNCOORDINATED; + } else { + presence = CBM_VERSION_COHORT_DAEMON_UNSAFE; + } +#else + cbm_daemon_ipc_startup_lock_t *startup = NULL; + int startup_status = cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &startup); + if (startup_status == 0) { + presence = CBM_VERSION_COHORT_DAEMON_UNCOORDINATED; + } else if (startup_status < 0 || !startup) { + presence = CBM_VERSION_COHORT_DAEMON_UNSAFE; + } else { + int cleanup = cbm_daemon_ipc_stale_generation_cleanup( + endpoint, startup); + if (cleanup == 1) { + presence = CBM_VERSION_COHORT_DAEMON_ABSENT; + } else if (cleanup == 0) { + presence = CBM_VERSION_COHORT_DAEMON_UNCOORDINATED; + } else { + presence = CBM_VERSION_COHORT_DAEMON_UNSAFE; + } + } + version_cohort_startup_lock_release_complete(&startup); +#endif + } else if (lifetime == 1) { + presence = version_cohort_active_daemon_presence(manager, NULL, + NULL); + } + } + if (!version_cohort_manager_unregister(manager)) { + presence = CBM_VERSION_COHORT_DAEMON_IO; + } + return presence; +} + +cbm_version_cohort_daemon_presence_t +cbm_version_cohort_daemon_presence_under_transition( + cbm_version_cohort_manager_t *manager, + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_local_transition_t *transition) { + if (!manager || !endpoint || !transition || + !version_cohort_manager_register(manager)) { + return CBM_VERSION_COHORT_DAEMON_UNSAFE; + } + + int transition_status = + cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, transition); + /* After validating the guard, classify through the marker. If it is free, + * keep it exclusively locked while observing lifetime so daemon shutdown + * cannot become a false conflict. The retained startup transition prevents + * a replacement generation. */ + cbm_version_cohort_daemon_presence_t presence = + transition_status < 0 + ? CBM_VERSION_COHORT_DAEMON_UNSAFE + : version_cohort_active_daemon_presence( + manager, endpoint, transition); + if (!version_cohort_manager_unregister(manager)) { + presence = CBM_VERSION_COHORT_DAEMON_IO; + } + return presence; +} + +cbm_private_file_lock_status_t cbm_version_cohort_manager_free( + cbm_version_cohort_manager_t **manager_io) { + if (!manager_io || !*manager_io) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_version_cohort_manager_t *manager = *manager_io; + cbm_mutex_lock(&manager->mutex); + bool leases_active = manager->lease_count != 0; + bool can_free = manager->owner_pid == version_cohort_current_pid() && + !leases_active && !manager->closing; + if (can_free) { + manager->closing = true; + } + cbm_mutex_unlock(&manager->mutex); + if (!can_free) { + return leases_active ? CBM_PRIVATE_FILE_LOCK_BUSY + : CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_private_lock_directory_close(manager->directory); + manager->directory = NULL; + cbm_mutex_destroy(&manager->mutex); + free(manager); + *manager_io = NULL; + return CBM_PRIVATE_FILE_LOCK_OK; +} + +bool cbm_version_cohort_log_conflict(const cbm_daemon_conflict_t *conflict) { + const char *cache = cbm_resolve_cache_dir(); + if (!cache || !cache[0] || !conflict) { + return false; + } + char logs[VERSION_COHORT_PATH_CAP]; + char path[VERSION_COHORT_PATH_CAP]; + int logs_written = snprintf(logs, sizeof(logs), "%s/logs", cache); + int path_written = snprintf(path, sizeof(path), + "%s/daemon-conflicts.ndjson", logs); + if (logs_written <= 0 || logs_written >= (int)sizeof(logs) || + path_written <= 0 || path_written >= (int)sizeof(path)) { + return false; + } + FILE *seed = cbm_daemon_ipc_private_log_open( + logs, "daemon-conflicts.ndjson", VERSION_COHORT_LOG_CAP); + if (!seed) { + return false; + } + bool seeded = fclose(seed) == 0; + return seeded && cbm_daemon_conflict_log_append( + path, conflict, VERSION_COHORT_LOG_CAP); +} + +bool cbm_version_cohort_log_uncoordinated_daemon( + const cbm_daemon_build_identity_t *requested) { + if (!version_cohort_identity_valid(requested)) { + return false; + } + cbm_daemon_conflict_t conflict = { + .status = CBM_DAEMON_HELLO_BUILD_CONFLICT, + }; + (void)snprintf(conflict.active_version, + sizeof(conflict.active_version), + "%s", "pre-cohort/unknown"); + memset(conflict.active_build_fingerprint, '0', + sizeof(conflict.active_build_fingerprint) - 1); + conflict.active_build_fingerprint[ + sizeof(conflict.active_build_fingerprint) - 1] = '\0'; + (void)snprintf(conflict.requested_version, + sizeof(conflict.requested_version), "%s", + requested->semantic_version); + (void)snprintf(conflict.requested_build_fingerprint, + sizeof(conflict.requested_build_fingerprint), "%s", + requested->build_fingerprint); + return cbm_version_cohort_log_conflict(&conflict); +} diff --git a/src/daemon/version_cohort.h b/src/daemon/version_cohort.h new file mode 100644 index 000000000..c3f609d32 --- /dev/null +++ b/src/daemon/version_cohort.h @@ -0,0 +1,155 @@ +/* version_cohort.h — Crash-safe exact-build admission across CBM processes. */ +#ifndef CBM_DAEMON_VERSION_COHORT_H +#define CBM_DAEMON_VERSION_COHORT_H + +#include "daemon/ipc.h" +#include "daemon/service.h" + +#include + +typedef struct cbm_version_cohort_manager cbm_version_cohort_manager_t; +typedef struct cbm_version_cohort_lease cbm_version_cohort_lease_t; +typedef struct cbm_version_cohort_daemon_claim + cbm_version_cohort_daemon_claim_t; + +typedef enum { + CBM_VERSION_COHORT_OK = 0, + CBM_VERSION_COHORT_CONFLICT = 1, + CBM_VERSION_COHORT_BUSY = 2, + CBM_VERSION_COHORT_UNSAFE = 3, + CBM_VERSION_COHORT_IO = 4, +} cbm_version_cohort_status_t; + +typedef enum { + CBM_VERSION_COHORT_DAEMON_ABSENT = 0, + CBM_VERSION_COHORT_DAEMON_COORDINATED = 1, + CBM_VERSION_COHORT_DAEMON_UNCOORDINATED = 2, + CBM_VERSION_COHORT_DAEMON_UNSAFE = 3, + CBM_VERSION_COHORT_DAEMON_IO = 4, +} cbm_version_cohort_daemon_presence_t; + +typedef enum { + CBM_VERSION_COHORT_MAINTENANCE_ABSENT = 0, + CBM_VERSION_COHORT_MAINTENANCE_REQUESTED = 1, + CBM_VERSION_COHORT_MAINTENANCE_UNSAFE = 2, + CBM_VERSION_COHORT_MAINTENANCE_IO = 3, +} cbm_version_cohort_maintenance_presence_t; + +/* A mutation barrier invokes its quiesce callback only after retaining the + * admission lock and observing active lifetime participants. The callback + * must return promptly: the barrier itself bounds only its native lock wait. + * It returns REQUESTED, REFUSED, or ERROR; NOT_NEEDED is reserved for the API + * output when lifetime was already free. REFUSED leaves active work untouched; + * ERROR reports an inability to request orderly quiescence. */ +typedef enum { + CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED = 0, + CBM_VERSION_COHORT_QUIESCE_REQUESTED = 1, + CBM_VERSION_COHORT_QUIESCE_REFUSED = 2, + CBM_VERSION_COHORT_QUIESCE_ERROR = 3, +} cbm_version_cohort_quiesce_result_t; + +typedef cbm_version_cohort_quiesce_result_t (*cbm_version_cohort_quiesce_fn)( + void *context); + +/* Managers independently reopen no paths: the endpoint duplicates its + * already-validated owner-only runtime-directory handle. All managers for one + * account therefore meet at the same stable native lock files. */ +cbm_version_cohort_manager_t *cbm_version_cohort_manager_new( + const cbm_daemon_ipc_endpoint_t *endpoint); + +/* Admission first takes the maintenance gate SH without waiting, holds it + * across the short admission EX transition, then retains SH on the cohort + * lifetime file. Active maintenance therefore fails fast with BUSY. Exact + * identity peers share SH; a different version, build, or ABI returns + * CONFLICT with conflict_out populated. deadline_ms is an absolute + * cbm_now_ms() deadline; UINT64_MAX waits indefinitely. Every non-NULL + * lease_out, including cleanup-only IO state, must be released. */ +cbm_version_cohort_status_t cbm_version_cohort_acquire( + cbm_version_cohort_manager_t *manager, + const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms, + cbm_version_cohort_lease_t **lease_out, + cbm_daemon_conflict_t *conflict_out); + +/* Binary activation takes the lifetime file EX. It therefore refuses while + * any CLI/bootstrap/daemon participant is active and blocks new admissions + * for the complete install/update/uninstall mutation window. */ +cbm_version_cohort_status_t cbm_version_cohort_reserve_exclusive( + cbm_version_cohort_manager_t *manager, uint64_t deadline_ms, + cbm_version_cohort_lease_t **lease_out); + +/* Coordinated install/update/uninstall barrier for modern CBM participants. + * It first publishes maintenance intent EX, then retains admission EX so no + * new participant can enter, and finally probes lifetime EX. If lifetime is + * busy, quiesce is invoked exactly once and the barrier waits no later than + * the finite absolute deadline_ms. On success the returned mutation lease + * retains maintenance EX, admission EX, and lifetime EX until release. On + * timeout, callback refusal/error, or lock failure it grants no mutation + * authority and releases every safely releasable guard. UINT64_MAX is + * rejected because mutation draining must always be bounded. A non-NULL + * cleanup-only lease_out after IO must still be released by the caller. */ +cbm_version_cohort_status_t cbm_version_cohort_reserve_for_mutation( + cbm_version_cohort_manager_t *manager, uint64_t deadline_ms, + cbm_version_cohort_quiesce_fn quiesce, void *quiesce_context, + cbm_version_cohort_quiesce_result_t *quiesce_result_out, + cbm_version_cohort_lease_t **lease_out); + +/* Cheap, non-blocking observation of the crash-released maintenance intent. + * Participants use the same native gate before admission, so REQUESTED is an + * authoritative fail-fast signal rather than a filesystem-presence guess. */ +cbm_version_cohort_maintenance_presence_t +cbm_version_cohort_maintenance_presence( + cbm_version_cohort_manager_t *manager); + +/* The daemon holds this separate EX marker for its generation after taking + * the stable daemon lifetime reservation. A local CLI can therefore + * distinguish a current coordinated daemon from a pre-cohort daemon without + * opening an IPC connection or registering a daemon session. A non-NULL + * claim_out accompanying IO is cleanup-only authority and must be released. */ +cbm_version_cohort_status_t cbm_version_cohort_daemon_claim_acquire( + cbm_version_cohort_manager_t *manager, + cbm_version_cohort_daemon_claim_t **claim_out); +cbm_private_file_lock_status_t cbm_version_cohort_daemon_claim_release( + cbm_version_cohort_daemon_claim_t **claim_io); + +/* Non-owning marker observation for startup turnover. COORDINATED means a + * current daemon still holds its generation claim, including the cleanup + * interval after its listener/lifetime reservation has closed. ABSENT means + * the marker was acquired and released safely; UNSAFE/IO fail closed. */ +cbm_version_cohort_daemon_presence_t +cbm_version_cohort_daemon_claim_presence( + cbm_version_cohort_manager_t *manager); + +/* Native marker ownership, never file existence, establishes a coordinated + * daemon. A retained marker remains authoritative during final cleanup even + * after listener/lifetime teardown. */ +cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence( + cbm_version_cohort_manager_t *manager, + const cbm_daemon_ipc_endpoint_t *endpoint); + +/* Standalone CLI presence check under its successfully sealed and retained + * startup transition. An absent daemon lifetime is authoritative because no + * current or legacy daemon can start through that guard; an active lifetime + * must still own the current-generation daemon marker to be coordinated. */ +cbm_version_cohort_daemon_presence_t +cbm_version_cohort_daemon_presence_under_transition( + cbm_version_cohort_manager_t *manager, + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_local_transition_t *transition); + +cbm_private_file_lock_status_t cbm_version_cohort_lease_release( + cbm_version_cohort_lease_t **lease_io); + +/* Refuses teardown while a lease or retryable cleanup handle remains. */ +cbm_private_file_lock_status_t cbm_version_cohort_manager_free( + cbm_version_cohort_manager_t **manager_io); + +/* Best-effort persistent conflict record in the same owner-only daemon log + * used by HELLO conflicts. Admission remains safely rejected if logging fails. */ +bool cbm_version_cohort_log_conflict(const cbm_daemon_conflict_t *conflict); + +/* Persist the fail-closed migration case where the stable daemon reservation + * is active but no current-generation coordination marker can be verified. */ +bool cbm_version_cohort_log_uncoordinated_daemon( + const cbm_daemon_build_identity_t *requested); + +#endif /* CBM_DAEMON_VERSION_COHORT_H */ diff --git a/src/foundation/compat.c b/src/foundation/compat.c index e59accf7e..158191d47 100644 --- a/src/foundation/compat.c +++ b/src/foundation/compat.c @@ -7,6 +7,7 @@ #include "foundation/compat.h" #include "foundation/constants.h" +#include #include #include #ifdef _WIN32 @@ -91,20 +92,28 @@ char *cbm_mkdtemp(char *tmpl) { #ifdef _WIN32 int cbm_mkstemp(char *tmpl) { /* Rewrite /tmp/ to %TEMP%\ like cbm_mkdtemp */ - static char buf[CBM_SZ_512]; + /* Per-call storage: daemon project workers can create staging files + * concurrently, so a process-global scratch buffer is a data race. */ + char buf[CBM_SZ_4K]; + int written; if (strncmp(tmpl, "/tmp/", 5) == 0) { const char *tmp = getenv("TEMP"); if (!tmp) tmp = getenv("TMP"); if (!tmp) tmp = "."; - snprintf(buf, sizeof(buf), "%s\\%s", tmp, tmpl + 5); + written = snprintf(buf, sizeof(buf), "%s\\%s", tmp, tmpl + 5); } else { - snprintf(buf, sizeof(buf), "%s", tmpl); + written = snprintf(buf, sizeof(buf), "%s", tmpl); + } + if (written < 0 || (size_t)written >= sizeof(buf)) { + errno = ENAMETOOLONG; + return CBM_NOT_FOUND; } if (!_mktemp(buf)) return CBM_NOT_FOUND; - int fd = _open(buf, _O_CREAT | _O_RDWR | _O_BINARY, _S_IREAD | _S_IWRITE); + int fd = + _open(buf, _O_CREAT | _O_EXCL | _O_RDWR | _O_BINARY, _S_IREAD | _S_IWRITE); if (fd >= 0) strcpy(tmpl, buf); return fd; diff --git a/src/foundation/compat_fs.c b/src/foundation/compat_fs.c index 54c9e3c67..b86c23410 100644 --- a/src/foundation/compat_fs.c +++ b/src/foundation/compat_fs.c @@ -802,13 +802,14 @@ int cbm_exec_no_shell(const char *const *argv) { #endif /* _WIN32 */ -/* Canonicalize an EXISTING path (collapse `..`, resolve per-OS): realpath on - * POSIX; on Windows a wide-path GetFileAttributesW existence check + - * GetFullPathNameW. The previous callers used the ANSI CRT (_access/ - * _fullpath) on UTF-8 input — locale-dependent by construction: on a CJK - * system codepage (e.g. Big5) the UTF-8 bytes of a CJK path re-decode into - * different characters and canonicalization corrupts the path (#973). - * Returns 0 when the path does not exist or cannot be resolved. */ +/* Canonicalize an EXISTING path (collapse `..`, resolve links/junctions): + * realpath on POSIX; a final path queried from an opened handle on Windows. + * The previous Windows callers used the ANSI CRT (_access/_fullpath) on UTF-8 + * input — locale-dependent by construction: on a CJK system codepage (e.g. + * Big5) the UTF-8 bytes of a CJK path re-decode into different characters and + * canonicalization corrupts the path (#973). GetFullPathNameW alone is also + * only lexical and would let an allowed-root check follow a junction outside + * the root. Returns 0 when the path does not exist or cannot be resolved. */ int cbm_canonical_path(const char *path, char *out, size_t out_sz) { if (!path || !out || out_sz == 0) { return 0; @@ -818,18 +819,46 @@ int cbm_canonical_path(const char *path, char *out, size_t out_sz) { if (!wpath) { return 0; } - if (GetFileAttributesW(wpath) == INVALID_FILE_ATTRIBUTES) { - free(wpath); + HANDLE handle = CreateFileW(wpath, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL); + free(wpath); + if (handle == INVALID_HANDLE_VALUE) { return 0; } - enum { CANON_WIDE_MAX = 4096 }; - wchar_t wfull[CANON_WIDE_MAX]; - DWORD n = GetFullPathNameW(wpath, CANON_WIDE_MAX, wfull, NULL); - free(wpath); - if (n == 0 || n >= CANON_WIDE_MAX) { + DWORD needed = + GetFinalPathNameByHandleW(handle, NULL, 0, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + if (needed == 0 || needed == MAXDWORD || (size_t)needed > SIZE_MAX / sizeof(wchar_t) - 1) { + (void)CloseHandle(handle); + return 0; + } + size_t capacity = (size_t)needed + 1; + wchar_t *wfull = calloc(capacity, sizeof(*wfull)); + if (!wfull) { + (void)CloseHandle(handle); return 0; } + DWORD n = GetFinalPathNameByHandleW(handle, wfull, (DWORD)capacity, + FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + (void)CloseHandle(handle); + if (n == 0 || (size_t)n >= capacity) { + free(wfull); + return 0; + } + + /* Preserve the conventional DOS/UNC form returned by the old API while + * retaining the handle-based resolution. */ + if (wcsncmp(wfull, L"\\\\?\\UNC\\", 8) == 0) { + size_t tail_length = wcslen(wfull + 8); + wmemmove(wfull + 2, wfull + 8, tail_length + 1); + wfull[0] = L'\\'; + wfull[1] = L'\\'; + } else if (wcsncmp(wfull, L"\\\\?\\", 4) == 0) { + size_t tail_length = wcslen(wfull + 4); + wmemmove(wfull, wfull + 4, tail_length + 1); + } char *utf8 = cbm_wide_to_utf8(wfull); + free(wfull); if (!utf8) { return 0; } @@ -859,7 +888,7 @@ int cbm_rename_replace(const char *src, const char *dst) { if (wsrc && wdst) { ret = MoveFileExW(wsrc, wdst, - MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED | MOVEFILE_WRITE_THROUGH) + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) ? 0 : CBM_NOT_FOUND; } @@ -871,23 +900,54 @@ int cbm_rename_replace(const char *src, const char *dst) { #endif } -/* Remove a SQLite database's -wal/-shm sidecars (both platforms). Any code - * path that installs a FRESH database file where a previous generation lived - * must remove them before the new generation can be opened: SQLite decides - * whether to replay a WAL purely from the sidecar's own header/checksums, so - * a leftover WAL can splice old-generation pages into the new file (#897). */ -void cbm_remove_db_sidecars(const char *db_path) { +/* Remove a SQLite database's -wal/-shm/-journal sidecars (both platforms). Any code + * path that installs a FRESH database file at a path where a previous + * generation lived must call this first: SQLite decides whether to replay a + * WAL purely from the sidecar's own header/checksums, so a leftover WAL + * from a crashed session is recovered ON TOP of the freshly installed file + * at the next open, splicing old-generation pages into it (#897). */ +int cbm_remove_db_sidecars(const char *db_path) { if (!db_path || !db_path[0]) { - return; + return CBM_NOT_FOUND; } enum { SIDECAR_PATH_MAX = 4096 }; char side[SIDECAR_PATH_MAX]; + /* Validate the longest suffix before unlinking anything. Otherwise a + * near-limit path can remove -wal/-shm, silently skip a truncated + * -journal, and report success after partially mutating the generation. */ + if (strlen(db_path) > sizeof(side) - sizeof("-journal")) { + return CBM_NOT_FOUND; + } + int result = 0; int n = snprintf(side, sizeof(side), "%s-wal", db_path); - if (n > 0 && (size_t)n < sizeof(side)) { - (void)cbm_unlink(side); + if (n <= 0 || (size_t)n >= sizeof(side)) { + return CBM_NOT_FOUND; + } + errno = 0; + int unlink_rc = cbm_unlink(side); + int unlink_error = errno; + if (unlink_rc != 0 && unlink_error != ENOENT) { + result = CBM_NOT_FOUND; } n = snprintf(side, sizeof(side), "%s-shm", db_path); - if (n > 0 && (size_t)n < sizeof(side)) { - (void)cbm_unlink(side); + if (n <= 0 || (size_t)n >= sizeof(side)) { + return CBM_NOT_FOUND; + } + errno = 0; + unlink_rc = cbm_unlink(side); + unlink_error = errno; + if (unlink_rc != 0 && unlink_error != ENOENT) { + result = CBM_NOT_FOUND; + } + n = snprintf(side, sizeof(side), "%s-journal", db_path); + if (n <= 0 || (size_t)n >= sizeof(side)) { + return CBM_NOT_FOUND; + } + errno = 0; + unlink_rc = cbm_unlink(side); + unlink_error = errno; + if (unlink_rc != 0 && unlink_error != ENOENT) { + result = CBM_NOT_FOUND; } + return result; } diff --git a/src/foundation/compat_fs.h b/src/foundation/compat_fs.h index af89c795d..92b6ca799 100644 --- a/src/foundation/compat_fs.h +++ b/src/foundation/compat_fs.h @@ -46,16 +46,18 @@ bool cbm_mkdir_p(const char *path, int mode); /* Delete a file. Returns 0 on success. */ int cbm_unlink(const char *path); -/* Remove -wal/-shm. Any path installing a fresh DB generation must - * do this before the new generation can be opened; a leftover WAL is - * otherwise replayed on top of the new file (#897). */ -void cbm_remove_db_sidecars(const char *db_path); +/* Remove -wal/-shm/-journal. MUST be called by any path installing a fresh + * DB file where a previous generation lived — a leftover WAL is otherwise + * replayed on top of the new file at the next open (#897). Returns 0 when + * every artifact is absent, -1 when cleanup could not be safely completed. */ +int cbm_remove_db_sidecars(const char *db_path); /* rename() that replaces an existing destination on every platform * (Windows rename fails with EEXIST; this uses write-through MoveFileExW). */ int cbm_rename_replace(const char *src, const char *dst); -/* Canonicalize an EXISTING path (realpath / wide GetFullPathNameW). Locale- - * independent on Windows — never routes UTF-8 through the ANSI CRT (#973). - * out must be >= 4096 bytes. Returns 1 on success, 0 otherwise. */ +/* Canonicalize an EXISTING path and resolve links/junctions (realpath / wide + * GetFinalPathNameByHandleW). Locale-independent on Windows — never routes + * UTF-8 through the ANSI CRT (#973). out must be >= 4096 bytes. Returns 1 on + * success, 0 otherwise. */ int cbm_canonical_path(const char *path, char *out, size_t out_sz); /* Delete an empty directory. Returns 0 on success. */ diff --git a/src/foundation/lock_registry.c b/src/foundation/lock_registry.c new file mode 100644 index 000000000..20617bc27 --- /dev/null +++ b/src/foundation/lock_registry.c @@ -0,0 +1,1098 @@ +/* lock_registry.c — FIFO process-local registry over private file locks. */ +#include "foundation/lock_registry.h" + +#include "foundation/compat_thread.h" +#include "foundation/lock_registry_internal.h" +#include "foundation/platform.h" +#include "foundation/private_file_lock_internal.h" +#include "foundation/sha256.h" + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#endif + +enum { + LOCK_REGISTRY_RESOURCE_CAP = 4096, + LOCK_REGISTRY_NATIVE_RETRY_MS = 1, +}; + +typedef struct lock_registry_waiter lock_registry_waiter_t; +typedef struct lock_registry_entry lock_registry_entry_t; + +struct lock_registry_waiter { + cbm_private_file_lock_mode_t mode; + lock_registry_waiter_t *next; + bool queued; +}; + +struct lock_registry_entry { + char turn_name[CBM_LOCK_REGISTRY_NAME_CAP]; + char rw_name[CBM_LOCK_REGISTRY_NAME_CAP]; + lock_registry_waiter_t *waiter_head; + lock_registry_waiter_t *waiter_tail; + lock_registry_waiter_t *attempt_waiter; + size_t active_readers; + bool writer_active; + lock_registry_entry_t *next; +}; + +struct cbm_lock_registry { + cbm_private_lock_directory_t *directory; + cbm_private_fork_condition_t *condition; + cbm_mutex_t mutex; + lock_registry_entry_t *entries; + size_t waiter_count; + size_t active_lease_count; + size_t pending_cleanup_count; + uint64_t owner_pid; + bool closing; + cbm_lock_registry_stage_hook_fn stage_hook; + void *stage_context; + cbm_lock_registry_release_handle_t test_release_fault_handle; + cbm_private_file_lock_release_step_t test_release_fault_step; + bool test_release_fault_armed; + cbm_lock_registry_abort_failure_t test_abort_failure; + bool test_abort_failure_armed; + atomic_uint_fast64_t test_condition_wait_calls; + atomic_size_t test_condition_waiters_now; + struct cbm_lock_registry *next_live; + struct cbm_lock_registry *next_retired; +}; + +struct cbm_lock_lease { + cbm_lock_registry_t *registry; + lock_registry_entry_t *entry; + lock_registry_waiter_t waiter; + cbm_private_file_lock_mode_t mode; + cbm_private_file_lock_t *turn; + cbm_private_file_lock_t *rw; + uint64_t owner_pid; + bool active; + bool cleanup_only; + bool waiter_cleanup_pending; + bool pending_registered; + bool native_released; + bool critical_released; + bool release_error; + bool test_abort_lock_failure_path; +}; + +/* Protected by cbm_private_file_lock_fork_guard_enter(). Besides serializing + * teardown against fork, this lets a caller already waiting on that guard + * reject a registry that another thread has just freed without touching the + * freed mutex. */ +static cbm_lock_registry_t *lock_registry_live; +/* Process-lifetime identity tombstones prevent a stale raw pointer from + * becoming live again through allocator address reuse. The list itself keeps + * the small retired control allocations reachable to leak detectors. */ +static cbm_lock_registry_t *lock_registry_retired; + +static uint64_t lock_registry_current_pid(void) { +#ifdef _WIN32 + return (uint64_t)GetCurrentProcessId(); +#else + return (uint64_t)getpid(); +#endif +} + +static bool lock_registry_is_live_unlocked(const cbm_lock_registry_t *registry) { + for (const cbm_lock_registry_t *cursor = lock_registry_live; cursor; + cursor = cursor->next_live) { + if (cursor == registry) { + return true; + } + } + return false; +} + +/* Lock order is always global fork guard, then registry mutex. No native lock + * operation or user callback is permitted while either one is held. */ +static bool lock_registry_lock(cbm_lock_registry_t *registry) { + if (!registry || !cbm_private_file_lock_fork_guard_enter()) { + return false; + } + if (!lock_registry_is_live_unlocked(registry)) { + cbm_private_file_lock_fork_guard_leave(); + return false; + } + cbm_mutex_lock(®istry->mutex); + if (registry->closing || registry->owner_pid != lock_registry_current_pid()) { + cbm_mutex_unlock(®istry->mutex); + cbm_private_file_lock_fork_guard_leave(); + return false; + } + return true; +} + +static void lock_registry_unlock(cbm_lock_registry_t *registry) { + cbm_mutex_unlock(®istry->mutex); + cbm_private_file_lock_fork_guard_leave(); +} + +/* Requires the global fork guard and registry mutex, in that order. */ +static void lock_registry_broadcast_locked(cbm_lock_registry_t *registry) { + cbm_private_fork_condition_broadcast_while_guarded(registry->condition); +} + +/* Requires the global fork guard and registry mutex, in that order. The + * condition is associated with the global guard: release only the registry + * mutex before the atomic guard-release-and-wait operation, then restore the + * full G -> R lock order before returning. */ +static cbm_private_fork_wait_status_t lock_registry_wait_locked( + cbm_lock_registry_t *registry, uint64_t deadline_ms) { + (void)atomic_fetch_add_explicit(®istry->test_condition_wait_calls, 1, + memory_order_relaxed); + (void)atomic_fetch_add_explicit(®istry->test_condition_waiters_now, 1, + memory_order_relaxed); + cbm_mutex_unlock(®istry->mutex); + cbm_private_fork_wait_status_t status = + cbm_private_fork_condition_wait_until_while_guarded(registry->condition, deadline_ms); + cbm_mutex_lock(®istry->mutex); + (void)atomic_fetch_sub_explicit(®istry->test_condition_waiters_now, 1, + memory_order_relaxed); + return status; +} + +static uint64_t lock_registry_bounded_deadline(uint64_t deadline_ms, uint64_t interval_ms) { + uint64_t now_ms = cbm_now_ms(); + uint64_t bounded = now_ms > UINT64_MAX - interval_ms ? UINT64_MAX : now_ms + interval_ms; + return deadline_ms < bounded ? deadline_ms : bounded; +} + +/* Native ownership can change in another process, so the attempt owner keeps + * a bounded retry even when no process-local state transition broadcasts. */ +static bool lock_registry_park_native(cbm_lock_registry_t *registry, uint64_t deadline_ms, + const cbm_lock_cancel_token_t *cancel_token) { + if (!lock_registry_lock(registry)) { + return false; + } + cbm_private_fork_wait_status_t status = CBM_PRIVATE_FORK_WAIT_SIGNALED; + if (!cancel_token || !atomic_load_explicit(cancel_token, memory_order_acquire)) { + status = lock_registry_wait_locked( + registry, lock_registry_bounded_deadline(deadline_ms, LOCK_REGISTRY_NATIVE_RETRY_MS)); + } + lock_registry_unlock(registry); + return status != CBM_PRIVATE_FORK_WAIT_ERROR; +} + +static bool lock_registry_should_stop(uint64_t deadline_ms, + const cbm_lock_cancel_token_t *cancel_token) { + if (cancel_token && atomic_load_explicit(cancel_token, memory_order_acquire)) { + return true; + } + return deadline_ms != UINT64_MAX && cbm_now_ms() >= deadline_ms; +} + +static void lock_registry_notify(cbm_lock_registry_t *registry, cbm_private_file_lock_mode_t mode, + cbm_lock_registry_stage_t stage) { + if (!lock_registry_lock(registry)) { + return; + } + cbm_lock_registry_stage_hook_fn hook = registry->stage_hook; + void *context = registry->stage_context; + lock_registry_unlock(registry); + if (hook) { + hook(context, mode, stage); + } +} + +static lock_registry_entry_t *lock_registry_find_entry(cbm_lock_registry_t *registry, + const char *turn_name, const char *rw_name) { + for (lock_registry_entry_t *entry = registry->entries; entry; entry = entry->next) { + if (strcmp(entry->turn_name, turn_name) == 0 && strcmp(entry->rw_name, rw_name) == 0) { + return entry; + } + } + return NULL; +} + +static void lock_registry_waiter_push(cbm_lock_registry_t *registry, lock_registry_entry_t *entry, + lock_registry_waiter_t *waiter) { + waiter->next = NULL; + waiter->queued = true; + if (entry->waiter_tail) { + entry->waiter_tail->next = waiter; + } else { + entry->waiter_head = waiter; + } + entry->waiter_tail = waiter; + registry->waiter_count++; +} + +static bool lock_registry_waiter_remove(cbm_lock_registry_t *registry, lock_registry_entry_t *entry, + lock_registry_waiter_t *waiter) { + if (!waiter->queued) { + return false; + } + lock_registry_waiter_t *previous = NULL; + lock_registry_waiter_t *cursor = entry->waiter_head; + while (cursor && cursor != waiter) { + previous = cursor; + cursor = cursor->next; + } + if (!cursor) { + return false; + } + if (previous) { + previous->next = cursor->next; + } else { + entry->waiter_head = cursor->next; + } + if (entry->waiter_tail == cursor) { + entry->waiter_tail = previous; + } + cursor->next = NULL; + cursor->queued = false; + registry->waiter_count--; + lock_registry_broadcast_locked(registry); + return true; +} + +static void lock_registry_apply_release_fault(cbm_lock_registry_t *registry, + cbm_lock_registry_release_handle_t handle, + cbm_private_file_lock_t *lock) { + if (!registry || !lock || !lock_registry_lock(registry)) { + return; + } + bool inject = + registry->test_release_fault_armed && registry->test_release_fault_handle == handle; + cbm_private_file_lock_release_step_t step = registry->test_release_fault_step; + if (inject) { + registry->test_release_fault_armed = false; + } + lock_registry_unlock(registry); + if (inject) { + (void)cbm_private_file_lock_fail_next_release_step_for_test(lock, step); + } +} + +/* Rollback and writer release are deliberately rw-before-turn. */ +static cbm_private_file_lock_status_t lock_registry_release_native( + cbm_lock_registry_t *registry, cbm_private_file_lock_t **rw_io, + cbm_private_file_lock_t **turn_io) { + cbm_private_file_lock_status_t result = CBM_PRIVATE_FILE_LOCK_OK; + lock_registry_apply_release_fault(registry, CBM_LOCK_REGISTRY_RELEASE_RW, *rw_io); + if (*rw_io && cbm_private_file_lock_release(rw_io) != CBM_PRIVATE_FILE_LOCK_OK) { + result = CBM_PRIVATE_FILE_LOCK_IO; + if (*rw_io && !cbm_private_file_lock_unlock_complete(*rw_io)) { + return result; + } + } + lock_registry_apply_release_fault(registry, CBM_LOCK_REGISTRY_RELEASE_TURN, *turn_io); + if (*turn_io && cbm_private_file_lock_release(turn_io) != CBM_PRIVATE_FILE_LOCK_OK) { + result = CBM_PRIVATE_FILE_LOCK_IO; + } + return result; +} + +bool cbm_lock_registry_resource_names(const char *resource_key, + char turn_out[CBM_LOCK_REGISTRY_NAME_CAP], + char rw_out[CBM_LOCK_REGISTRY_NAME_CAP]) { + if (!resource_key || !resource_key[0] || !turn_out || !rw_out) { + return false; + } + size_t length = strnlen(resource_key, LOCK_REGISTRY_RESOURCE_CAP + 1U); + if (length == 0 || length > LOCK_REGISTRY_RESOURCE_CAP) { + return false; + } + char digest[CBM_SHA256_HEX_LEN + 1]; + cbm_sha256_hex(resource_key, length, digest); + int turn_written = snprintf(turn_out, CBM_LOCK_REGISTRY_NAME_CAP, "cbm-%s.turn", digest); + int rw_written = snprintf(rw_out, CBM_LOCK_REGISTRY_NAME_CAP, "cbm-%s.rw", digest); + return turn_written > 0 && turn_written < (int)CBM_LOCK_REGISTRY_NAME_CAP && rw_written > 0 && + rw_written < (int)CBM_LOCK_REGISTRY_NAME_CAP; +} + +cbm_lock_registry_t *cbm_lock_registry_new(cbm_private_lock_directory_t *directory) { + if (!directory) { + return NULL; + } + cbm_lock_registry_t *registry = calloc(1, sizeof(*registry)); + if (!registry) { + return NULL; + } + registry->directory = directory; + registry->owner_pid = lock_registry_current_pid(); + cbm_mutex_init(®istry->mutex); + registry->condition = cbm_private_fork_condition_new(); + if (!registry->condition) { + cbm_mutex_destroy(®istry->mutex); + free(registry); + return NULL; + } + if (!cbm_private_file_lock_fork_guard_enter()) { + cbm_private_fork_condition_free(registry->condition); + cbm_mutex_destroy(®istry->mutex); + free(registry); + return NULL; + } + registry->next_live = lock_registry_live; + lock_registry_live = registry; + cbm_private_file_lock_fork_guard_leave(); + return registry; +} + +cbm_private_file_lock_status_t cbm_lock_registry_request_cancel( + cbm_lock_registry_t *registry, cbm_lock_cancel_token_t *token) { + if (!token) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + atomic_store_explicit(token, true, memory_order_release); + if (!lock_registry_lock(registry)) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + lock_registry_broadcast_locked(registry); + lock_registry_unlock(registry); + return CBM_PRIVATE_FILE_LOCK_OK; +} + +static void lock_registry_retain_waiter_cleanup(cbm_lock_lease_t *lease, + cbm_private_file_lock_t **rw_io, + cbm_private_file_lock_t **turn_io, + cbm_lock_lease_t **lease_out) { + lease->rw = *rw_io; + lease->turn = *turn_io; + lease->cleanup_only = true; + lease->waiter_cleanup_pending = true; + *rw_io = NULL; + *turn_io = NULL; + *lease_out = lease; +} + +static bool lock_registry_abort_lock(cbm_lock_registry_t *registry) { + if (!lock_registry_lock(registry)) { + return false; + } + bool fail_lock = registry->test_abort_failure_armed && + registry->test_abort_failure == CBM_LOCK_REGISTRY_ABORT_FAIL_LOCK; + if (fail_lock) { + registry->test_abort_failure_armed = false; + lock_registry_unlock(registry); + return false; + } + return true; +} + +static cbm_private_file_lock_status_t lock_registry_abort_attempt( + cbm_lock_registry_t *registry, lock_registry_entry_t *entry, lock_registry_waiter_t *waiter, + cbm_private_file_lock_t **rw_io, cbm_private_file_lock_t **turn_io, + cbm_private_file_lock_status_t requested_status, cbm_lock_lease_t *lease, + cbm_lock_lease_t **lease_out) { + if (!lock_registry_abort_lock(registry)) { + lease->test_abort_lock_failure_path = true; + lock_registry_retain_waiter_cleanup(lease, rw_io, turn_io, lease_out); + return CBM_PRIVATE_FILE_LOCK_IO; + } + bool owns_attempt = entry->attempt_waiter == waiter; + bool fail_remove = registry->test_abort_failure_armed && + registry->test_abort_failure == CBM_LOCK_REGISTRY_ABORT_FAIL_REMOVE; + if (fail_remove) { + registry->test_abort_failure_armed = false; + } + bool removed = !fail_remove && lock_registry_waiter_remove(registry, entry, waiter); + if (removed && owns_attempt) { + entry->attempt_waiter = NULL; + } + bool has_native = *rw_io || *turn_io; + if (removed && has_native) { + lease->registry = registry; + lease->mode = waiter->mode; + lease->rw = *rw_io; + lease->turn = *turn_io; + lease->owner_pid = registry->owner_pid; + lease->cleanup_only = true; + lease->pending_registered = true; + lease->entry = NULL; + registry->pending_cleanup_count++; + lock_registry_broadcast_locked(registry); + *rw_io = NULL; + *turn_io = NULL; + } + lock_registry_unlock(registry); + if (!removed) { + lock_registry_retain_waiter_cleanup(lease, rw_io, turn_io, lease_out); + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (!has_native) { + free(lease); + return owns_attempt ? requested_status : CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_lock_lease_t *cleanup = lease; + if (cbm_lock_lease_release(&cleanup) != CBM_PRIVATE_FILE_LOCK_OK) { + *lease_out = cleanup; + return CBM_PRIVATE_FILE_LOCK_IO; + } + return owns_attempt ? requested_status : CBM_PRIVATE_FILE_LOCK_IO; +} + +static cbm_private_file_lock_status_t lock_registry_attempt_native( + cbm_lock_registry_t *registry, lock_registry_entry_t *entry, lock_registry_waiter_t *waiter, + uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, + bool try_once, cbm_lock_lease_t *lease, cbm_lock_lease_t **lease_out) { + cbm_private_file_lock_t *turn = NULL; + cbm_private_file_lock_t *rw = NULL; + cbm_private_file_lock_status_t status = CBM_PRIVATE_FILE_LOCK_BUSY; + + while (!rw) { + if (!try_once && lock_registry_should_stop(deadline_ms, cancel_token)) { + status = CBM_PRIVATE_FILE_LOCK_BUSY; + break; + } + if (!turn) { + cbm_private_file_lock_mode_t turn_mode = waiter->mode == CBM_PRIVATE_FILE_LOCK_SH + ? CBM_PRIVATE_FILE_LOCK_EX + : CBM_PRIVATE_FILE_LOCK_SH; + status = cbm_private_file_lock_try_acquire(registry->directory, entry->turn_name, + turn_mode, &turn); + if (status == CBM_PRIVATE_FILE_LOCK_BUSY) { + lock_registry_notify(registry, waiter->mode, CBM_LOCK_REGISTRY_STAGE_TURN_BUSY); + if (try_once) { + break; + } + if (!lock_registry_park_native(registry, deadline_ms, cancel_token)) { + status = CBM_PRIVATE_FILE_LOCK_IO; + break; + } + continue; + } + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + break; + } + lock_registry_notify(registry, waiter->mode, CBM_LOCK_REGISTRY_STAGE_TURN_HELD); + } + + if (!try_once && lock_registry_should_stop(deadline_ms, cancel_token)) { + status = CBM_PRIVATE_FILE_LOCK_BUSY; + break; + } + status = cbm_private_file_lock_try_acquire(registry->directory, entry->rw_name, + waiter->mode, &rw); + if (status == CBM_PRIVATE_FILE_LOCK_BUSY) { + lock_registry_notify(registry, waiter->mode, CBM_LOCK_REGISTRY_STAGE_RW_BUSY); + if (try_once) { + break; + } + if (!lock_registry_park_native(registry, deadline_ms, cancel_token)) { + status = CBM_PRIVATE_FILE_LOCK_IO; + break; + } + continue; + } + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + break; + } + if (waiter->mode == CBM_PRIVATE_FILE_LOCK_SH) { + status = cbm_private_file_lock_release(&turn); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + break; + } + } + lock_registry_notify(registry, waiter->mode, CBM_LOCK_REGISTRY_STAGE_NATIVE_READY); + } + + if (!rw || status != CBM_PRIVATE_FILE_LOCK_OK) { + return lock_registry_abort_attempt(registry, entry, waiter, &rw, &turn, status, lease, + lease_out); + } + + for (;;) { + if (!try_once && lock_registry_should_stop(deadline_ms, cancel_token)) { + return lock_registry_abort_attempt(registry, entry, waiter, &rw, &turn, + CBM_PRIVATE_FILE_LOCK_BUSY, lease, lease_out); + } + if (!lock_registry_lock(registry)) { + return lock_registry_abort_attempt(registry, entry, waiter, &rw, &turn, + CBM_PRIVATE_FILE_LOCK_IO, lease, lease_out); + } + bool owns_attempt = entry->attempt_waiter == waiter && entry->waiter_head == waiter; + bool local_state_ready = waiter->mode == CBM_PRIVATE_FILE_LOCK_SH + ? !entry->writer_active + : !entry->writer_active && entry->active_readers == 0; + bool removed = owns_attempt && local_state_ready && + lock_registry_waiter_remove(registry, entry, waiter); + cbm_private_fork_wait_status_t wait_status = CBM_PRIVATE_FORK_WAIT_SIGNALED; + if (removed) { + entry->attempt_waiter = NULL; + if (waiter->mode == CBM_PRIVATE_FILE_LOCK_SH) { + entry->active_readers++; + } else { + entry->writer_active = true; + } + registry->active_lease_count++; + lease->registry = registry; + lease->entry = entry; + lease->mode = waiter->mode; + lease->turn = turn; + lease->rw = rw; + lease->owner_pid = registry->owner_pid; + lease->active = true; + turn = NULL; + rw = NULL; + lock_registry_broadcast_locked(registry); + } else if (!try_once && owns_attempt && + (!cancel_token || + !atomic_load_explicit(cancel_token, memory_order_acquire))) { + wait_status = lock_registry_wait_locked(registry, deadline_ms); + } + lock_registry_unlock(registry); + if (removed) { + *lease_out = lease; + return CBM_PRIVATE_FILE_LOCK_OK; + } + if (try_once) { + return lock_registry_abort_attempt( + registry, entry, waiter, &rw, &turn, + CBM_PRIVATE_FILE_LOCK_BUSY, lease, lease_out); + } + if (!owns_attempt) { + return lock_registry_abort_attempt(registry, entry, waiter, &rw, &turn, + CBM_PRIVATE_FILE_LOCK_IO, lease, lease_out); + } + if (wait_status == CBM_PRIVATE_FORK_WAIT_ERROR) { + return lock_registry_abort_attempt(registry, entry, waiter, &rw, &turn, + CBM_PRIVATE_FILE_LOCK_IO, lease, lease_out); + } + } +} + +static cbm_private_file_lock_status_t lock_registry_acquire_internal( + cbm_lock_registry_t *registry, const char *resource_key, cbm_private_file_lock_mode_t mode, + uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, + bool try_once, cbm_lock_lease_t **lease_out) { + if (lease_out) { + *lease_out = NULL; + } + if (!registry || !lease_out) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (mode != CBM_PRIVATE_FILE_LOCK_SH && mode != CBM_PRIVATE_FILE_LOCK_EX) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + char turn_name[CBM_LOCK_REGISTRY_NAME_CAP]; + char rw_name[CBM_LOCK_REGISTRY_NAME_CAP]; + if (!cbm_lock_registry_resource_names(resource_key, turn_name, rw_name)) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!try_once && lock_registry_should_stop(deadline_ms, cancel_token)) { + return CBM_PRIVATE_FILE_LOCK_BUSY; + } + + lock_registry_entry_t *candidate = calloc(1, sizeof(*candidate)); + cbm_lock_lease_t *lease = calloc(1, sizeof(*lease)); + if (!candidate || !lease) { + free(candidate); + free(lease); + return CBM_PRIVATE_FILE_LOCK_IO; + } + (void)snprintf(candidate->turn_name, sizeof(candidate->turn_name), "%s", turn_name); + (void)snprintf(candidate->rw_name, sizeof(candidate->rw_name), "%s", rw_name); + lease->waiter.mode = mode; + + if (!lock_registry_lock(registry)) { + free(candidate); + free(lease); + return CBM_PRIVATE_FILE_LOCK_IO; + } + lock_registry_entry_t *entry = lock_registry_find_entry(registry, turn_name, rw_name); + if (!entry) { + entry = candidate; + candidate = NULL; + entry->next = registry->entries; + registry->entries = entry; + } + lease->registry = registry; + lease->entry = entry; + lease->mode = mode; + lease->owner_pid = registry->owner_pid; + lock_registry_waiter_push(registry, entry, &lease->waiter); + lock_registry_unlock(registry); + free(candidate); + + for (;;) { + bool should_stop = + !try_once && lock_registry_should_stop(deadline_ms, cancel_token); + if (!lock_registry_lock(registry)) { + lease->cleanup_only = true; + lease->waiter_cleanup_pending = true; + *lease_out = lease; + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (should_stop) { + bool removed = lock_registry_waiter_remove(registry, entry, &lease->waiter); + lock_registry_unlock(registry); + if (removed) { + free(lease); + return CBM_PRIVATE_FILE_LOCK_BUSY; + } + lease->cleanup_only = true; + lease->waiter_cleanup_pending = true; + *lease_out = lease; + return CBM_PRIVATE_FILE_LOCK_IO; + } + bool can_attempt = entry->waiter_head == &lease->waiter && entry->attempt_waiter == NULL; + cbm_private_fork_wait_status_t wait_status = CBM_PRIVATE_FORK_WAIT_SIGNALED; + if (can_attempt) { + entry->attempt_waiter = &lease->waiter; + } else if (try_once) { + bool removed = + lock_registry_waiter_remove(registry, entry, &lease->waiter); + lock_registry_unlock(registry); + if (removed) { + free(lease); + return CBM_PRIVATE_FILE_LOCK_BUSY; + } + lease->cleanup_only = true; + lease->waiter_cleanup_pending = true; + *lease_out = lease; + return CBM_PRIVATE_FILE_LOCK_IO; + } else if (!cancel_token || + !atomic_load_explicit(cancel_token, memory_order_acquire)) { + wait_status = lock_registry_wait_locked(registry, deadline_ms); + } + lock_registry_unlock(registry); + if (can_attempt) { + return lock_registry_attempt_native(registry, entry, &lease->waiter, deadline_ms, + cancel_token, try_once, lease, lease_out); + } + if (wait_status == CBM_PRIVATE_FORK_WAIT_ERROR) { + lease->cleanup_only = true; + lease->waiter_cleanup_pending = true; + *lease_out = lease; + return CBM_PRIVATE_FILE_LOCK_IO; + } + } +} + +cbm_private_file_lock_status_t cbm_lock_registry_acquire( + cbm_lock_registry_t *registry, const char *resource_key, + cbm_private_file_lock_mode_t mode, uint64_t deadline_ms, + const cbm_lock_cancel_token_t *cancel_token, + cbm_lock_lease_t **lease_out) { + return lock_registry_acquire_internal(registry, resource_key, mode, + deadline_ms, cancel_token, false, + lease_out); +} + +cbm_private_file_lock_status_t cbm_lock_registry_try_acquire( + cbm_lock_registry_t *registry, const char *resource_key, + cbm_private_file_lock_mode_t mode, cbm_lock_lease_t **lease_out) { + return lock_registry_acquire_internal(registry, resource_key, mode, + UINT64_MAX, NULL, true, lease_out); +} + +static cbm_private_file_lock_status_t lock_registry_cleanup_lease_release( + cbm_lock_lease_t **lease_io) { + cbm_lock_lease_t *lease = *lease_io; + cbm_lock_registry_t *registry = lease->registry; + bool waiter_state_valid = lease->waiter_cleanup_pending && lease->entry && + lease->waiter.queued && !lease->pending_registered && + !lease->native_released; + bool pending_state_valid = + !lease->waiter_cleanup_pending && lease->pending_registered && + (lease->native_released ? !lease->rw && !lease->turn : lease->rw || lease->turn); + if (!registry || !lease->cleanup_only || (!waiter_state_valid && !pending_state_valid)) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + + if (lease->waiter_cleanup_pending) { + if (!lock_registry_lock(registry)) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + lock_registry_entry_t *entry = lease->entry; + bool removed = lock_registry_waiter_remove(registry, entry, &lease->waiter); + if (!removed) { + lock_registry_unlock(registry); + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (entry->attempt_waiter == &lease->waiter) { + entry->attempt_waiter = NULL; + } + lease->waiter_cleanup_pending = false; + lease->entry = NULL; + bool has_native = lease->rw || lease->turn; + if (has_native) { + registry->pending_cleanup_count++; + lease->pending_registered = true; + lock_registry_broadcast_locked(registry); + } else { + lease->native_released = true; + } + lock_registry_unlock(registry); + if (!has_native) { + *lease_io = NULL; + free(lease); + return CBM_PRIVATE_FILE_LOCK_OK; + } + } + + if (!lock_registry_lock(registry)) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + bool registered = registry->pending_cleanup_count > 0; + lock_registry_unlock(registry); + if (!registered) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + + cbm_private_file_lock_status_t result = CBM_PRIVATE_FILE_LOCK_OK; + if (!lease->native_released) { + cbm_private_file_lock_status_t status = + lock_registry_release_native(registry, &lease->rw, &lease->turn); + lease->native_released = !lease->rw && !lease->turn; + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + result = CBM_PRIVATE_FILE_LOCK_IO; + if (!lease->native_released) { + return result; + } + } + } + if (!lock_registry_lock(registry)) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (registry->pending_cleanup_count == 0) { + lock_registry_unlock(registry); + return CBM_PRIVATE_FILE_LOCK_IO; + } + registry->pending_cleanup_count--; + lease->pending_registered = false; + lock_registry_broadcast_locked(registry); + lock_registry_unlock(registry); + *lease_io = NULL; + free(lease); + return result; +} + +cbm_private_file_lock_status_t cbm_lock_lease_release(cbm_lock_lease_t **lease_io) { + if (!lease_io || !*lease_io) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_lock_lease_t *lease = *lease_io; + if (lease->owner_pid != lock_registry_current_pid()) { + cbm_private_file_lock_status_t status = + lock_registry_release_native(lease->registry, &lease->rw, &lease->turn); + if (lease->waiter_cleanup_pending || lease->waiter.queued) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (status == CBM_PRIVATE_FILE_LOCK_OK) { + *lease_io = NULL; + free(lease); + } + return status; + } + if (lease->cleanup_only) { + return lock_registry_cleanup_lease_release(lease_io); + } + + cbm_lock_registry_t *registry = lease->registry; + lock_registry_entry_t *entry = lease->entry; + bool native_state_valid = false; + if (lease->critical_released) { + native_state_valid = true; + } else if (lease->mode == CBM_PRIVATE_FILE_LOCK_SH) { + native_state_valid = lease->rw && !lease->turn; + } else if (lease->mode == CBM_PRIVATE_FILE_LOCK_EX) { + native_state_valid = lease->turn != NULL; + } + if (!registry || !entry || !lease->active || !native_state_valid || + !lock_registry_lock(registry)) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + bool valid = registry->active_lease_count > 0; + if (valid && lease->mode == CBM_PRIVATE_FILE_LOCK_SH) { + valid = entry->active_readers > 0; + } else if (valid && lease->mode == CBM_PRIVATE_FILE_LOCK_EX) { + valid = entry->writer_active; + } else { + valid = false; + } + lock_registry_unlock(registry); + if (!valid) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + + if (!lease->critical_released) { + cbm_private_file_lock_status_t status = + lock_registry_release_native(registry, &lease->rw, &lease->turn); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + lease->release_error = true; + } + if (lease->rw && !cbm_private_file_lock_unlock_complete(lease->rw)) { + return status; + } + lease->critical_released = true; + lease->native_released = !lease->rw && !lease->turn; + } + if (!lock_registry_lock(registry)) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (lease->mode == CBM_PRIVATE_FILE_LOCK_SH) { + entry->active_readers--; + } else { + entry->writer_active = false; + } + registry->active_lease_count--; + lease->active = false; + bool cleanup_pending = lease->rw || lease->turn; + if (cleanup_pending) { + registry->pending_cleanup_count++; + lease->cleanup_only = true; + lease->pending_registered = true; + lease->entry = NULL; + } + lock_registry_broadcast_locked(registry); + lock_registry_unlock(registry); + if (cleanup_pending) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_private_file_lock_status_t result = + lease->release_error ? CBM_PRIVATE_FILE_LOCK_IO : CBM_PRIVATE_FILE_LOCK_OK; + *lease_io = NULL; + free(lease); + return result; +} + +cbm_private_file_lock_status_t cbm_lock_registry_free(cbm_lock_registry_t **registry_io) { + if (!registry_io || !*registry_io || !cbm_private_file_lock_fork_guard_enter()) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_lock_registry_t *registry = *registry_io; + cbm_lock_registry_t **live_cursor = &lock_registry_live; + while (*live_cursor && *live_cursor != registry) { + live_cursor = &(*live_cursor)->next_live; + } + if (!*live_cursor) { + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_IO; + } + + cbm_mutex_lock(®istry->mutex); + if (registry->closing || registry->owner_pid != lock_registry_current_pid()) { + cbm_mutex_unlock(®istry->mutex); + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_IO; + } + bool idle = registry->waiter_count == 0 && registry->active_lease_count == 0 && + registry->pending_cleanup_count == 0 && + atomic_load_explicit(®istry->test_condition_waiters_now, + memory_order_relaxed) == 0; + for (lock_registry_entry_t *entry = registry->entries; idle && entry; entry = entry->next) { + idle = !entry->waiter_head && !entry->waiter_tail && !entry->attempt_waiter && + entry->active_readers == 0 && !entry->writer_active; + } + if (!idle) { + cbm_mutex_unlock(®istry->mutex); + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_BUSY; + } + registry->closing = true; + *live_cursor = registry->next_live; + registry->next_live = NULL; + cbm_mutex_unlock(®istry->mutex); + + lock_registry_entry_t *entry = registry->entries; + while (entry) { + lock_registry_entry_t *next = entry->next; + free(entry); + entry = next; + } + registry->entries = NULL; + cbm_private_fork_condition_free(registry->condition); + registry->condition = NULL; + cbm_mutex_destroy(®istry->mutex); + registry->directory = NULL; + registry->owner_pid = 0; + registry->stage_hook = NULL; + registry->stage_context = NULL; + registry->test_release_fault_handle = 0; + registry->test_release_fault_step = 0; + registry->test_release_fault_armed = false; + registry->test_abort_failure = 0; + registry->test_abort_failure_armed = false; + atomic_store_explicit(®istry->test_condition_wait_calls, 0, memory_order_relaxed); + atomic_store_explicit(®istry->test_condition_waiters_now, 0, memory_order_relaxed); + registry->next_retired = lock_registry_retired; + lock_registry_retired = registry; + *registry_io = NULL; + /* Retaining this guard through destruction prevents atfork from observing + * a partially detached registry or destroyed registry mutex. */ + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_OK; +} + +size_t cbm_lock_registry_waiter_count(cbm_lock_registry_t *registry) { + if (!lock_registry_lock(registry)) { + return 0; + } + size_t count = registry->waiter_count; + lock_registry_unlock(registry); + return count; +} + +size_t cbm_lock_registry_active_lease_count_for_test(cbm_lock_registry_t *registry) { + if (!lock_registry_lock(registry)) { + return 0; + } + size_t count = registry->active_lease_count; + lock_registry_unlock(registry); + return count; +} + +size_t cbm_lock_registry_pending_cleanup_count_for_test(cbm_lock_registry_t *registry) { + if (!lock_registry_lock(registry)) { + return 0; + } + size_t count = registry->pending_cleanup_count; + lock_registry_unlock(registry); + return count; +} + +bool cbm_lock_registry_is_retired_for_test(const cbm_lock_registry_t *registry) { + if (!registry || !cbm_private_file_lock_fork_guard_enter()) { + return false; + } + bool retired = false; + for (const cbm_lock_registry_t *cursor = lock_registry_retired; cursor; + cursor = cursor->next_retired) { + if (cursor == registry) { + retired = true; + break; + } + } + cbm_private_file_lock_fork_guard_leave(); + return retired; +} + +size_t cbm_lock_registry_attempting_waiter_count_for_test(cbm_lock_registry_t *registry) { + if (!lock_registry_lock(registry)) { + return 0; + } + size_t count = 0; + for (const lock_registry_entry_t *entry = registry->entries; entry; entry = entry->next) { + if (entry->attempt_waiter) { + count++; + } + } + lock_registry_unlock(registry); + return count; +} + +uint64_t cbm_lock_registry_condition_wait_call_count_for_test( + const cbm_lock_registry_t *registry) { + return registry ? atomic_load_explicit(®istry->test_condition_wait_calls, + memory_order_relaxed) + : 0; +} + +size_t cbm_lock_registry_condition_waiter_count_for_test(const cbm_lock_registry_t *registry) { + return registry ? atomic_load_explicit(®istry->test_condition_waiters_now, + memory_order_relaxed) + : 0; +} + +bool cbm_lock_lease_fail_next_release_step_for_test(cbm_lock_lease_t *lease, + cbm_lock_registry_release_handle_t handle, + cbm_private_file_lock_release_step_t step) { + if (!lease) { + return false; + } + cbm_private_file_lock_t *lock = handle == CBM_LOCK_REGISTRY_RELEASE_RW ? lease->rw + : handle == CBM_LOCK_REGISTRY_RELEASE_TURN ? lease->turn + : NULL; + return cbm_private_file_lock_fail_next_release_step_for_test(lock, step); +} + +bool cbm_lock_lease_has_release_handle_for_test(const cbm_lock_lease_t *lease, + cbm_lock_registry_release_handle_t handle) { + if (!lease) { + return false; + } + if (handle == CBM_LOCK_REGISTRY_RELEASE_RW) { + return lease->rw != NULL; + } + if (handle == CBM_LOCK_REGISTRY_RELEASE_TURN) { + return lease->turn != NULL; + } + return false; +} + +bool cbm_lock_lease_used_abort_lock_failure_path_for_test(const cbm_lock_lease_t *lease) { + return lease && lease->test_abort_lock_failure_path; +} + +#ifndef _WIN32 +bool cbm_lock_lease_fail_close_after_consuming_for_test(cbm_lock_lease_t *lease, + cbm_lock_registry_release_handle_t handle) { + if (!lease) { + return false; + } + cbm_private_file_lock_t *lock = handle == CBM_LOCK_REGISTRY_RELEASE_RW ? lease->rw + : handle == CBM_LOCK_REGISTRY_RELEASE_TURN ? lease->turn + : NULL; + return cbm_private_file_lock_fail_close_after_consuming_for_test(lock); +} +#endif + +bool cbm_lock_registry_fail_next_native_release_step_for_test( + cbm_lock_registry_t *registry, cbm_lock_registry_release_handle_t handle, + cbm_private_file_lock_release_step_t step) { + if ((handle != CBM_LOCK_REGISTRY_RELEASE_RW && handle != CBM_LOCK_REGISTRY_RELEASE_TURN) || + (step != CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK && + step != CBM_PRIVATE_FILE_LOCK_RELEASE_CLOSE) || + !lock_registry_lock(registry)) { + return false; + } + bool idle = registry->waiter_count == 0 && registry->active_lease_count == 0 && + registry->pending_cleanup_count == 0 && !registry->test_release_fault_armed; + if (idle) { + registry->test_release_fault_handle = handle; + registry->test_release_fault_step = step; + registry->test_release_fault_armed = true; + } + lock_registry_unlock(registry); + return idle; +} + +bool cbm_lock_registry_fail_next_abort_bookkeeping_for_test( + cbm_lock_registry_t *registry, cbm_lock_registry_abort_failure_t failure) { + if ((failure != CBM_LOCK_REGISTRY_ABORT_FAIL_LOCK && + failure != CBM_LOCK_REGISTRY_ABORT_FAIL_REMOVE) || + !lock_registry_lock(registry)) { + return false; + } + bool idle = registry->waiter_count == 0 && registry->active_lease_count == 0 && + registry->pending_cleanup_count == 0 && !registry->test_abort_failure_armed; + if (idle) { + registry->test_abort_failure = failure; + registry->test_abort_failure_armed = true; + } + lock_registry_unlock(registry); + return idle; +} + +bool cbm_lock_registry_set_stage_hook_for_test(cbm_lock_registry_t *registry, + cbm_lock_registry_stage_hook_fn hook, + void *context) { + if (!lock_registry_lock(registry)) { + return false; + } + bool idle = registry->waiter_count == 0 && registry->active_lease_count == 0 && + registry->pending_cleanup_count == 0; + if (idle) { + registry->stage_hook = hook; + registry->stage_context = context; + } + lock_registry_unlock(registry); + return idle; +} diff --git a/src/foundation/lock_registry.h b/src/foundation/lock_registry.h new file mode 100644 index 000000000..fdf0b2d39 --- /dev/null +++ b/src/foundation/lock_registry.h @@ -0,0 +1,67 @@ +/* + * lock_registry.h — Generic writer-preference registry over private file locks. + */ +#ifndef CBM_LOCK_REGISTRY_H +#define CBM_LOCK_REGISTRY_H + +#include "foundation/private_file_lock.h" + +#include +#include +#include +#include + +#define CBM_LOCK_REGISTRY_NAME_CAP 80U + +typedef struct cbm_lock_registry cbm_lock_registry_t; +typedef struct cbm_lock_lease cbm_lock_lease_t; +typedef atomic_bool cbm_lock_cancel_token_t; + +cbm_lock_registry_t *cbm_lock_registry_new(cbm_private_lock_directory_t *directory); + +/* Sticky cancellation: stores true with release ordering and wakes registry + * waiters. The token must outlive every acquisition that observes it. */ +cbm_private_file_lock_status_t cbm_lock_registry_request_cancel( + cbm_lock_registry_t *registry, cbm_lock_cancel_token_t *token); + +/* The directory is borrowed and must outlive the registry and every lease. + * Resource keys must come from a bounded internal namespace: lock sidecars are + * deliberately stable and are not unlinked on release. */ + +/* deadline_ms is an absolute cbm_now_ms() deadline; UINT64_MAX means no + * deadline. cancel_token may be NULL; otherwise initialize it to false before + * acquisition and keep it alive until acquisition returns. Tokens are sticky + * and are not reset by the registry. Clean cancellation/deadline rollback + * returns BUSY with a NULL lease. Waiter-bookkeeping or native-rollback + * failures can return IO with a cleanup-only lease; callers must release every + * non-NULL lease even when acquisition did not return OK. IO may return a NULL + * lease after a terminal native close error when no retryable ownership or + * accounting remains. */ +cbm_private_file_lock_status_t cbm_lock_registry_acquire( + cbm_lock_registry_t *registry, const char *resource_key, cbm_private_file_lock_mode_t mode, + uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, + cbm_lock_lease_t **lease_out); + +/* Make one fair process-local/native acquisition attempt without parking. + * A free lock can succeed; existing queued/native contention returns BUSY. + * Cleanup-only lease semantics are identical to the waiting API. */ +cbm_private_file_lock_status_t cbm_lock_registry_try_acquire( + cbm_lock_registry_t *registry, const char *resource_key, + cbm_private_file_lock_mode_t mode, cbm_lock_lease_t **lease_out); + +/* Writers release .rw before .turn. Readers retain only .rw. The same release + * call disposes cleanup-only leases returned by failed acquisition rollback, + * first detaching any retained waiter and then releasing native handles. OK + * clears *lease_io. IO normally leaves a non-NULL retryable lease whose waiter, + * active, or pending-cleanup accounting keeps the registry live. A terminal + * native close error can return IO while clearing *lease_io after all ownership + * and accounting have been discharged. */ +cbm_private_file_lock_status_t cbm_lock_lease_release(cbm_lock_lease_t **lease_io); + +/* Refuses to free a registry with active leases, waiters, or pending cleanup. + * OK destroys its resources, clears *registry_io, and retires the control + * identity for the process lifetime so copied stale pointers cannot alias a + * future registry; stale operations fail with IO. */ +cbm_private_file_lock_status_t cbm_lock_registry_free(cbm_lock_registry_t **registry_io); + +#endif /* CBM_LOCK_REGISTRY_H */ diff --git a/src/foundation/lock_registry_internal.h b/src/foundation/lock_registry_internal.h new file mode 100644 index 000000000..dd69ea4ae --- /dev/null +++ b/src/foundation/lock_registry_internal.h @@ -0,0 +1,63 @@ +#ifndef CBM_LOCK_REGISTRY_INTERNAL_H +#define CBM_LOCK_REGISTRY_INTERNAL_H + +#include "foundation/lock_registry.h" +#include "foundation/private_file_lock_internal.h" + +typedef enum { + CBM_LOCK_REGISTRY_STAGE_TURN_BUSY = 1, + CBM_LOCK_REGISTRY_STAGE_TURN_HELD = 2, + CBM_LOCK_REGISTRY_STAGE_RW_BUSY = 3, + CBM_LOCK_REGISTRY_STAGE_NATIVE_READY = 4, +} cbm_lock_registry_stage_t; + +typedef void (*cbm_lock_registry_stage_hook_fn)(void *context, cbm_private_file_lock_mode_t mode, + cbm_lock_registry_stage_t stage); + +bool cbm_lock_registry_resource_names(const char *resource_key, + char turn_out[CBM_LOCK_REGISTRY_NAME_CAP], + char rw_out[CBM_LOCK_REGISTRY_NAME_CAP]); + +size_t cbm_lock_registry_waiter_count(cbm_lock_registry_t *registry); +size_t cbm_lock_registry_active_lease_count_for_test(cbm_lock_registry_t *registry); +size_t cbm_lock_registry_pending_cleanup_count_for_test(cbm_lock_registry_t *registry); +bool cbm_lock_registry_is_retired_for_test(const cbm_lock_registry_t *registry); +size_t cbm_lock_registry_attempting_waiter_count_for_test(cbm_lock_registry_t *registry); +uint64_t cbm_lock_registry_condition_wait_call_count_for_test( + const cbm_lock_registry_t *registry); +size_t cbm_lock_registry_condition_waiter_count_for_test(const cbm_lock_registry_t *registry); + +typedef enum { + CBM_LOCK_REGISTRY_RELEASE_RW = 1, + CBM_LOCK_REGISTRY_RELEASE_TURN = 2, +} cbm_lock_registry_release_handle_t; + +bool cbm_lock_lease_fail_next_release_step_for_test(cbm_lock_lease_t *lease, + cbm_lock_registry_release_handle_t handle, + cbm_private_file_lock_release_step_t step); +bool cbm_lock_lease_has_release_handle_for_test(const cbm_lock_lease_t *lease, + cbm_lock_registry_release_handle_t handle); +bool cbm_lock_lease_used_abort_lock_failure_path_for_test(const cbm_lock_lease_t *lease); +#ifndef _WIN32 +bool cbm_lock_lease_fail_close_after_consuming_for_test(cbm_lock_lease_t *lease, + cbm_lock_registry_release_handle_t handle); +#endif +bool cbm_lock_registry_fail_next_native_release_step_for_test( + cbm_lock_registry_t *registry, cbm_lock_registry_release_handle_t handle, + cbm_private_file_lock_release_step_t step); + +typedef enum { + CBM_LOCK_REGISTRY_ABORT_FAIL_LOCK = 1, + CBM_LOCK_REGISTRY_ABORT_FAIL_REMOVE = 2, +} cbm_lock_registry_abort_failure_t; + +/* One-shot abort-bookkeeping fault seam, armable only while idle. */ +bool cbm_lock_registry_fail_next_abort_bookkeeping_for_test( + cbm_lock_registry_t *registry, cbm_lock_registry_abort_failure_t failure); + +/* Deterministic test seam. May only be changed while the registry is idle; + * callbacks run without the fork guard or registry mutex held. */ +bool cbm_lock_registry_set_stage_hook_for_test(cbm_lock_registry_t *registry, + cbm_lock_registry_stage_hook_fn hook, void *context); + +#endif /* CBM_LOCK_REGISTRY_INTERNAL_H */ diff --git a/src/foundation/macos_acl.c b/src/foundation/macos_acl.c new file mode 100644 index 000000000..13dc0bcfe --- /dev/null +++ b/src/foundation/macos_acl.c @@ -0,0 +1,60 @@ +/* macos_acl.c — Darwin extended-ACL operations anchored to an open fd. */ +#include "foundation/macos_acl.h" + +#include + +#ifdef __APPLE__ + +#include +#include +#include + +bool cbm_macos_extended_acl_fd_is_empty(int fd) { + if (fd < 0) { + return false; + } + + errno = 0; + acl_t acl = acl_get_fd_np(fd, ACL_TYPE_EXTENDED); + if (!acl) { + /* Darwin reports an object with no extended ACL as ENOENT on some + * filesystems. All other retrieval failures are fail-closed. */ + return errno == ENOENT; + } + + acl_entry_t entry = NULL; + errno = 0; + int entry_result = acl_get_entry(acl, ACL_FIRST_ENTRY, &entry); + int entry_error = errno; + int free_result = acl_free(acl); + + /* Darwin returns 0 for an entry and -1/EINVAL when an empty ACL (or the + * end of an ACL) is reached. Looking only at the first entry makes every + * nonempty ACL unsafe, independent of its allow/deny permissions. */ + return entry_result == -1 && entry_error == EINVAL && free_result == 0; +} + +bool cbm_macos_extended_acl_fd_clear(int fd) { + if (fd < 0) { + return false; + } + acl_t empty = acl_init(0); + if (!empty) { + return false; + } + bool cleared = acl_set_fd_np(fd, empty, ACL_TYPE_EXTENDED) == 0; + bool freed = acl_free(empty) == 0; + return cleared && freed && cbm_macos_extended_acl_fd_is_empty(fd); +} + +#else + +bool cbm_macos_extended_acl_fd_is_empty(int fd) { + return fd >= 0; +} + +bool cbm_macos_extended_acl_fd_clear(int fd) { + return fd >= 0; +} + +#endif diff --git a/src/foundation/macos_acl.h b/src/foundation/macos_acl.h new file mode 100644 index 000000000..94fac0b4e --- /dev/null +++ b/src/foundation/macos_acl.h @@ -0,0 +1,15 @@ +/* macos_acl.h — Anchored extended-ACL checks for private filesystem objects. */ +#ifndef CBM_FOUNDATION_MACOS_ACL_H +#define CBM_FOUNDATION_MACOS_ACL_H + +#include + +/* On macOS, succeeds only when fd has no extended ACL entries. Other + * platforms have no Darwin extended ACL surface, so a valid fd is sufficient. */ +bool cbm_macos_extended_acl_fd_is_empty(int fd); + +/* On macOS, replaces fd's extended ACL with an empty ACL and verifies the + * result through the same anchored descriptor. Other platforms are a no-op. */ +bool cbm_macos_extended_acl_fd_clear(int fd); + +#endif diff --git a/src/foundation/mem.c b/src/foundation/mem.c index 858382ce1..1169d1734 100644 --- a/src/foundation/mem.c +++ b/src/foundation/mem.c @@ -134,6 +134,7 @@ cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction, .source = "ram_fraction", .clamped = false, .invalid = false, + .hard_capped = false, }; if (budget_mb == NULL || budget_mb[0] == '\0') { @@ -173,7 +174,19 @@ cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction, return result; } -void cbm_mem_init(double ram_fraction) { +cbm_mem_budget_t cbm_mem_resolve_budget_capped(size_t total_ram, double ram_fraction, + const char *budget_mb, + size_t hard_cap_bytes) { + cbm_mem_budget_t result = cbm_mem_resolve_budget(total_ram, ram_fraction, budget_mb); + if (hard_cap_bytes > 0 && (result.budget == 0 || result.budget > hard_cap_bytes)) { + result.budget = hard_cap_bytes; + result.source = "daemon_worker_cap"; + result.hard_capped = true; + } + return result; +} + +void cbm_mem_init_with_cap(double ram_fraction, size_t hard_cap_bytes) { int expected = 0; if (!atomic_compare_exchange_strong(&g_initialized, &expected, 1)) { return; @@ -210,7 +223,8 @@ void cbm_mem_init(double ram_fraction) { char env_buf[CBM_SZ_32]; const char *env = cbm_safe_getenv("CBM_MEM_BUDGET_MB", env_buf, sizeof(env_buf), NULL); - cbm_mem_budget_t resolved = cbm_mem_resolve_budget(info.total_ram, ram_fraction, env); + cbm_mem_budget_t resolved = + cbm_mem_resolve_budget_capped(info.total_ram, ram_fraction, env, hard_cap_bytes); g_budget = resolved.budget; /* The resolver is the single source of truth for the parse + clamp; this @@ -222,6 +236,11 @@ void cbm_mem_init(double ram_fraction) { snprintf(cap_mb, sizeof(cap_mb), "%zu", info.total_ram / MB_DIVISOR); cbm_log_warn("mem.budget.clamped", "requested_mb", env, "cap_mb", cap_mb); } + if (resolved.hard_capped) { + char cap_bytes[CBM_SZ_32]; + snprintf(cap_bytes, sizeof(cap_bytes), "%zu", hard_cap_bytes); + cbm_log_info("mem.budget.worker_cap", "cap_bytes", cap_bytes); + } char budget_mb[CBM_SZ_32]; char ram_mb[CBM_SZ_32]; @@ -231,6 +250,10 @@ void cbm_mem_init(double ram_fraction) { resolved.source); } +void cbm_mem_init(double ram_fraction) { + cbm_mem_init_with_cap(ram_fraction, 0); +} + size_t cbm_mem_rss(void) { #if defined(__linux__) /* Linux: mimalloc's _mi_prim_process_info() (vendored/mimalloc/src/prim/ diff --git a/src/foundation/mem.h b/src/foundation/mem.h index a239112c7..58362acd9 100644 --- a/src/foundation/mem.h +++ b/src/foundation/mem.h @@ -21,6 +21,11 @@ double cbm_mem_ram_fraction_for_total(size_t total_ram_bytes); * Configures mimalloc options for reduced upfront memory. */ void cbm_mem_init(double ram_fraction); +/* Worker-only initialization cap carried on the build-bound internal argv. + * The existing user override is still resolved first; a lower explicit + * CBM_MEM_BUDGET_MB wins, while a larger/default budget is capped. */ +void cbm_mem_init_with_cap(double ram_fraction, size_t hard_cap_bytes); + /* Result of cbm_mem_resolve_budget: the resolved budget plus the metadata * cbm_mem_init logs — so the parse/clamp logic lives in exactly ONE place and * the caller never re-parses the env string. */ @@ -29,6 +34,7 @@ typedef struct { const char *source; /* log token: "ram_fraction" | "CBM_MEM_BUDGET_MB" */ bool clamped; /* override was valid but exceeded total_ram → clamped down */ bool invalid; /* override was present but unparseable / out-of-range / ≤0 */ + bool hard_capped; /* internal worker hard cap reduced the resolved budget */ } cbm_mem_budget_t; /* Pure budget resolver shared by cbm_mem_init (exposed for testing). @@ -40,6 +46,11 @@ typedef struct { cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction, const char *budget_mb); +/* Pure capped variant used by supervised workers and deterministic tests. */ +cbm_mem_budget_t cbm_mem_resolve_budget_capped(size_t total_ram, double ram_fraction, + const char *budget_mb, + size_t hard_cap_bytes); + /* Current RSS in bytes via mi_process_info(). * Falls back to OS-specific queries when MI_OVERRIDE=0 (ASan builds). */ size_t cbm_mem_rss(void); diff --git a/src/foundation/platform.c b/src/foundation/platform.c index 263f89c6e..a1e4b10c6 100644 --- a/src/foundation/platform.c +++ b/src/foundation/platform.c @@ -5,12 +5,58 @@ */ #include "platform.h" +#include "foundation/compat.h" #include "foundation/constants.h" +#include "foundation/platform_internal.h" #include #include #include #include +#define CBM_NSEC_PER_SEC 1000000000ULL + +static uint64_t cbm_platform_scale_fraction(uint64_t remainder, uint64_t multiplier, + uint64_t divisor) { + uint64_t quotient = 0; + uint64_t reduced = 0; + uint64_t mask = 1; + while (mask <= multiplier / 2) { + mask <<= 1U; + } + for (; mask != 0; mask >>= 1U) { + quotient *= 2; + if (reduced >= divisor - reduced) { + reduced -= divisor - reduced; + quotient++; + } else { + reduced += reduced; + } + if ((multiplier & mask) != 0) { + if (reduced >= divisor - remainder) { + reduced -= divisor - remainder; + quotient++; + } else { + reduced += remainder; + } + } + } + return quotient; +} + +uint64_t cbm_platform_scale_counter_ns(uint64_t counter, uint64_t frequency) { + if (frequency == 0) { + return UINT64_MAX; + } + uint64_t whole_seconds = counter / frequency; + uint64_t remainder = counter % frequency; + if (whole_seconds > UINT64_MAX / CBM_NSEC_PER_SEC) { + return UINT64_MAX; + } + uint64_t whole_ns = whole_seconds * CBM_NSEC_PER_SEC; + uint64_t fraction_ns = cbm_platform_scale_fraction(remainder, CBM_NSEC_PER_SEC, frequency); + return fraction_ns > UINT64_MAX - whole_ns ? UINT64_MAX : whole_ns + fraction_ns; +} + /* Canonicalize a Windows drive letter to upper-case in place: "c:/x" -> "C:/x". * Windows drive letters are case-insensitive, but a lowercase one (as agent * CWDs often report, e.g. Claude Code's "c:\...") otherwise produces a distinct @@ -90,7 +136,7 @@ uint64_t cbm_now_ns(void) { LARGE_INTEGER freq, count; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&count); - return (uint64_t)count.QuadPart * 1000000000ULL / (uint64_t)freq.QuadPart; + return cbm_platform_scale_counter_ns((uint64_t)count.QuadPart, (uint64_t)freq.QuadPart); } #define CBM_USEC_PER_SEC 1000000ULL @@ -165,6 +211,7 @@ char *cbm_normalize_path_sep(char *path) { #ifdef __APPLE__ #include +#include #include #else #include @@ -208,14 +255,19 @@ void cbm_munmap(void *addr, size_t size) { /* ── Timing ───────────────────────────── */ #ifdef __APPLE__ -static mach_timebase_info_data_t timebase_info; -static int timebase_init = 0; +static mach_timebase_info_data_t timebase_info = {1, 1}; +static pthread_once_t timebase_once = PTHREAD_ONCE_INIT; -uint64_t cbm_now_ns(void) { - if (!timebase_init) { - mach_timebase_info(&timebase_info); - timebase_init = SKIP_ONE; +static void cbm_timebase_initialize(void) { + (void)mach_timebase_info(&timebase_info); + if (timebase_info.numer == 0 || timebase_info.denom == 0) { + timebase_info.numer = 1; + timebase_info.denom = 1; } +} + +uint64_t cbm_now_ns(void) { + (void)pthread_once(&timebase_once, cbm_timebase_initialize); uint64_t ticks = mach_absolute_time(); return ticks * timebase_info.numer / timebase_info.denom; } @@ -361,7 +413,7 @@ const char *cbm_safe_getenv(const char *name, char *buf, size_t buf_sz, const ch /* ── Home directory (cross-platform) ───────────────────── */ const char *cbm_get_home_dir(void) { - static char buf[CBM_SZ_1K]; + static CBM_TLS char buf[CBM_SZ_1K]; char tmp[CBM_SZ_256] = ""; cbm_safe_getenv("HOME", tmp, sizeof(tmp), NULL); @@ -383,7 +435,7 @@ const char *cbm_get_home_dir(void) { /* ── App config directories (cross-platform) ────────── */ const char *cbm_app_config_dir(void) { - static char buf[CBM_SZ_1K]; + static CBM_TLS char buf[CBM_SZ_1K]; char tmp[CBM_SZ_256] = ""; #ifdef _WIN32 cbm_safe_getenv("APPDATA", tmp, sizeof(tmp), NULL); @@ -416,7 +468,7 @@ const char *cbm_app_config_dir(void) { const char *cbm_app_local_dir(void) { #ifdef _WIN32 - static char buf[CBM_SZ_1K]; + static CBM_TLS char buf[CBM_SZ_1K]; char tmp[CBM_SZ_256] = ""; cbm_safe_getenv("LOCALAPPDATA", tmp, sizeof(tmp), NULL); if (tmp[0]) { @@ -438,7 +490,7 @@ const char *cbm_app_local_dir(void) { /* ── Cache directory ────────────────────────── */ const char *cbm_resolve_cache_dir(void) { - static char buf[CBM_SZ_1K]; + static CBM_TLS char buf[CBM_SZ_1K]; char tmp[CBM_SZ_256] = ""; cbm_safe_getenv("CBM_CACHE_DIR", tmp, sizeof(tmp), NULL); if (tmp[0]) { diff --git a/src/foundation/platform_internal.h b/src/foundation/platform_internal.h new file mode 100644 index 000000000..55c910812 --- /dev/null +++ b/src/foundation/platform_internal.h @@ -0,0 +1,12 @@ +/* Internal seams shared by platform implementations and focused tests. */ +#ifndef CBM_PLATFORM_INTERNAL_H +#define CBM_PLATFORM_INTERNAL_H + +#include + +/* Convert a monotonic counter to nanoseconds using its ticks-per-second + * frequency. Kept outside the Windows guard so arithmetic edge cases can be + * verified on every supported build host. */ +uint64_t cbm_platform_scale_counter_ns(uint64_t counter, uint64_t frequency); + +#endif /* CBM_PLATFORM_INTERNAL_H */ diff --git a/src/foundation/private_file_lock.c b/src/foundation/private_file_lock.c new file mode 100644 index 000000000..ce8c40ac0 --- /dev/null +++ b/src/foundation/private_file_lock.c @@ -0,0 +1,1624 @@ +/* private_file_lock.c — Handle-anchored private-file locking. */ +#include "foundation/private_file_lock.h" + +#include "foundation/macos_acl.h" +#include "foundation/private_file_lock_internal.h" +#include "foundation/platform.h" + +#include +#include +#include + +enum { PRIVATE_FILE_LOCK_PAYLOAD_CAP = 4096 }; + +#ifndef _WIN32 + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct cbm_private_lock_directory { + int fd; + char *path; + dev_t device; + ino_t inode; + pid_t owner_pid; + bool test_fail_post_acquire_once; + bool test_fail_post_acquire_unlock; + bool test_fail_post_acquire_close; +}; + +struct cbm_private_file_lock { + int fd; + pid_t owner_pid; + cbm_private_file_lock_mode_t mode; + struct cbm_private_file_lock *next_tracked; + bool unlocked; + bool test_fail_unlock_once; + bool test_fail_close_once; + bool test_fail_close_after_consuming_once; + unsigned int test_unlock_attempts; + unsigned int test_close_attempts; +}; + +struct cbm_private_fork_condition { + pthread_cond_t value; +}; + +static pthread_once_t private_atfork_once = PTHREAD_ONCE_INIT; +static pthread_mutex_t private_fork_mutex = PTHREAD_MUTEX_INITIALIZER; +static cbm_private_file_lock_t *private_tracked_locks; +static int private_atfork_status = -1; + +static void private_atfork_prepare(void) { + (void)pthread_mutex_lock(&private_fork_mutex); +} + +static void private_atfork_parent(void) { + (void)pthread_mutex_unlock(&private_fork_mutex); +} + +static void private_atfork_child(void) { + cbm_private_file_lock_t *lock = private_tracked_locks; + while (lock) { + if (lock->fd >= 0) { + /* Never LOCK_UN in the child: its descriptor references the same + * flock description as the parent's still-live descriptor. */ + (void)close(lock->fd); + lock->fd = -1; + } + lock = lock->next_tracked; + } + private_tracked_locks = NULL; + (void)pthread_mutex_unlock(&private_fork_mutex); +} + +static void private_atfork_install(void) { + private_atfork_status = + pthread_atfork(private_atfork_prepare, private_atfork_parent, private_atfork_child); +} + +static bool private_atfork_ensure(void) { + return pthread_once(&private_atfork_once, private_atfork_install) == 0 && + private_atfork_status == 0; +} + +bool cbm_private_file_lock_fork_guard_enter(void) { + return private_atfork_ensure() && pthread_mutex_lock(&private_fork_mutex) == 0; +} + +void cbm_private_file_lock_fork_guard_leave(void) { + (void)pthread_mutex_unlock(&private_fork_mutex); +} + +cbm_private_fork_condition_t *cbm_private_fork_condition_new(void) { + cbm_private_fork_condition_t *condition = calloc(1, sizeof(*condition)); + pthread_condattr_t attributes; + if (!condition || pthread_condattr_init(&attributes) != 0) { + free(condition); + return NULL; + } +#ifndef __APPLE__ + if (pthread_condattr_setclock(&attributes, CLOCK_MONOTONIC) != 0) { + (void)pthread_condattr_destroy(&attributes); + free(condition); + return NULL; + } +#endif + int status = pthread_cond_init(&condition->value, &attributes); + (void)pthread_condattr_destroy(&attributes); + if (status != 0) { + free(condition); + return NULL; + } + return condition; +} + +void cbm_private_fork_condition_free(cbm_private_fork_condition_t *condition) { + if (!condition) { + return; + } + (void)pthread_cond_destroy(&condition->value); + free(condition); +} + +void cbm_private_fork_condition_broadcast_while_guarded( + cbm_private_fork_condition_t *condition) { + if (condition) { + (void)pthread_cond_broadcast(&condition->value); + } +} + +cbm_private_fork_wait_status_t cbm_private_fork_condition_wait_until_while_guarded( + cbm_private_fork_condition_t *condition, uint64_t deadline_ms) { + if (!condition) { + return CBM_PRIVATE_FORK_WAIT_ERROR; + } + int status; + if (deadline_ms == UINT64_MAX) { + status = pthread_cond_wait(&condition->value, &private_fork_mutex); + } else { + struct timespec timeout; +#ifdef __APPLE__ + uint64_t now_ms = cbm_now_ms(); + uint64_t remaining_ms = deadline_ms > now_ms ? deadline_ms - now_ms : 0; + timeout.tv_sec = (time_t)(remaining_ms / 1000U); + timeout.tv_nsec = (long)((remaining_ms % 1000U) * 1000000U); + status = pthread_cond_timedwait_relative_np(&condition->value, &private_fork_mutex, + &timeout); +#else + timeout.tv_sec = (time_t)(deadline_ms / 1000U); + timeout.tv_nsec = (long)((deadline_ms % 1000U) * 1000000U); + status = pthread_cond_timedwait(&condition->value, &private_fork_mutex, &timeout); +#endif + } + if (status == 0) { + return CBM_PRIVATE_FORK_WAIT_SIGNALED; + } + return status == ETIMEDOUT ? CBM_PRIVATE_FORK_WAIT_TIMEOUT : CBM_PRIVATE_FORK_WAIT_ERROR; +} + +static bool private_fd_set_cloexec(int fd) { + int flags = fcntl(fd, F_GETFD); + return flags >= 0 && fcntl(fd, F_SETFD, flags | FD_CLOEXEC) == 0; +} + +static bool private_directory_revalidate(const cbm_private_lock_directory_t *directory) { + struct stat by_handle; + struct stat by_path; + return directory && directory->owner_pid == getpid() && directory->fd >= 0 && + fstat(directory->fd, &by_handle) == 0 && lstat(directory->path, &by_path) == 0 && + S_ISDIR(by_handle.st_mode) && S_ISDIR(by_path.st_mode) && + by_handle.st_dev == directory->device && by_handle.st_ino == directory->inode && + by_path.st_dev == directory->device && by_path.st_ino == directory->inode && + by_handle.st_uid == geteuid() && by_path.st_uid == geteuid() && + (by_handle.st_mode & 07777) == 0700 && (by_path.st_mode & 07777) == 0700 && + cbm_macos_extended_acl_fd_is_empty(directory->fd); +} + +static bool private_base_name_valid(const char *base_name) { + if (!base_name || !base_name[0] || strcmp(base_name, ".") == 0 || + strcmp(base_name, "..") == 0) { + return false; + } + size_t length = strlen(base_name); + if (length > NAME_MAX) { + return false; + } + for (size_t index = 0; index < length; index++) { + char ch = base_name[index]; + if (!((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || + ch == '.')) { + return false; + } + } + return true; +} + +static bool private_file_revalidate(const cbm_private_lock_directory_t *directory, + const char *base_name, int fd, const struct stat *expected) { + struct stat by_handle; + struct stat by_path; + return private_directory_revalidate(directory) && fstat(fd, &by_handle) == 0 && + S_ISREG(by_handle.st_mode) && by_handle.st_uid == geteuid() && by_handle.st_nlink == 1 && + (by_handle.st_mode & 07777) == 0600 && + fstatat(directory->fd, base_name, &by_path, AT_SYMLINK_NOFOLLOW) == 0 && + S_ISREG(by_path.st_mode) && by_path.st_uid == geteuid() && by_path.st_nlink == 1 && + (by_path.st_mode & 07777) == 0600 && by_path.st_dev == by_handle.st_dev && + by_path.st_ino == by_handle.st_ino && cbm_macos_extended_acl_fd_is_empty(fd) && + (!expected || + (expected->st_dev == by_handle.st_dev && expected->st_ino == by_handle.st_ino)); +} + +static cbm_private_file_lock_status_t private_open_failure_status( + const cbm_private_lock_directory_t *directory, const char *base_name) { + struct stat status; + if (fstatat(directory->fd, base_name, &status, AT_SYMLINK_NOFOLLOW) == 0) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + return errno == ENOENT ? CBM_PRIVATE_FILE_LOCK_IO : CBM_PRIVATE_FILE_LOCK_UNSAFE; +} + +static int private_flock_set(int fd, int operation) { + int result; + do { + result = flock(fd, operation); + } while (result != 0 && errno == EINTR); + return result; +} + +static int private_release_unlock(cbm_private_file_lock_t *lock) { + lock->test_unlock_attempts++; + if (lock->test_fail_unlock_once) { + lock->test_fail_unlock_once = false; + errno = EIO; + return -1; + } + return private_flock_set(lock->fd, LOCK_UN); +} + +static int private_release_close(cbm_private_file_lock_t *lock, bool *attempted_out) { + *attempted_out = false; + lock->test_close_attempts++; + if (lock->test_fail_close_once) { + lock->test_fail_close_once = false; + errno = EIO; + return -1; + } + *attempted_out = true; + if (lock->test_fail_close_after_consuming_once) { + lock->test_fail_close_after_consuming_once = false; + (void)close(lock->fd); + errno = EIO; + return -1; + } + return close(lock->fd); +} + +cbm_private_file_lock_status_t cbm_private_lock_directory_adopt_posix( + int directory_fd, const char *stable_path, cbm_private_lock_directory_t **directory_out) { + if (directory_out) { + *directory_out = NULL; + } + if (directory_fd < 0 || !stable_path || !stable_path[0] || !directory_out || + !private_fd_set_cloexec(directory_fd)) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + struct stat status; + struct stat by_path; + if (fstat(directory_fd, &status) != 0 || lstat(stable_path, &by_path) != 0 || + !S_ISDIR(status.st_mode) || !S_ISDIR(by_path.st_mode) || status.st_uid != geteuid() || + by_path.st_uid != geteuid() || (status.st_mode & 07777) != 0700 || + (by_path.st_mode & 07777) != 0700 || status.st_dev != by_path.st_dev || + status.st_ino != by_path.st_ino || + !cbm_macos_extended_acl_fd_is_empty(directory_fd)) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + cbm_private_lock_directory_t *directory = calloc(1, sizeof(*directory)); + if (directory) { + directory->path = strdup(stable_path); + } + if (!directory || !directory->path) { + free(directory); + return CBM_PRIVATE_FILE_LOCK_IO; + } + directory->fd = directory_fd; + directory->device = status.st_dev; + directory->inode = status.st_ino; + directory->owner_pid = getpid(); + *directory_out = directory; + return CBM_PRIVATE_FILE_LOCK_OK; +} + +cbm_private_file_lock_status_t cbm_private_file_lock_try_acquire( + cbm_private_lock_directory_t *directory, const char *base_name, + cbm_private_file_lock_mode_t mode, cbm_private_file_lock_t **lock_out) { + if (lock_out) { + *lock_out = NULL; + } + if (!directory || !lock_out || !private_base_name_valid(base_name) || + (mode != CBM_PRIVATE_FILE_LOCK_SH && mode != CBM_PRIVATE_FILE_LOCK_EX)) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!private_directory_revalidate(directory)) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!cbm_private_file_lock_fork_guard_enter()) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + + int flags = O_RDWR | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK; + bool created = false; + int fd = openat(directory->fd, base_name, flags | O_CREAT | O_EXCL, 0600); + if (fd >= 0) { + created = true; + } else if (errno == EEXIST) { + fd = openat(directory->fd, base_name, flags); + } + if (fd < 0) { + cbm_private_file_lock_status_t status = private_open_failure_status(directory, base_name); + cbm_private_file_lock_fork_guard_leave(); + return status; + } + + struct stat initial; + bool initial_ok = private_fd_set_cloexec(fd) && fstat(fd, &initial) == 0; + if (initial_ok && created) { + initial_ok = fchmod(fd, 0600) == 0 && fstat(fd, &initial) == 0; + } + if (!initial_ok || !private_file_revalidate(directory, base_name, fd, &initial)) { + (void)close(fd); + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + + cbm_private_file_lock_t *lock = calloc(1, sizeof(*lock)); + if (!lock) { + (void)close(fd); + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_IO; + } + lock->fd = fd; + lock->owner_pid = getpid(); + lock->mode = mode; + + int operation = mode == CBM_PRIVATE_FILE_LOCK_SH ? LOCK_SH : LOCK_EX; + if (private_flock_set(fd, operation | LOCK_NB) != 0) { + int lock_error = errno; + (void)close(fd); + free(lock); + cbm_private_file_lock_fork_guard_leave(); + return lock_error == EWOULDBLOCK || lock_error == EAGAIN ? CBM_PRIVATE_FILE_LOCK_BUSY + : CBM_PRIVATE_FILE_LOCK_IO; + } + lock->next_tracked = private_tracked_locks; + private_tracked_locks = lock; + + bool forced_cleanup = directory->test_fail_post_acquire_once; + bool fail_cleanup_unlock = directory->test_fail_post_acquire_unlock; + bool fail_cleanup_close = directory->test_fail_post_acquire_close; + directory->test_fail_post_acquire_once = false; + directory->test_fail_post_acquire_unlock = false; + directory->test_fail_post_acquire_close = false; + if (forced_cleanup || !private_file_revalidate(directory, base_name, fd, &initial)) { + cbm_private_file_lock_status_t failure_status = + forced_cleanup ? CBM_PRIVATE_FILE_LOCK_IO : CBM_PRIVATE_FILE_LOCK_UNSAFE; + lock->test_fail_unlock_once = fail_cleanup_unlock; + lock->test_fail_close_once = fail_cleanup_close; + *lock_out = lock; + cbm_private_file_lock_fork_guard_leave(); + cbm_private_file_lock_status_t cleanup_status = cbm_private_file_lock_release(lock_out); + return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK ? failure_status + : CBM_PRIVATE_FILE_LOCK_IO; + } + *lock_out = lock; + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_OK; +} + +static bool private_lock_is_tracked(const cbm_private_file_lock_t *lock) { + for (const cbm_private_file_lock_t *cursor = private_tracked_locks; cursor; + cursor = cursor->next_tracked) { + if (cursor == lock) { + return true; + } + } + return false; +} + +static bool private_payload_fd_valid(const cbm_private_file_lock_t *lock, + struct stat *status_out) { + struct stat status; + bool valid = lock && lock->fd >= 0 && !lock->unlocked && + lock->owner_pid == getpid() && private_lock_is_tracked(lock) && + fstat(lock->fd, &status) == 0 && S_ISREG(status.st_mode) && + status.st_uid == geteuid() && status.st_nlink == 1 && + (status.st_mode & 07777) == 0600 && status.st_size >= 0 && + cbm_macos_extended_acl_fd_is_empty(lock->fd); + if (valid && status_out) { + *status_out = status; + } + return valid; +} + +cbm_private_file_lock_status_t cbm_private_file_lock_payload_read( + cbm_private_file_lock_t *lock, void *buffer, size_t capacity, + size_t *length_out) { + if (length_out) { + *length_out = 0; + } + if (!lock || !buffer || capacity == 0 || !length_out) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!cbm_private_file_lock_fork_guard_enter()) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + struct stat status; + bool metadata_safe = private_payload_fd_valid(lock, &status); + bool valid = metadata_safe && + (uintmax_t)status.st_size <= PRIVATE_FILE_LOCK_PAYLOAD_CAP && + (uintmax_t)status.st_size <= capacity; + size_t length = valid ? (size_t)status.st_size : 0; + size_t offset = 0; + while (valid && offset < length) { + ssize_t count = pread(lock->fd, (unsigned char *)buffer + offset, + length - offset, (off_t)offset); + if (count > 0) { + offset += (size_t)count; + } else if (count < 0 && errno == EINTR) { + continue; + } else { + valid = false; + } + } + cbm_private_file_lock_fork_guard_leave(); + if (!metadata_safe) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!valid) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + *length_out = length; + return CBM_PRIVATE_FILE_LOCK_OK; +} + +cbm_private_file_lock_status_t cbm_private_file_lock_payload_write( + cbm_private_file_lock_t *lock, const void *buffer, size_t length) { + if (!lock || !buffer || length == 0 || + length > PRIVATE_FILE_LOCK_PAYLOAD_CAP || + lock->mode != CBM_PRIVATE_FILE_LOCK_EX) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!cbm_private_file_lock_fork_guard_enter()) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + bool metadata_safe = private_payload_fd_valid(lock, NULL); + bool valid = metadata_safe && ftruncate(lock->fd, 0) == 0; + size_t offset = 0; + while (valid && offset < length) { + ssize_t count = pwrite(lock->fd, + (const unsigned char *)buffer + offset, + length - offset, (off_t)offset); + if (count > 0) { + offset += (size_t)count; + } else if (count < 0 && errno == EINTR) { + continue; + } else { + valid = false; + } + } + valid = valid && ftruncate(lock->fd, (off_t)length) == 0; + if (valid) { + int sync_status; + do { + sync_status = fsync(lock->fd); + } while (sync_status != 0 && errno == EINTR); + valid = sync_status == 0; + } + cbm_private_file_lock_fork_guard_leave(); + if (!metadata_safe) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + return valid ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO; +} + +cbm_private_file_lock_status_t cbm_private_file_lock_release(cbm_private_file_lock_t **lock_io) { + if (!lock_io || !*lock_io) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_private_file_lock_t *lock = *lock_io; + if (lock->owner_pid != getpid() || lock->fd < 0) { + *lock_io = NULL; + free(lock); + return CBM_PRIVATE_FILE_LOCK_OK; + } + if (!cbm_private_file_lock_fork_guard_enter()) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_private_file_lock_t **cursor = &private_tracked_locks; + while (*cursor && *cursor != lock) { + cursor = &(*cursor)->next_tracked; + } + if (*cursor != lock) { + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (!lock->unlocked) { + if (private_release_unlock(lock) != 0) { + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_IO; + } + lock->unlocked = true; + } + bool close_attempted = false; + int close_status = private_release_close(lock, &close_attempted); + if (close_status != 0 && !close_attempted) { + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_IO; + } + lock->fd = -1; + *cursor = lock->next_tracked; + cbm_private_file_lock_fork_guard_leave(); + *lock_io = NULL; + free(lock); + return close_status == 0 ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO; +} + +void cbm_private_lock_directory_close(cbm_private_lock_directory_t *directory) { + if (!directory) { + return; + } + if (directory->fd >= 0) { + (void)close(directory->fd); + } + free(directory->path); + free(directory); +} + +const char *cbm_private_lock_directory_path(const cbm_private_lock_directory_t *directory) { + return directory ? directory->path : NULL; +} + +bool cbm_private_file_lock_is_cloexec_for_test(const cbm_private_file_lock_t *lock) { + if (!lock || lock->fd < 0) { + return false; + } + int flags = fcntl(lock->fd, F_GETFD); + return flags >= 0 && (flags & FD_CLOEXEC) != 0; +} + +bool cbm_private_file_lock_unlock_complete(const cbm_private_file_lock_t *lock) { + return lock && lock->unlocked; +} + +bool cbm_private_lock_directory_fail_post_acquire_cleanup_for_test( + cbm_private_lock_directory_t *directory, bool fail_unlock, bool fail_close) { + if (!directory || (!fail_unlock && !fail_close)) { + return false; + } + directory->test_fail_post_acquire_once = true; + directory->test_fail_post_acquire_unlock = fail_unlock; + directory->test_fail_post_acquire_close = fail_close; + return true; +} + +bool cbm_private_file_lock_fail_next_release_step_for_test( + cbm_private_file_lock_t *lock, cbm_private_file_lock_release_step_t step) { + if (!lock) { + return false; + } + if (step == CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK) { + lock->test_fail_unlock_once = true; + return true; + } + if (step == CBM_PRIVATE_FILE_LOCK_RELEASE_CLOSE) { + lock->test_fail_close_once = true; + return true; + } + return false; +} + +unsigned int cbm_private_file_lock_release_step_attempts_for_test( + const cbm_private_file_lock_t *lock, cbm_private_file_lock_release_step_t step) { + if (!lock) { + return 0; + } + if (step == CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK) { + return lock->test_unlock_attempts; + } + if (step == CBM_PRIVATE_FILE_LOCK_RELEASE_CLOSE) { + return lock->test_close_attempts; + } + return 0; +} + +bool cbm_private_file_lock_fail_close_after_consuming_for_test(cbm_private_file_lock_t *lock) { + if (!lock) { + return false; + } + lock->test_fail_close_after_consuming_once = true; + return true; +} + +int cbm_private_file_lock_native_fd_for_test(const cbm_private_file_lock_t *lock) { + return lock ? lock->fd : -1; +} + +#else + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#include + +typedef BOOL(WINAPI *private_open_process_token_fn)(HANDLE, DWORD, PHANDLE); +typedef BOOL(WINAPI *private_get_token_information_fn)(HANDLE, TOKEN_INFORMATION_CLASS, LPVOID, + DWORD, PDWORD); +typedef DWORD(WINAPI *private_get_length_sid_fn)(PSID); +typedef BOOL(WINAPI *private_copy_sid_fn)(DWORD, PSID, PSID); +typedef BOOL(WINAPI *private_equal_sid_fn)(PSID, PSID); +typedef BOOL(WINAPI *private_is_valid_sid_fn)(PSID); +typedef BOOL(WINAPI *private_initialize_acl_fn)(PACL, DWORD, DWORD); +typedef BOOL(WINAPI *private_add_access_allowed_ace_fn)(PACL, DWORD, DWORD, PSID); +typedef BOOL(WINAPI *private_initialize_security_descriptor_fn)(PSECURITY_DESCRIPTOR, DWORD); +typedef BOOL(WINAPI *private_set_security_descriptor_dacl_fn)(PSECURITY_DESCRIPTOR, BOOL, PACL, + BOOL); +typedef BOOL(WINAPI *private_set_security_descriptor_control_fn)(PSECURITY_DESCRIPTOR, + SECURITY_DESCRIPTOR_CONTROL, + SECURITY_DESCRIPTOR_CONTROL); +typedef BOOL(WINAPI *private_get_security_descriptor_control_fn)(PSECURITY_DESCRIPTOR, + PSECURITY_DESCRIPTOR_CONTROL, + LPDWORD); +typedef BOOL(WINAPI *private_get_acl_information_fn)(PACL, LPVOID, DWORD, ACL_INFORMATION_CLASS); +typedef BOOL(WINAPI *private_get_ace_fn)(PACL, DWORD, LPVOID *); +typedef DWORD(WINAPI *private_get_security_info_fn)(HANDLE, SE_OBJECT_TYPE, SECURITY_INFORMATION, + PSID *, PSID *, PACL *, PACL *, + PSECURITY_DESCRIPTOR *); + +typedef struct { + HMODULE advapi; + private_open_process_token_fn open_process_token; + private_get_token_information_fn get_token_information; + private_get_length_sid_fn get_length_sid; + private_copy_sid_fn copy_sid; + private_equal_sid_fn equal_sid; + private_is_valid_sid_fn is_valid_sid; + private_initialize_acl_fn initialize_acl; + private_add_access_allowed_ace_fn add_access_allowed_ace; + private_initialize_security_descriptor_fn initialize_security_descriptor; + private_set_security_descriptor_dacl_fn set_security_descriptor_dacl; + private_set_security_descriptor_control_fn set_security_descriptor_control; + private_get_security_descriptor_control_fn get_security_descriptor_control; + private_get_acl_information_fn get_acl_information; + private_get_ace_fn get_ace; + private_get_security_info_fn get_security_info; + PSID user_sid; + PACL acl; + PSECURITY_DESCRIPTOR descriptor; + SECURITY_ATTRIBUTES attributes; +} private_win_security_t; + +typedef struct { + DWORD volume_serial; + DWORD index_high; + DWORD index_low; +} private_win_identity_t; + +struct cbm_private_lock_directory { + HANDLE handle; + char *path; + wchar_t *wide_path; + private_win_identity_t identity; + private_win_security_t security; + bool test_fail_post_acquire_once; + bool test_fail_post_acquire_unlock; + bool test_fail_post_acquire_close; + bool test_fail_lock_attempt_once; + bool test_fail_lock_attempt_close; +}; + +struct cbm_private_file_lock { + HANDLE handle; + OVERLAPPED range; + cbm_private_file_lock_mode_t mode; + bool unlocked; + bool test_fail_unlock_once; + bool test_fail_close_once; + unsigned int test_unlock_attempts; + unsigned int test_close_attempts; +}; + +struct cbm_private_fork_condition { + CONDITION_VARIABLE value; +}; + +static INIT_ONCE private_win_gate_once = INIT_ONCE_STATIC_INIT; +static CRITICAL_SECTION private_win_gate; + +static BOOL CALLBACK private_win_gate_initialize(PINIT_ONCE once, PVOID parameter, PVOID *context) { + (void)once; + (void)parameter; + (void)context; + return InitializeCriticalSectionAndSpinCount(&private_win_gate, 4000); +} + +static void private_win_security_destroy(private_win_security_t *security) { + if (!security) { + return; + } + free(security->descriptor); + free(security->acl); + free(security->user_sid); + if (security->advapi) { + (void)FreeLibrary(security->advapi); + } + memset(security, 0, sizeof(*security)); +} + +static void *private_win_token_user_query(private_win_security_t *security, HANDLE token, + PSID *sid_out) { + DWORD needed = 0; + (void)security->get_token_information(token, TokenUser, NULL, 0, &needed); + if (needed == 0 || GetLastError() != ERROR_INSUFFICIENT_BUFFER) { + return NULL; + } + void *buffer = calloc(1, needed); + if (!buffer || !security->get_token_information(token, TokenUser, buffer, needed, &needed)) { + free(buffer); + return NULL; + } + *sid_out = ((TOKEN_USER *)buffer)->User.Sid; + return buffer; +} + +#define PRIVATE_RESOLVE_ADVAPI(context, member, type, symbol) \ + do { \ + (context)->member = (type)GetProcAddress((context)->advapi, (symbol)); \ + if (!(context)->member) { \ + private_win_security_destroy((context)); \ + return false; \ + } \ + } while (0) + +static bool private_win_security_init(private_win_security_t *security) { + memset(security, 0, sizeof(*security)); + security->advapi = LoadLibraryW(L"advapi32.dll"); + if (!security->advapi) { + return false; + } + PRIVATE_RESOLVE_ADVAPI(security, open_process_token, private_open_process_token_fn, + "OpenProcessToken"); + PRIVATE_RESOLVE_ADVAPI(security, get_token_information, private_get_token_information_fn, + "GetTokenInformation"); + PRIVATE_RESOLVE_ADVAPI(security, get_length_sid, private_get_length_sid_fn, "GetLengthSid"); + PRIVATE_RESOLVE_ADVAPI(security, copy_sid, private_copy_sid_fn, "CopySid"); + PRIVATE_RESOLVE_ADVAPI(security, equal_sid, private_equal_sid_fn, "EqualSid"); + PRIVATE_RESOLVE_ADVAPI(security, is_valid_sid, private_is_valid_sid_fn, "IsValidSid"); + PRIVATE_RESOLVE_ADVAPI(security, initialize_acl, private_initialize_acl_fn, "InitializeAcl"); + PRIVATE_RESOLVE_ADVAPI(security, add_access_allowed_ace, private_add_access_allowed_ace_fn, + "AddAccessAllowedAce"); + PRIVATE_RESOLVE_ADVAPI(security, initialize_security_descriptor, + private_initialize_security_descriptor_fn, + "InitializeSecurityDescriptor"); + PRIVATE_RESOLVE_ADVAPI(security, set_security_descriptor_dacl, + private_set_security_descriptor_dacl_fn, "SetSecurityDescriptorDacl"); + PRIVATE_RESOLVE_ADVAPI(security, set_security_descriptor_control, + private_set_security_descriptor_control_fn, + "SetSecurityDescriptorControl"); + PRIVATE_RESOLVE_ADVAPI(security, get_security_descriptor_control, + private_get_security_descriptor_control_fn, + "GetSecurityDescriptorControl"); + PRIVATE_RESOLVE_ADVAPI(security, get_acl_information, private_get_acl_information_fn, + "GetAclInformation"); + PRIVATE_RESOLVE_ADVAPI(security, get_ace, private_get_ace_fn, "GetAce"); + PRIVATE_RESOLVE_ADVAPI(security, get_security_info, private_get_security_info_fn, + "GetSecurityInfo"); + + HANDLE token = NULL; + if (!security->open_process_token(GetCurrentProcess(), TOKEN_QUERY, &token)) { + private_win_security_destroy(security); + return false; + } + PSID token_sid = NULL; + void *token_user = private_win_token_user_query(security, token, &token_sid); + (void)CloseHandle(token); + if (!token_user || !token_sid || !security->is_valid_sid(token_sid)) { + free(token_user); + private_win_security_destroy(security); + return false; + } + DWORD sid_length = security->get_length_sid(token_sid); + security->user_sid = malloc(sid_length); + if (sid_length == 0 || !security->user_sid || + !security->copy_sid(sid_length, security->user_sid, token_sid)) { + free(token_user); + private_win_security_destroy(security); + return false; + } + free(token_user); + + DWORD acl_size = (DWORD)(sizeof(ACL) + sizeof(ACCESS_ALLOWED_ACE) - sizeof(DWORD)); + if (sid_length > MAXDWORD - acl_size) { + private_win_security_destroy(security); + return false; + } + acl_size += sid_length; + security->acl = malloc(acl_size); + security->descriptor = malloc(SECURITY_DESCRIPTOR_MIN_LENGTH); + if (!security->acl || !security->descriptor || + !security->initialize_acl(security->acl, acl_size, ACL_REVISION) || + !security->add_access_allowed_ace(security->acl, ACL_REVISION, FILE_ALL_ACCESS, + security->user_sid) || + !security->initialize_security_descriptor(security->descriptor, + SECURITY_DESCRIPTOR_REVISION) || + !security->set_security_descriptor_dacl(security->descriptor, TRUE, security->acl, FALSE) || + !security->set_security_descriptor_control(security->descriptor, SE_DACL_PROTECTED, + SE_DACL_PROTECTED)) { + private_win_security_destroy(security); + return false; + } + security->attributes.nLength = sizeof(security->attributes); + security->attributes.lpSecurityDescriptor = security->descriptor; + security->attributes.bInheritHandle = FALSE; + return true; +} + +#undef PRIVATE_RESOLVE_ADVAPI + +static bool private_win_handle_is_noninheritable(HANDLE handle) { + DWORD flags = 0; + return handle && handle != INVALID_HANDLE_VALUE && GetHandleInformation(handle, &flags) != 0 && + (flags & HANDLE_FLAG_INHERIT) == 0; +} + +static bool private_win_make_noninheritable(HANDLE handle) { + return handle && handle != INVALID_HANDLE_VALUE && + SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) != 0 && + private_win_handle_is_noninheritable(handle); +} + +static private_win_identity_t private_win_identity(const BY_HANDLE_FILE_INFORMATION *information) { + private_win_identity_t identity; + identity.volume_serial = information->dwVolumeSerialNumber; + identity.index_high = information->nFileIndexHigh; + identity.index_low = information->nFileIndexLow; + return identity; +} + +static bool private_win_identity_equal(const private_win_identity_t *left, + const private_win_identity_t *right) { + return left->volume_serial == right->volume_serial && left->index_high == right->index_high && + left->index_low == right->index_low; +} + +static bool private_win_handle_has_local_dos_path(HANDLE handle, wchar_t expected_drive) { + DWORD capacity = + GetFinalPathNameByHandleW(handle, NULL, 0, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + if (capacity < 8 || capacity > 32768) { + return false; + } + wchar_t *path = malloc((size_t)capacity * sizeof(*path)); + if (!path) { + return false; + } + DWORD length = + GetFinalPathNameByHandleW(handle, path, capacity, FILE_NAME_NORMALIZED | VOLUME_NAME_DOS); + bool valid = length >= 7 && length < capacity && path[0] == L'\\' && path[1] == L'\\' && + path[2] == L'?' && path[3] == L'\\' && path[5] == L':' && path[6] == L'\\'; + wchar_t actual_drive = valid ? path[4] : L'\0'; + if (actual_drive >= L'a' && actual_drive <= L'z') { + actual_drive -= L'a' - L'A'; + } + if (expected_drive >= L'a' && expected_drive <= L'z') { + expected_drive -= L'a' - L'A'; + } + valid = valid && actual_drive == expected_drive; + free(path); + return valid; +} + +static bool private_win_owner_only_dacl(private_win_security_t *security, HANDLE handle) { + PSID owner = NULL; + PACL dacl = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + DWORD result = security->get_security_info( + handle, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner, + NULL, &dacl, NULL, &descriptor); + SECURITY_DESCRIPTOR_CONTROL control = 0; + DWORD revision = 0; + ACL_SIZE_INFORMATION acl_information; + memset(&acl_information, 0, sizeof(acl_information)); + LPVOID opaque_ace = NULL; + bool valid = result == ERROR_SUCCESS && descriptor && owner && dacl && + security->is_valid_sid(owner) && security->equal_sid(owner, security->user_sid) && + security->get_security_descriptor_control(descriptor, &control, &revision) && + (control & SE_DACL_PRESENT) != 0 && (control & SE_DACL_PROTECTED) != 0 && + security->get_acl_information(dacl, &acl_information, sizeof(acl_information), + AclSizeInformation) && + acl_information.AceCount == 1 && security->get_ace(dacl, 0, &opaque_ace) && + opaque_ace; + if (valid) { + ACCESS_ALLOWED_ACE *ace = (ACCESS_ALLOWED_ACE *)opaque_ace; + PSID ace_sid = (PSID)&ace->SidStart; + valid = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE && + ace->Header.AceSize >= sizeof(ACCESS_ALLOWED_ACE) && + (ace->Header.AceFlags & (INHERITED_ACE | INHERIT_ONLY_ACE)) == 0 && + security->is_valid_sid(ace_sid) && + security->equal_sid(ace_sid, security->user_sid) && + (ace->Mask == FILE_ALL_ACCESS || ace->Mask == GENERIC_ALL); + } + if (descriptor) { + (void)LocalFree(descriptor); + } + return valid; +} + +static bool private_win_path_character_forbidden(wchar_t character) { + return character == L':' || character == L'*' || character == L'?' || character == L'"' || + character == L'<' || character == L'>' || character == L'|'; +} + +static bool private_win_path_syntax_valid(const wchar_t *path) { + size_t length = path ? wcslen(path) : 0; + bool drive_absolute = + length >= 3 && + ((path[0] >= L'A' && path[0] <= L'Z') || (path[0] >= L'a' && path[0] <= L'z')) && + path[1] == L':' && path[2] == L'\\'; + if (!drive_absolute) { + /* Reject UNC, device, NT-object and relative namespaces. */ + return false; + } + size_t component_start = 3; + for (size_t index = component_start; index <= length; index++) { + if (index < length && path[index] != L'\\') { + if (private_win_path_character_forbidden(path[index])) { + return false; + } + continue; + } + if (index == component_start) { + return length == 3 && index == length; + } + size_t component_length = index - component_start; + const wchar_t *component = path + component_start; + if ((component_length == 1 && component[0] == L'.') || + (component_length == 2 && component[0] == L'.' && component[1] == L'.') || + component[component_length - 1] == L'.' || component[component_length - 1] == L' ') { + return false; + } + component_start = index + 1; + } + return true; +} + +static cbm_private_file_lock_status_t private_win_path_from_utf8(const char *path, + wchar_t **wide_out) { + *wide_out = NULL; + int needed = path ? MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, NULL, 0) : 0; + if (needed <= 0 || needed > MAX_PATH) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + wchar_t *wide = malloc((size_t)needed * sizeof(*wide)); + if (!wide) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, path, -1, wide, needed) <= 0) { + free(wide); + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + for (int index = 0; index < needed - 1; index++) { + if (wide[index] == L'/') { + wide[index] = L'\\'; + } + } + size_t length = wcslen(wide); + while (length > 3 && wide[length - 1] == L'\\') { + wide[--length] = L'\0'; + } + if (!private_win_path_syntax_valid(wide)) { + free(wide); + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + *wide_out = wide; + return CBM_PRIVATE_FILE_LOCK_OK; +} + +static bool private_win_path_tree_is_plain_local(const wchar_t *path) { + if (!private_win_path_syntax_valid(path)) { + return false; + } + wchar_t volume_root[4] = {path[0], L':', L'\\', L'\0'}; + UINT drive_type = GetDriveTypeW(volume_root); + if (drive_type != DRIVE_FIXED && drive_type != DRIVE_REMOVABLE && drive_type != DRIVE_RAMDISK) { + return false; + } + DWORD filesystem_flags = 0; + if (!GetVolumeInformationW(volume_root, NULL, 0, NULL, NULL, &filesystem_flags, NULL, 0) || + (filesystem_flags & FILE_PERSISTENT_ACLS) == 0) { + return false; + } + + size_t length = wcslen(path); + wchar_t *partial = malloc((length + 1) * sizeof(*partial)); + if (!partial) { + return false; + } + memcpy(partial, path, (length + 1) * sizeof(*partial)); + bool valid = true; + for (size_t index = 3; valid && index <= length; index++) { + if (index < length && partial[index] != L'\\') { + continue; + } + wchar_t saved = partial[index]; + partial[index] = L'\0'; + HANDLE component = CreateFileW( + partial, FILE_READ_ATTRIBUTES, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + BY_HANDLE_FILE_INFORMATION information; + valid = component != INVALID_HANDLE_VALUE && GetFileType(component) == FILE_TYPE_DISK && + GetFileInformationByHandle(component, &information) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; + if (component != INVALID_HANDLE_VALUE) { + (void)CloseHandle(component); + } + partial[index] = saved; + } + free(partial); + return valid; +} + +static bool private_win_directory_handle_valid(cbm_private_lock_directory_t *directory, + BY_HANDLE_FILE_INFORMATION *information_out) { + BY_HANDLE_FILE_INFORMATION information; + bool valid = + directory && directory->handle != INVALID_HANDLE_VALUE && + GetFileType(directory->handle) == FILE_TYPE_DISK && + GetFileInformationByHandle(directory->handle, &information) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && + private_win_handle_has_local_dos_path(directory->handle, directory->wide_path[0]) && + private_win_handle_is_noninheritable(directory->handle) && + private_win_owner_only_dacl(&directory->security, directory->handle); + if (valid && information_out) { + *information_out = information; + } + return valid; +} + +static bool private_win_directory_path_matches(cbm_private_lock_directory_t *directory) { + HANDLE probe = + CreateFileW(directory->wide_path, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + BY_HANDLE_FILE_INFORMATION information; + bool valid = probe != INVALID_HANDLE_VALUE && GetFileType(probe) == FILE_TYPE_DISK && + GetFileInformationByHandle(probe, &information) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && + private_win_handle_has_local_dos_path(probe, directory->wide_path[0]) && + private_win_owner_only_dacl(&directory->security, probe); + if (valid) { + private_win_identity_t identity = private_win_identity(&information); + valid = private_win_identity_equal(&identity, &directory->identity); + } + if (probe != INVALID_HANDLE_VALUE) { + (void)CloseHandle(probe); + } + return valid; +} + +static bool private_win_directory_revalidate(cbm_private_lock_directory_t *directory) { + BY_HANDLE_FILE_INFORMATION information; + if (!private_win_path_tree_is_plain_local(directory->wide_path) || + !private_win_directory_handle_valid(directory, &information)) { + return false; + } + private_win_identity_t identity = private_win_identity(&information); + return private_win_identity_equal(&identity, &directory->identity) && + private_win_directory_path_matches(directory); +} + +static bool private_win_base_name_valid(const char *base_name) { + if (!base_name || !base_name[0] || strcmp(base_name, ".") == 0 || + strcmp(base_name, "..") == 0) { + return false; + } + size_t length = strlen(base_name); + if (length > 253 || base_name[length - 1] == '.') { + return false; + } + for (size_t index = 0; index < length; index++) { + char character = base_name[index]; + if (!((character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') || + character == '-' || character == '_' || character == '.')) { + return false; + } + } + size_t stem_length = strcspn(base_name, "."); + if ((stem_length == 3 && + (strncmp(base_name, "con", 3) == 0 || strncmp(base_name, "prn", 3) == 0 || + strncmp(base_name, "aux", 3) == 0 || strncmp(base_name, "nul", 3) == 0)) || + (stem_length == 4 && + ((strncmp(base_name, "com", 3) == 0 || strncmp(base_name, "lpt", 3) == 0) && + base_name[3] >= '1' && base_name[3] <= '9'))) { + /* Win32 resolves these names as devices even below a drive path and + * even when they carry an extension. */ + return false; + } + return true; +} + +static wchar_t *private_win_file_path(const cbm_private_lock_directory_t *directory, + const char *base_name) { + size_t directory_length = wcslen(directory->wide_path); + size_t base_length = strlen(base_name); + bool separator = directory_length > 0 && directory->wide_path[directory_length - 1] != L'\\'; + size_t total = directory_length + (separator ? 1 : 0) + base_length + 1; + if (total > MAX_PATH) { + return NULL; + } + wchar_t *path = malloc(total * sizeof(*path)); + if (!path) { + return NULL; + } + memcpy(path, directory->wide_path, directory_length * sizeof(*path)); + size_t offset = directory_length; + if (separator) { + path[offset++] = L'\\'; + } + for (size_t index = 0; index < base_length; index++) { + path[offset++] = (unsigned char)base_name[index]; + } + path[offset] = L'\0'; + return path; +} + +static bool private_win_file_handle_valid(cbm_private_lock_directory_t *directory, HANDLE handle, + BY_HANDLE_FILE_INFORMATION *information_out) { + BY_HANDLE_FILE_INFORMATION information; + bool valid = handle != INVALID_HANDLE_VALUE && GetFileType(handle) == FILE_TYPE_DISK && + GetFileInformationByHandle(handle, &information) != 0 && + (information.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + information.nNumberOfLinks == 1 && + private_win_handle_has_local_dos_path(handle, directory->wide_path[0]) && + private_win_handle_is_noninheritable(handle) && + private_win_owner_only_dacl(&directory->security, handle); + if (valid && information_out) { + *information_out = information; + } + return valid; +} + +static bool private_win_file_revalidate(cbm_private_lock_directory_t *directory, + const wchar_t *path, HANDLE handle, + const private_win_identity_t *expected) { + BY_HANDLE_FILE_INFORMATION information; + if (!private_win_directory_revalidate(directory) || + !private_win_file_handle_valid(directory, handle, &information)) { + return false; + } + private_win_identity_t identity = private_win_identity(&information); + if (expected && !private_win_identity_equal(&identity, expected)) { + return false; + } + + HANDLE probe = CreateFileW(path, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + BY_HANDLE_FILE_INFORMATION probe_information; + bool valid = probe != INVALID_HANDLE_VALUE && GetFileType(probe) == FILE_TYPE_DISK && + GetFileInformationByHandle(probe, &probe_information) != 0 && + (probe_information.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + probe_information.nNumberOfLinks == 1; + if (valid) { + private_win_identity_t probe_identity = private_win_identity(&probe_information); + valid = private_win_identity_equal(&identity, &probe_identity); + } + if (probe != INVALID_HANDLE_VALUE) { + (void)CloseHandle(probe); + } + return valid; +} + +static cbm_private_file_lock_status_t private_win_open_failure_status(const wchar_t *path) { + DWORD attributes = GetFileAttributesW(path); + return attributes == INVALID_FILE_ATTRIBUTES ? CBM_PRIVATE_FILE_LOCK_IO + : CBM_PRIVATE_FILE_LOCK_UNSAFE; +} + +cbm_private_file_lock_status_t cbm_private_lock_directory_adopt_windows( + void *directory_handle, const char *stable_path, cbm_private_lock_directory_t **directory_out) { + if (directory_out) { + *directory_out = NULL; + } + HANDLE handle = (HANDLE)directory_handle; + if (!directory_out || !stable_path || !stable_path[0] || !handle || + handle == INVALID_HANDLE_VALUE) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + wchar_t *wide_path = NULL; + cbm_private_file_lock_status_t path_status = + private_win_path_from_utf8(stable_path, &wide_path); + if (path_status != CBM_PRIVATE_FILE_LOCK_OK) { + return path_status; + } + private_win_security_t security; + if (!private_win_security_init(&security)) { + free(wide_path); + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (!private_win_make_noninheritable(handle)) { + private_win_security_destroy(&security); + free(wide_path); + return CBM_PRIVATE_FILE_LOCK_IO; + } + + cbm_private_lock_directory_t candidate; + memset(&candidate, 0, sizeof(candidate)); + candidate.handle = handle; + candidate.wide_path = wide_path; + candidate.security = security; + BY_HANDLE_FILE_INFORMATION information; + bool safe = private_win_path_tree_is_plain_local(wide_path) && + private_win_directory_handle_valid(&candidate, &information); + if (safe) { + candidate.identity = private_win_identity(&information); + safe = private_win_directory_path_matches(&candidate); + } + if (!safe) { + private_win_security_destroy(&candidate.security); + free(wide_path); + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + + size_t path_length = strlen(stable_path); + cbm_private_lock_directory_t *directory = calloc(1, sizeof(*directory)); + char *path_copy = malloc(path_length + 1); + if (!directory || !path_copy) { + free(directory); + free(path_copy); + private_win_security_destroy(&candidate.security); + free(wide_path); + return CBM_PRIVATE_FILE_LOCK_IO; + } + memcpy(path_copy, stable_path, path_length + 1); + *directory = candidate; + directory->path = path_copy; + *directory_out = directory; + return CBM_PRIVATE_FILE_LOCK_OK; +} + +cbm_private_file_lock_status_t cbm_private_file_lock_try_acquire( + cbm_private_lock_directory_t *directory, const char *base_name, + cbm_private_file_lock_mode_t mode, cbm_private_file_lock_t **lock_out) { + if (lock_out) { + *lock_out = NULL; + } + if (!directory || !lock_out || !private_win_base_name_valid(base_name) || + (mode != CBM_PRIVATE_FILE_LOCK_SH && mode != CBM_PRIVATE_FILE_LOCK_EX)) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!private_win_directory_revalidate(directory)) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + wchar_t *path = private_win_file_path(directory, base_name); + if (!path) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (!cbm_private_file_lock_fork_guard_enter()) { + free(path); + return CBM_PRIVATE_FILE_LOCK_IO; + } + HANDLE handle = + CreateFileW(path, GENERIC_READ | GENERIC_WRITE | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE, &directory->security.attributes, + OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (handle == INVALID_HANDLE_VALUE) { + cbm_private_file_lock_status_t status = private_win_open_failure_status(path); + cbm_private_file_lock_fork_guard_leave(); + free(path); + return status; + } + if (!private_win_make_noninheritable(handle)) { + (void)CloseHandle(handle); + cbm_private_file_lock_fork_guard_leave(); + free(path); + return CBM_PRIVATE_FILE_LOCK_IO; + } + + BY_HANDLE_FILE_INFORMATION information; + bool initially_valid = private_win_file_handle_valid(directory, handle, &information); + private_win_identity_t identity; + if (initially_valid) { + identity = private_win_identity(&information); + initially_valid = private_win_file_revalidate(directory, path, handle, &identity); + } + if (!initially_valid) { + (void)CloseHandle(handle); + cbm_private_file_lock_fork_guard_leave(); + free(path); + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + + cbm_private_file_lock_t *lock = calloc(1, sizeof(*lock)); + if (!lock) { + (void)CloseHandle(handle); + cbm_private_file_lock_fork_guard_leave(); + free(path); + return CBM_PRIVATE_FILE_LOCK_IO; + } + lock->handle = handle; + lock->mode = mode; + + DWORD flags = LOCKFILE_FAIL_IMMEDIATELY; + if (mode == CBM_PRIVATE_FILE_LOCK_EX) { + flags |= LOCKFILE_EXCLUSIVE_LOCK; + } + bool forced_lock_failure = directory->test_fail_lock_attempt_once; + bool fail_lock_cleanup_close = directory->test_fail_lock_attempt_close; + directory->test_fail_lock_attempt_once = false; + directory->test_fail_lock_attempt_close = false; + BOOL native_locked = + forced_lock_failure ? FALSE : LockFileEx(handle, flags, 0, 1, 0, &lock->range); + if (!native_locked) { + DWORD lock_error = forced_lock_failure ? ERROR_LOCK_VIOLATION : GetLastError(); + lock->unlocked = true; + lock->test_fail_close_once = fail_lock_cleanup_close; + *lock_out = lock; + cbm_private_file_lock_fork_guard_leave(); + free(path); + cbm_private_file_lock_status_t failure_status = lock_error == ERROR_LOCK_VIOLATION + ? CBM_PRIVATE_FILE_LOCK_BUSY + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t cleanup_status = cbm_private_file_lock_release(lock_out); + return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK ? failure_status + : CBM_PRIVATE_FILE_LOCK_IO; + } + bool forced_cleanup = directory->test_fail_post_acquire_once; + bool fail_cleanup_unlock = directory->test_fail_post_acquire_unlock; + bool fail_cleanup_close = directory->test_fail_post_acquire_close; + directory->test_fail_post_acquire_once = false; + directory->test_fail_post_acquire_unlock = false; + directory->test_fail_post_acquire_close = false; + if (forced_cleanup || !private_win_file_revalidate(directory, path, handle, &identity)) { + cbm_private_file_lock_status_t failure_status = + forced_cleanup ? CBM_PRIVATE_FILE_LOCK_IO : CBM_PRIVATE_FILE_LOCK_UNSAFE; + lock->test_fail_unlock_once = fail_cleanup_unlock; + lock->test_fail_close_once = fail_cleanup_close; + *lock_out = lock; + cbm_private_file_lock_fork_guard_leave(); + free(path); + cbm_private_file_lock_status_t cleanup_status = cbm_private_file_lock_release(lock_out); + return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK ? failure_status + : CBM_PRIVATE_FILE_LOCK_IO; + } + free(path); + *lock_out = lock; + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_OK; +} + +static bool private_win_payload_handle_valid(const cbm_private_file_lock_t *lock) { + BY_HANDLE_FILE_INFORMATION information; + return lock && lock->handle != INVALID_HANDLE_VALUE && !lock->unlocked && + GetFileType(lock->handle) == FILE_TYPE_DISK && + GetFileInformationByHandle(lock->handle, &information) != 0 && + (information.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + information.nNumberOfLinks == 1 && + private_win_handle_is_noninheritable(lock->handle); +} + +cbm_private_file_lock_status_t cbm_private_file_lock_payload_read( + cbm_private_file_lock_t *lock, void *buffer, size_t capacity, + size_t *length_out) { + if (length_out) { + *length_out = 0; + } + if (!lock || !buffer || capacity == 0 || !length_out) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!cbm_private_file_lock_fork_guard_enter()) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + LARGE_INTEGER size; + LARGE_INTEGER zero; + zero.QuadPart = 0; + bool valid = private_win_payload_handle_valid(lock) && + GetFileSizeEx(lock->handle, &size) != 0 && size.QuadPart >= 0 && + (uint64_t)size.QuadPart <= PRIVATE_FILE_LOCK_PAYLOAD_CAP && + (uint64_t)size.QuadPart <= capacity && + SetFilePointerEx(lock->handle, zero, NULL, FILE_BEGIN) != 0; + size_t length = valid ? (size_t)size.QuadPart : 0; + size_t offset = 0; + while (valid && offset < length) { + DWORD chunk = (DWORD)(length - offset); + DWORD count = 0; + valid = ReadFile(lock->handle, (unsigned char *)buffer + offset, chunk, + &count, NULL) != 0 && count > 0; + offset += valid ? (size_t)count : 0; + } + cbm_private_file_lock_fork_guard_leave(); + if (!valid) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + *length_out = length; + return CBM_PRIVATE_FILE_LOCK_OK; +} + +cbm_private_file_lock_status_t cbm_private_file_lock_payload_write( + cbm_private_file_lock_t *lock, const void *buffer, size_t length) { + if (!lock || !buffer || length == 0 || + length > PRIVATE_FILE_LOCK_PAYLOAD_CAP || + lock->mode != CBM_PRIVATE_FILE_LOCK_EX) { + return CBM_PRIVATE_FILE_LOCK_UNSAFE; + } + if (!cbm_private_file_lock_fork_guard_enter()) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + LARGE_INTEGER zero; + zero.QuadPart = 0; + bool valid = private_win_payload_handle_valid(lock) && + SetFilePointerEx(lock->handle, zero, NULL, FILE_BEGIN) != 0 && + SetEndOfFile(lock->handle) != 0; + size_t offset = 0; + while (valid && offset < length) { + DWORD chunk = (DWORD)(length - offset); + DWORD count = 0; + valid = WriteFile(lock->handle, + (const unsigned char *)buffer + offset, chunk, &count, + NULL) != 0 && count > 0; + offset += valid ? (size_t)count : 0; + } + valid = valid && FlushFileBuffers(lock->handle) != 0; + cbm_private_file_lock_fork_guard_leave(); + return valid ? CBM_PRIVATE_FILE_LOCK_OK : CBM_PRIVATE_FILE_LOCK_IO; +} + +static bool private_win_release_unlock(cbm_private_file_lock_t *lock) { + lock->test_unlock_attempts++; + if (lock->test_fail_unlock_once) { + lock->test_fail_unlock_once = false; + SetLastError(ERROR_GEN_FAILURE); + return false; + } + return UnlockFileEx(lock->handle, 0, 1, 0, &lock->range) != 0; +} + +static bool private_win_release_close(cbm_private_file_lock_t *lock) { + lock->test_close_attempts++; + if (lock->test_fail_close_once) { + lock->test_fail_close_once = false; + SetLastError(ERROR_GEN_FAILURE); + return false; + } + return CloseHandle(lock->handle) != 0; +} + +cbm_private_file_lock_status_t cbm_private_file_lock_release(cbm_private_file_lock_t **lock_io) { + if (!lock_io || !*lock_io) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + cbm_private_file_lock_t *lock = *lock_io; + if (!cbm_private_file_lock_fork_guard_enter()) { + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (lock->handle == INVALID_HANDLE_VALUE) { + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_IO; + } + if (!lock->unlocked) { + if (!private_win_release_unlock(lock)) { + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_IO; + } + lock->unlocked = true; + } + if (!private_win_release_close(lock)) { + cbm_private_file_lock_fork_guard_leave(); + return CBM_PRIVATE_FILE_LOCK_IO; + } + lock->handle = INVALID_HANDLE_VALUE; + cbm_private_file_lock_fork_guard_leave(); + *lock_io = NULL; + free(lock); + return CBM_PRIVATE_FILE_LOCK_OK; +} + +void cbm_private_lock_directory_close(cbm_private_lock_directory_t *directory) { + if (!directory) { + return; + } + if (directory->handle != INVALID_HANDLE_VALUE) { + (void)CloseHandle(directory->handle); + } + private_win_security_destroy(&directory->security); + free(directory->wide_path); + free(directory->path); + free(directory); +} + +const char *cbm_private_lock_directory_path(const cbm_private_lock_directory_t *directory) { + return directory ? directory->path : NULL; +} + +bool cbm_private_file_lock_is_cloexec_for_test(const cbm_private_file_lock_t *lock) { + return lock && private_win_handle_is_noninheritable(lock->handle); +} + +bool cbm_private_file_lock_unlock_complete(const cbm_private_file_lock_t *lock) { + return lock && lock->unlocked; +} + +bool cbm_private_lock_directory_fail_post_acquire_cleanup_for_test( + cbm_private_lock_directory_t *directory, bool fail_unlock, bool fail_close) { + if (!directory || (!fail_unlock && !fail_close)) { + return false; + } + directory->test_fail_post_acquire_once = true; + directory->test_fail_post_acquire_unlock = fail_unlock; + directory->test_fail_post_acquire_close = fail_close; + return true; +} + +bool cbm_private_lock_directory_fail_lock_attempt_cleanup_for_test( + cbm_private_lock_directory_t *directory) { + if (!directory) { + return false; + } + directory->test_fail_lock_attempt_once = true; + directory->test_fail_lock_attempt_close = true; + return true; +} + +bool cbm_private_file_lock_fail_next_release_step_for_test( + cbm_private_file_lock_t *lock, cbm_private_file_lock_release_step_t step) { + if (!lock) { + return false; + } + if (step == CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK) { + lock->test_fail_unlock_once = true; + return true; + } + if (step == CBM_PRIVATE_FILE_LOCK_RELEASE_CLOSE) { + lock->test_fail_close_once = true; + return true; + } + return false; +} + +unsigned int cbm_private_file_lock_release_step_attempts_for_test( + const cbm_private_file_lock_t *lock, cbm_private_file_lock_release_step_t step) { + if (!lock) { + return 0; + } + if (step == CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK) { + return lock->test_unlock_attempts; + } + if (step == CBM_PRIVATE_FILE_LOCK_RELEASE_CLOSE) { + return lock->test_close_attempts; + } + return 0; +} + +bool cbm_private_file_lock_fork_guard_enter(void) { + if (!InitOnceExecuteOnce(&private_win_gate_once, private_win_gate_initialize, NULL, NULL)) { + return false; + } + EnterCriticalSection(&private_win_gate); + return true; +} + +void cbm_private_file_lock_fork_guard_leave(void) { + LeaveCriticalSection(&private_win_gate); +} + +cbm_private_fork_condition_t *cbm_private_fork_condition_new(void) { + cbm_private_fork_condition_t *condition = calloc(1, sizeof(*condition)); + if (condition) { + InitializeConditionVariable(&condition->value); + } + return condition; +} + +void cbm_private_fork_condition_free(cbm_private_fork_condition_t *condition) { + free(condition); +} + +void cbm_private_fork_condition_broadcast_while_guarded( + cbm_private_fork_condition_t *condition) { + if (condition) { + WakeAllConditionVariable(&condition->value); + } +} + +cbm_private_fork_wait_status_t cbm_private_fork_condition_wait_until_while_guarded( + cbm_private_fork_condition_t *condition, uint64_t deadline_ms) { + if (!condition) { + return CBM_PRIVATE_FORK_WAIT_ERROR; + } + DWORD timeout = INFINITE; + if (deadline_ms != UINT64_MAX) { + uint64_t now_ms = cbm_now_ms(); + uint64_t remaining_ms = deadline_ms > now_ms ? deadline_ms - now_ms : 0; + timeout = remaining_ms >= (uint64_t)INFINITE ? INFINITE - 1U : (DWORD)remaining_ms; + } + if (SleepConditionVariableCS(&condition->value, &private_win_gate, timeout)) { + return CBM_PRIVATE_FORK_WAIT_SIGNALED; + } + return GetLastError() == ERROR_TIMEOUT ? CBM_PRIVATE_FORK_WAIT_TIMEOUT + : CBM_PRIVATE_FORK_WAIT_ERROR; +} + +#endif diff --git a/src/foundation/private_file_lock.h b/src/foundation/private_file_lock.h new file mode 100644 index 000000000..a1a9e3417 --- /dev/null +++ b/src/foundation/private_file_lock.h @@ -0,0 +1,45 @@ +/* + * private_file_lock.h — Secure locks inside a prevalidated private directory. + * + * This is an internal foundation primitive. It deliberately does not choose a + * product runtime path; callers must supply an opaque directory handle created + * by the platform runtime-path layer. + */ +#ifndef CBM_PRIVATE_FILE_LOCK_H +#define CBM_PRIVATE_FILE_LOCK_H + +#include + +typedef enum { + CBM_PRIVATE_FILE_LOCK_OK = 0, + CBM_PRIVATE_FILE_LOCK_BUSY = 1, + CBM_PRIVATE_FILE_LOCK_UNSAFE = 2, + CBM_PRIVATE_FILE_LOCK_IO = 3, +} cbm_private_file_lock_status_t; + +typedef enum { + CBM_PRIVATE_FILE_LOCK_SH = 1, + CBM_PRIVATE_FILE_LOCK_EX = 2, +} cbm_private_file_lock_mode_t; + +typedef struct cbm_private_lock_directory cbm_private_lock_directory_t; +typedef struct cbm_private_file_lock cbm_private_file_lock_t; + +/* Basenames are fixed internal names, never paths. Acquisition is + * nonblocking; BUSY is the only contention result. Stable lock files are never + * unlinked by this API. Any non-NULL *lock_out on any status owns native + * cleanup state and must be passed to cbm_private_file_lock_release(). */ +cbm_private_file_lock_status_t cbm_private_file_lock_try_acquire( + cbm_private_lock_directory_t *directory, const char *base_name, + cbm_private_file_lock_mode_t mode, cbm_private_file_lock_t **lock_out); + +/* OK terminally closes the native handle and clears *lock_io. IO retains a + * non-NULL object only while native ownership is safely retryable. POSIX + * close(2) consumes descriptor ownership once invoked even if it reports an + * error, so that terminal IO case clears *lock_io to prevent fd-reuse races. */ +cbm_private_file_lock_status_t cbm_private_file_lock_release(cbm_private_file_lock_t **lock_io); + +void cbm_private_lock_directory_close(cbm_private_lock_directory_t *directory); +const char *cbm_private_lock_directory_path(const cbm_private_lock_directory_t *directory); + +#endif /* CBM_PRIVATE_FILE_LOCK_H */ diff --git a/src/foundation/private_file_lock_internal.h b/src/foundation/private_file_lock_internal.h new file mode 100644 index 000000000..5ea81bb5e --- /dev/null +++ b/src/foundation/private_file_lock_internal.h @@ -0,0 +1,94 @@ +/* Internal constructors for platform runtime-path code and focused tests. */ +#ifndef CBM_PRIVATE_FILE_LOCK_INTERNAL_H +#define CBM_PRIVATE_FILE_LOCK_INTERNAL_H + +#include "foundation/private_file_lock.h" + +#include +#include +#include + +typedef struct cbm_private_fork_condition cbm_private_fork_condition_t; + +typedef enum { + CBM_PRIVATE_FORK_WAIT_SIGNALED = 0, + CBM_PRIVATE_FORK_WAIT_TIMEOUT = 1, + CBM_PRIVATE_FORK_WAIT_ERROR = 2, +} cbm_private_fork_wait_status_t; + +/* Condition variable associated with the global fork guard. Wait atomically + * releases that guard and always reacquires it before returning. */ +cbm_private_fork_condition_t *cbm_private_fork_condition_new(void); +void cbm_private_fork_condition_free(cbm_private_fork_condition_t *condition); +void cbm_private_fork_condition_broadcast_while_guarded( + cbm_private_fork_condition_t *condition); +cbm_private_fork_wait_status_t cbm_private_fork_condition_wait_until_while_guarded( + cbm_private_fork_condition_t *condition, uint64_t deadline_ms); + +#ifdef _WIN32 +/* On success ownership of directory_handle transfers to the returned object. + * On failure the caller still owns it. The handle must allow attribute and + * security queries; stable_path must be a local drive path naming that exact + * non-reparse directory. */ +cbm_private_file_lock_status_t cbm_private_lock_directory_adopt_windows( + void *directory_handle, const char *stable_path, cbm_private_lock_directory_t **directory_out); +/* Models LockFileEx contention after opening the handle, then makes the first + * CloseHandle cleanup attempt fail before invocation. */ +bool cbm_private_lock_directory_fail_lock_attempt_cleanup_for_test( + cbm_private_lock_directory_t *directory); +#else +/* On success ownership of directory_fd transfers to the returned object. On + * failure the caller still owns it. stable_path must name that exact handle. */ +cbm_private_file_lock_status_t cbm_private_lock_directory_adopt_posix( + int directory_fd, const char *stable_path, cbm_private_lock_directory_t **directory_out); +#endif + +/* Focused invariant seam: production callers never receive a native handle. */ +bool cbm_private_file_lock_is_cloexec_for_test(const cbm_private_file_lock_t *lock); + +/* True after the native SH/EX ownership has been released, even when closing + * the descriptor/handle remains retryable. */ +bool cbm_private_file_lock_unlock_complete(const cbm_private_file_lock_t *lock); + +/* A held lock file may carry one small fixed-format coordination record. + * Reads require SH or EX ownership; writes require EX ownership. The handle + * validated at acquisition remains the authority, so callers never reopen a + * user-controlled path. Payloads are capped at 4096 bytes. */ +cbm_private_file_lock_status_t cbm_private_file_lock_payload_read( + cbm_private_file_lock_t *lock, void *buffer, size_t capacity, + size_t *length_out); +cbm_private_file_lock_status_t cbm_private_file_lock_payload_write( + cbm_private_file_lock_t *lock, const void *buffer, size_t length); + +/* Forces the next successfully acquired native lock down the post-lock + * validation cleanup path and injects pre-call release failures there. */ +bool cbm_private_lock_directory_fail_post_acquire_cleanup_for_test( + cbm_private_lock_directory_t *directory, bool fail_unlock, bool fail_close); + +typedef enum { + CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK = 1, + CBM_PRIVATE_FILE_LOCK_RELEASE_CLOSE = 2, +} cbm_private_file_lock_release_step_t; + +/* One-shot native release fault seam. The selected operation reports failure + * without invoking the OS primitive, so retry behavior can be tested without + * corrupting or replacing a real descriptor/handle. */ +bool cbm_private_file_lock_fail_next_release_step_for_test( + cbm_private_file_lock_t *lock, cbm_private_file_lock_release_step_t step); +unsigned int cbm_private_file_lock_release_step_attempts_for_test( + const cbm_private_file_lock_t *lock, cbm_private_file_lock_release_step_t step); + +#ifndef _WIN32 +/* Models close(2) consuming fd ownership while reporting an error. */ +bool cbm_private_file_lock_fail_close_after_consuming_for_test(cbm_private_file_lock_t *lock); +int cbm_private_file_lock_native_fd_for_test(const cbm_private_file_lock_t *lock); +#endif + +/* Global process-lifetime gate for registry live-set mutation and teardown. + * POSIX atfork prepare takes this same gate before closing child-side lock + * descriptors. Never call a private-file-lock operation while holding it: + * those operations take the gate internally on every platform. */ +bool cbm_private_file_lock_fork_guard_enter(void); +void cbm_private_file_lock_fork_guard_leave(void); + +#endif /* CBM_PRIVATE_FILE_LOCK_INTERNAL_H */ diff --git a/src/foundation/subprocess.c b/src/foundation/subprocess.c index 5d429fb68..a62bd8aca 100644 --- a/src/foundation/subprocess.c +++ b/src/foundation/subprocess.c @@ -9,6 +9,8 @@ #include "platform.h" /* cbm_now_ms */ #include +#include +#include #include #ifdef _WIN32 @@ -20,6 +22,7 @@ #include #include #include +#include #include #include #endif @@ -28,6 +31,7 @@ * exit codes: 0xC0000005 (access violation), 0xC00000FD (stack overflow), * 0xC000001D (illegal instruction), 0xC0000094 (integer divide by zero), … */ #define CBM_WIN_CRASH_CODE_MIN 0xC0000000u +#define CBM_WIN_CONTROL_C_EXIT 0xC000013Au #ifndef _WIN32 static bool cbm_is_fault_signal(int sig) { @@ -63,6 +67,9 @@ cbm_proc_outcome_t cbm_proc_classify(bool exited_normally, int exit_code, int te } /* Exited with a code. A Windows NTSTATUS exception code is a crash; on POSIX * exit codes are 0..255 so this branch never misfires there. */ + if ((unsigned)exit_code == CBM_WIN_CONTROL_C_EXIT) { + return CBM_PROC_KILLED; + } if ((unsigned)exit_code >= CBM_WIN_CRASH_CODE_MIN) { return CBM_PROC_CRASH; } @@ -95,24 +102,56 @@ static bool cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb c if (!log_file) { return false; } +#ifdef _WIN32 FILE *lf = fopen(log_file, "r"); +#else + int open_flags = O_RDONLY | O_NONBLOCK; +#ifdef O_CLOEXEC + open_flags |= O_CLOEXEC; +#endif +#ifdef O_NOFOLLOW + open_flags |= O_NOFOLLOW; +#endif + int log_fd = open(log_file, open_flags); + if (log_fd < 0) { + return false; + } + struct stat log_status; + if (fstat(log_fd, &log_status) != 0 || !S_ISREG(log_status.st_mode)) { + (void)close(log_fd); + return false; + } + FILE *lf = fdopen(log_fd, "r"); +#endif if (!lf) { +#ifndef _WIN32 + (void)close(log_fd); +#endif return false; } bool progressed = false; if (fseek(lf, *tail_pos, SEEK_SET) == 0) { char line[1024]; - for (;;) { + size_t delivered_lines = 0; + size_t delivered_bytes = 0; + enum { + CBM_TAIL_MAX_LINES_PER_POLL = 64, + CBM_TAIL_MAX_BYTES_PER_POLL = 64 * 1024, + }; + while (delivered_lines < CBM_TAIL_MAX_LINES_PER_POLL && + delivered_bytes < CBM_TAIL_MAX_BYTES_PER_POLL) { long before = ftell(lf); if (!fgets(line, sizeof(line), lf)) { break; } size_t l = strlen(line); + delivered_bytes += l; bool complete = (l > 0 && line[l - 1] == '\n'); if (complete) { line[l - 1] = '\0'; *tail_pos = ftell(lf); progressed = true; + delivered_lines++; if (line[0] && cb) { cb(line, ud); } @@ -121,6 +160,7 @@ static bool cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb c * anyway (counts as progress) so we never stall on one long line. */ *tail_pos = ftell(lf); progressed = true; + delivered_lines++; if (cb) { cb(line, ud); } @@ -157,7 +197,7 @@ static size_t cbm_cmdline_put(char *buf, size_t cap, size_t pos, char c, bool *o * {"repo_path":"C:/r"} loses its inner quotes and the child receives the invalid * {repo_path:C:/r} — the Windows-only index-worker cmdline-quoting bug (the worker exited * non-zero at JSON-arg parse, misattributed to the last-marked file). POSIX is - * unaffected: cbm_run_posix passes the argv array straight to execv. */ + * unaffected: the POSIX spawn path passes the argv array straight to execv. */ static size_t cbm_cmdline_append_arg(char *buf, size_t cap, size_t pos, const char *arg, bool first, bool *ovf) { if (!first) { @@ -203,8 +243,8 @@ static size_t cbm_cmdline_append_arg(char *buf, size_t cap, size_t pos, const ch * * Defined unconditionally (pure string logic, no Windows headers) so the quoting * contract is unit-tested on Linux/macOS CI too — even though the real spawn path - * only runs on Windows. Shared by cbm_run_win AND the UI http_server index spawn - * so both escape identically; a naive `"%s"` wrap silently corrupts any argument + * only runs on Windows. Shared by cbm_subprocess_spawn_win AND the UI http_server + * index spawn so both escape identically; a naive `"%s"` wrap corrupts any argument * containing a quote (e.g. the index JSON {"repo_path":"…"}), corrupting the * spawned child's argv. */ bool cbm_build_win_cmdline(char *buf, size_t cap, const char *const *argv) { @@ -224,190 +264,759 @@ bool cbm_build_win_cmdline(char *buf, size_t cap, const char *const *argv) { return true; } +/* ── Nonblocking contained-process supervisor ─────────────────────────────── */ + +enum { CBM_SUBPROCESS_ARGV_LIMIT = 4096 }; + +struct cbm_subprocess { + char *bin; + char **argv; + size_t argc; + char *log_file; + cbm_proc_log_cb on_log_line; + void *log_ud; + int quiet_timeout_ms; + int cancel_grace_ms; + bool delete_log_on_exit; + + long tail_pos; + uint64_t last_activity_ms; + uint64_t termination_started_ms; + atomic_bool cancellation_requested; + bool timed_out; + bool termination_started; + bool force_sent; + bool root_reaped; + atomic_bool terminal; + uint64_t force_started_ms; + bool containment_failed; + cbm_proc_result_t result; + #ifdef _WIN32 + HANDLE process; + HANDLE job; + DWORD process_id; + bool root_forced; +#else + pid_t pid; + pid_t pgid; +#endif +}; -static int cbm_run_win(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) { - const char *bin = opts->bin; - const char *const default_argv[] = {bin, NULL}; - const char *const *argv = opts->argv ? opts->argv : default_argv; +static void cbm_subprocess_result_init(cbm_proc_result_t *result) { + result->outcome = CBM_PROC_SPAWN_FAILED; + result->exit_code = -1; + result->term_signal = 0; + result->cancellation_requested = false; + result->forced = false; + result->tree_quiesced = false; + result->supervision_failed = false; +} - char cmdline[8192]; - if (!cbm_build_win_cmdline(cmdline, sizeof(cmdline), argv)) { - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; - return -1; +static void cbm_subprocess_free_config(cbm_subprocess_t *process) { + if (!process) { + return; } - /* Spawn via CreateProcessW with a WIDE command line. CreateProcessA would - * re-interpret our UTF-8 cmdline bytes through the ANSI code page (CP_ACP), - * re-mangling a non-ASCII repo path at the parent->worker boundary — so the - * worker's own wide-argv read could never recover it (#423/#20). */ - wchar_t *wcmd = cbm_utf8_to_wide(cmdline); - if (!wcmd) { - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; - return -1; + if (process->argv) { + for (size_t i = 0; i < process->argc; i++) { + free(process->argv[i]); + } + } + free(process->argv); + free(process->bin); + free(process->log_file); + free(process); +} + +static cbm_subprocess_t *cbm_subprocess_copy_opts(const cbm_proc_opts_t *opts) { + if (!opts || !opts->bin || !opts->bin[0]) { + return NULL; } - HANDLE hlog = INVALID_HANDLE_VALUE; - STARTUPINFOW si = {.cb = sizeof(si)}; + size_t argc = 1; + if (opts->argv) { + argc = 0; + while (argc < CBM_SUBPROCESS_ARGV_LIMIT && opts->argv[argc]) { + argc++; + } + if (argc == 0 || argc == CBM_SUBPROCESS_ARGV_LIMIT) { + return NULL; + } + } + + cbm_subprocess_t *process = (cbm_subprocess_t *)calloc(1, sizeof(*process)); + if (!process) { + return NULL; + } + process->bin = cbm_strdup(opts->bin); + process->argv = (char **)calloc(argc + 1, sizeof(*process->argv)); + process->argc = argc; + if (!process->bin || !process->argv) { + cbm_subprocess_free_config(process); + return NULL; + } + for (size_t i = 0; i < argc; i++) { + const char *arg = opts->argv ? opts->argv[i] : opts->bin; + process->argv[i] = cbm_strdup(arg); + if (!process->argv[i]) { + cbm_subprocess_free_config(process); + return NULL; + } + } if (opts->log_file) { - hlog = CreateFileA(opts->log_file, GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, NULL); - if (hlog != INVALID_HANDLE_VALUE) { - si.dwFlags = STARTF_USESTDHANDLES; - si.hStdError = hlog; - si.hStdOutput = hlog; + process->log_file = cbm_strdup(opts->log_file); + if (!process->log_file) { + cbm_subprocess_free_config(process); + return NULL; } } + process->on_log_line = opts->on_log_line; + process->log_ud = opts->log_ud; + process->quiet_timeout_ms = opts->quiet_timeout_ms; + process->cancel_grace_ms = + opts->cancel_grace_ms > 0 ? opts->cancel_grace_ms : CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS; + if (process->cancel_grace_ms > CBM_SUBPROCESS_MAX_CANCEL_GRACE_MS) { + process->cancel_grace_ms = CBM_SUBPROCESS_MAX_CANCEL_GRACE_MS; + } + process->delete_log_on_exit = opts->delete_log_on_exit; + atomic_init(&process->cancellation_requested, false); + atomic_init(&process->terminal, false); + cbm_subprocess_result_init(&process->result); + return process; +} - PROCESS_INFORMATION pi = {0}; - BOOL ok = CreateProcessW(NULL, wcmd, NULL, NULL, TRUE, 0, NULL, NULL, &si, &pi); - free(wcmd); - if (hlog != INVALID_HANDLE_VALUE) { - CloseHandle(hlog); +static void cbm_subprocess_poll_log(cbm_subprocess_t *process) { + if (cbm_tail_log(process->log_file, &process->tail_pos, process->on_log_line, + process->log_ud)) { + process->last_activity_ms = cbm_now_ms(); } - if (!ok) { - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; +} + +static void cbm_subprocess_delete_log(cbm_subprocess_t *process) { + if (!process->log_file || !process->delete_log_on_exit) { + return; + } +#ifdef _WIN32 + wchar_t *path = cbm_utf8_to_wide(process->log_file); + if (path) { + (void)DeleteFileW(path); + free(path); + } +#else + (void)unlink(process->log_file); +#endif +} + +static cbm_proc_poll_t cbm_subprocess_finish(cbm_subprocess_t *process, cbm_proc_result_t *out) { + /* Capture lines written between the first tail in this poll and process exit. */ + cbm_subprocess_poll_log(process); + process->result.cancellation_requested = + atomic_load_explicit(&process->cancellation_requested, memory_order_acquire); + process->result.tree_quiesced = true; + process->result.supervision_failed = false; + atomic_store_explicit(&process->terminal, true, memory_order_release); + cbm_subprocess_delete_log(process); + if (out) { + *out = process->result; + } + return CBM_PROC_POLL_TERMINAL; +} + +static cbm_proc_poll_t cbm_subprocess_finish_failed(cbm_subprocess_t *process, + cbm_proc_result_t *out) { + cbm_subprocess_poll_log(process); + process->result.cancellation_requested = + atomic_load_explicit(&process->cancellation_requested, memory_order_acquire); + process->result.tree_quiesced = false; + process->result.supervision_failed = true; + process->result.forced = true; + if (process->timed_out) { + process->result.outcome = CBM_PROC_HANG; + } else if (process->result.outcome == CBM_PROC_SPAWN_FAILED) { + process->result.outcome = CBM_PROC_KILLED; + } + process->containment_failed = true; + atomic_store_explicit(&process->terminal, true, memory_order_release); + if (out) { + *out = process->result; + } + return CBM_PROC_POLL_TERMINAL; +} + +#ifdef _WIN32 + +static void cbm_win_close_spawn_handles(HANDLE nul, HANDLE log, LPPROC_THREAD_ATTRIBUTE_LIST attrs, + bool attrs_init) { + if (attrs) { + if (attrs_init) { + DeleteProcThreadAttributeList(attrs); + } + free(attrs); + } + if (log != INVALID_HANDLE_VALUE) { + CloseHandle(log); + } + if (nul != INVALID_HANDLE_VALUE) { + CloseHandle(nul); + } +} + +static int cbm_subprocess_spawn_win(cbm_subprocess_t *process) { + char cmdline[8192]; + if (!cbm_build_win_cmdline(cmdline, sizeof(cmdline), (const char *const *)process->argv)) { + return -1; + } + wchar_t *wbin = cbm_utf8_to_wide(process->bin); + wchar_t *wcmdline = cbm_utf8_to_wide(cmdline); + if (!wbin || !wcmdline) { + free(wbin); + free(wcmdline); return -1; } - long tail_pos = 0; - uint64_t last_activity = cbm_now_ms(); - bool timed_out = false; - for (;;) { - DWORD w = WaitForSingleObject(pi.hProcess, 200); - if (cbm_tail_log(opts->log_file, &tail_pos, opts->on_log_line, opts->log_ud)) { - last_activity = cbm_now_ms(); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits; + ZeroMemory(&limits, sizeof(limits)); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + HANDLE job = CreateJobObjectW(NULL, NULL); + if (!job || + !SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits))) { + if (job) { + CloseHandle(job); } - if (w == WAIT_OBJECT_0) { - break; + free(wbin); + free(wcmdline); + return -1; + } + + SECURITY_ATTRIBUTES security; + ZeroMemory(&security, sizeof(security)); + security.nLength = sizeof(security); + security.bInheritHandle = TRUE; + HANDLE nul = CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, &security, OPEN_EXISTING, 0, NULL); + HANDLE log = INVALID_HANDLE_VALUE; + if (nul == INVALID_HANDLE_VALUE) { + CloseHandle(job); + free(wbin); + free(wcmdline); + return -1; + } + if (process->log_file) { + wchar_t *wlog = cbm_utf8_to_wide(process->log_file); + if (wlog) { + log = CreateFileW(wlog, GENERIC_WRITE, FILE_SHARE_READ, &security, CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL, NULL); + free(wlog); } - if (opts->quiet_timeout_ms > 0 && - (cbm_now_ms() - last_activity) >= (uint64_t)opts->quiet_timeout_ms) { - TerminateProcess(pi.hProcess, 1); - WaitForSingleObject(pi.hProcess, INFINITE); - timed_out = true; - break; + if (log == INVALID_HANDLE_VALUE) { + CloseHandle(nul); + CloseHandle(job); + free(wbin); + free(wcmdline); + return -1; } } - DWORD code = 1; - GetExitCodeProcess(pi.hProcess, &code); - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - if (opts->log_file && opts->delete_log_on_exit) { - DeleteFileA(opts->log_file); + HANDLE inherit[2]; + SIZE_T inherit_count = 0; + inherit[inherit_count++] = nul; + if (log != INVALID_HANDLE_VALUE) { + inherit[inherit_count++] = log; + } + SIZE_T attrs_size = 0; + (void)InitializeProcThreadAttributeList(NULL, 1, 0, &attrs_size); + LPPROC_THREAD_ATTRIBUTE_LIST attrs = (LPPROC_THREAD_ATTRIBUTE_LIST)malloc(attrs_size); + bool attrs_init = attrs && InitializeProcThreadAttributeList(attrs, 1, 0, &attrs_size); + bool attrs_ready = attrs_init && UpdateProcThreadAttribute( + attrs, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherit, + inherit_count * sizeof(inherit[0]), NULL, NULL); + if (!attrs_ready) { + cbm_win_close_spawn_handles(nul, log, attrs, attrs_init); + CloseHandle(job); + free(wbin); + free(wcmdline); + return -1; + } + + STARTUPINFOEXW startup; + ZeroMemory(&startup, sizeof(startup)); + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = nul; + startup.StartupInfo.hStdOutput = log != INVALID_HANDLE_VALUE ? log : nul; + startup.StartupInfo.hStdError = log != INVALID_HANDLE_VALUE ? log : nul; + startup.lpAttributeList = attrs; + + PROCESS_INFORMATION child; + ZeroMemory(&child, sizeof(child)); + DWORD flags = EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_NEW_PROCESS_GROUP; + BOOL created = CreateProcessW(wbin, wcmdline, NULL, NULL, TRUE, flags, NULL, NULL, + &startup.StartupInfo, &child); + cbm_win_close_spawn_handles(nul, log, attrs, attrs_init); + free(wbin); + free(wcmdline); + if (!created) { + CloseHandle(job); + return -1; + } + + /* Assignment while suspended closes the process-creation race: no worker + * instruction executes unless all descendants are already contained. */ + if (!AssignProcessToJobObject(job, child.hProcess)) { + (void)TerminateProcess(child.hProcess, 1); + (void)WaitForSingleObject(child.hProcess, 5000); + CloseHandle(child.hThread); + CloseHandle(child.hProcess); + CloseHandle(job); + return -1; + } + if (ResumeThread(child.hThread) == (DWORD)-1) { + /* KILL_ON_JOB_CLOSE terminates the still-suspended contained process. */ + CloseHandle(job); + (void)WaitForSingleObject(child.hProcess, 5000); + CloseHandle(child.hThread); + CloseHandle(child.hProcess); + return -1; } - out->exit_code = (int)code; - out->term_signal = 0; - out->outcome = cbm_proc_classify(true, (int)code, 0, timed_out); + CloseHandle(child.hThread); + process->process = child.hProcess; + process->process_id = child.dwProcessId; + process->job = job; return 0; } +static bool cbm_win_job_active(cbm_subprocess_t *process, bool *known) { + JOBOBJECT_BASIC_ACCOUNTING_INFORMATION accounting; + ZeroMemory(&accounting, sizeof(accounting)); + if (!QueryInformationJobObject(process->job, JobObjectBasicAccountingInformation, &accounting, + sizeof(accounting), NULL)) { + *known = false; + return true; /* fail closed: an unqueryable job is never declared quiescent */ + } + *known = true; + return accounting.ActiveProcesses != 0; +} + +static void cbm_win_begin_termination(cbm_subprocess_t *process, uint64_t now) { + if (process->termination_started) { + return; + } + process->termination_started = true; + process->termination_started_ms = now; + /* Best effort only: services and detached parents may have no shared console. + * The Job Object remains the authoritative force-containment boundary. */ + (void)GenerateConsoleCtrlEvent(CTRL_BREAK_EVENT, process->process_id); +} + +static void cbm_win_force_tree(cbm_subprocess_t *process, uint64_t now) { + if (process->force_sent) { + return; + } + if (process->force_started_ms == 0) { + process->force_started_ms = now; + } + if (TerminateJobObject(process->job, 1)) { + if (!process->root_reaped) { + process->root_forced = true; + } + process->result.forced = true; + process->force_sent = true; + } else { + process->containment_failed = true; + } +} + +static void cbm_win_capture_root(cbm_subprocess_t *process, DWORD code) { + process->root_reaped = true; + process->result.exit_code = (int)code; + process->result.term_signal = 0; + if (process->timed_out) { + process->result.outcome = CBM_PROC_HANG; + } else if (process->root_forced || + (atomic_load_explicit(&process->cancellation_requested, memory_order_acquire) && + code == CBM_WIN_CONTROL_C_EXIT)) { + process->result.outcome = CBM_PROC_KILLED; + } else { + process->result.outcome = cbm_proc_classify(true, (int)code, 0, false); + } +} + +static cbm_proc_poll_t cbm_subprocess_poll_win(cbm_subprocess_t *process, cbm_proc_result_t *out) { + cbm_subprocess_poll_log(process); + uint64_t now = cbm_now_ms(); + + if (!process->root_reaped) { + DWORD waited = WaitForSingleObject(process->process, 0); + if (waited == WAIT_OBJECT_0) { + DWORD code = 1; + (void)GetExitCodeProcess(process->process, &code); + cbm_win_capture_root(process, code); + cbm_subprocess_poll_log(process); + } else if (waited == WAIT_FAILED) { + DWORD code = STILL_ACTIVE; + if (GetExitCodeProcess(process->process, &code) && code != STILL_ACTIVE) { + cbm_win_capture_root(process, code); + } else { + cbm_win_begin_termination(process, now); + cbm_win_force_tree(process, now); + } + } + } + + bool job_known = false; + bool job_active = cbm_win_job_active(process, &job_known); + if (!process->termination_started) { + if (atomic_load_explicit(&process->cancellation_requested, memory_order_acquire)) { + cbm_win_begin_termination(process, now); + } else if (!process->root_reaped && process->quiet_timeout_ms > 0 && + now - process->last_activity_ms >= (uint64_t)process->quiet_timeout_ms) { + process->timed_out = true; + cbm_win_begin_termination(process, now); + } else if (process->root_reaped && job_active) { + /* Preserve the root's classification while draining escaped work. */ + cbm_win_begin_termination(process, now); + } + } + if (!job_known) { + cbm_win_begin_termination(process, now); + cbm_win_force_tree(process, now); + } + if (process->termination_started && job_active && !process->force_sent && + now - process->termination_started_ms >= (uint64_t)process->cancel_grace_ms) { + cbm_win_force_tree(process, now); + } + if (process->force_started_ms != 0 && + now - process->force_started_ms >= CBM_SUBPROCESS_FORCE_SETTLE_MS && + (!job_known || job_active || !process->root_reaped)) { + return cbm_subprocess_finish_failed(process, out); + } + if (process->root_reaped && !job_active) { + return cbm_subprocess_finish(process, out); + } + return CBM_PROC_POLL_RUNNING; +} + #else /* POSIX */ -static int cbm_run_posix(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) { +static void cbm_posix_reset_child_signals(void) { + struct sigaction action = {0}; + action.sa_handler = SIG_DFL; + (void)sigemptyset(&action.sa_mask); + for (int sig = 1; sig < NSIG; sig++) { + if (sig != SIGKILL && sig != SIGSTOP) { + (void)sigaction(sig, &action, NULL); + } + } + sigset_t empty; + (void)sigemptyset(&empty); + (void)sigprocmask(SIG_SETMASK, &empty, NULL); +} + +static void cbm_posix_child_exec(cbm_subprocess_t *process, int input, int output, long max_fd) { + if (setpgid(0, 0) < 0) { + _exit(127); + } + cbm_posix_reset_child_signals(); + + /* Never let a worker consume the MCP transport inherited as stdin. Only + * async-signal-safe calls are used between fork and exec. */ + if (input < 0 || output < 0 || dup2(input, STDIN_FILENO) < 0 || + dup2(output, STDOUT_FILENO) < 0 || dup2(output, STDERR_FILENO) < 0) { + _exit(127); + } + if (input > STDERR_FILENO) { + (void)close(input); + } + if (output > STDERR_FILENO) { + (void)close(output); + } + for (int fd = STDERR_FILENO + 1; fd < max_fd; fd++) { + (void)close(fd); + } + execv(process->bin, process->argv); + _exit(127); +} + +static int cbm_posix_fd_at_least_three(int fd) { + if (fd < 0 || fd > STDERR_FILENO) { + return fd; + } +#ifdef F_DUPFD_CLOEXEC + int duplicate = fcntl(fd, F_DUPFD_CLOEXEC, STDERR_FILENO + 1); +#else + int duplicate = fcntl(fd, F_DUPFD, STDERR_FILENO + 1); + if (duplicate >= 0) { + (void)fcntl(duplicate, F_SETFD, FD_CLOEXEC); + } +#endif + (void)close(fd); + return duplicate; +} + +static int cbm_subprocess_spawn_posix(cbm_subprocess_t *process) { + int input_flags = O_RDONLY; +#ifdef O_CLOEXEC + input_flags |= O_CLOEXEC; +#endif + int input = cbm_posix_fd_at_least_three(open("/dev/null", input_flags)); + const char *target = process->log_file ? process->log_file : "/dev/null"; + int output_flags = O_WRONLY | O_CREAT | O_TRUNC; +#ifdef O_CLOEXEC + output_flags |= O_CLOEXEC; +#endif +#ifdef O_NOFOLLOW + output_flags |= O_NOFOLLOW; +#endif + int output = cbm_posix_fd_at_least_three(open(target, output_flags, 0600)); + if (input < 0 || output < 0) { + if (input >= 0) { + (void)close(input); + } + if (output >= 0) { + (void)close(output); + } + return -1; + } + struct stat output_status; + if (fstat(output, &output_status) != 0 || + (process->log_file && !S_ISREG(output_status.st_mode))) { + (void)close(input); + (void)close(output); + return -1; + } + if (process->log_file && fchmod(output, 0600) != 0) { + (void)close(input); + (void)close(output); + return -1; + } + + long max_fd = sysconf(_SC_OPEN_MAX); + if (max_fd < 0 || max_fd > 1048576L) { + max_fd = 65536L; + } + pid_t pid = fork(); if (pid < 0) { - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; + (void)close(input); + (void)close(output); return -1; } if (pid == 0) { - /* Child: redirect stdout+stderr to the log (or discard), then exec. - * Use open()+dup2() (async-signal-safe, no malloc) rather than freopen(): - * the parent may be multithreaded (the MCP server holds worker/watcher/http - * threads plus mimalloc/sqlite global state), and a fork() copies - * only the calling thread — a malloc between fork and exec could deadlock on - * a lock another thread held at fork time. open/dup2/execv touch no heap. */ - const char *bin = opts->bin; - const char *const default_argv[] = {bin, NULL}; - const char *const *argv = opts->argv ? opts->argv : default_argv; - const char *target = opts->log_file ? opts->log_file : "/dev/null"; - int fd = open(target, O_WRONLY | O_CREAT | O_TRUNC, 0644); - if (fd >= 0) { - (void)dup2(fd, STDOUT_FILENO); - (void)dup2(fd, STDERR_FILENO); - if (fd > STDERR_FILENO) { - (void)close(fd); + cbm_posix_child_exec(process, input, output, max_fd); + } + (void)close(input); + (void)close(output); + + /* Parent and child both establish the group, removing scheduler-order races. + * If the child won and already execed, EACCES is accepted only after proving + * that its process group is the expected isolated one. */ + bool contained = setpgid(pid, pid) == 0; + if (!contained && (errno == EACCES || errno == EPERM || errno == ESRCH)) { + contained = getpgid(pid) == pid; + } + if (!contained) { + (void)kill(pid, SIGKILL); + int status = 0; + for (int attempt = 0; attempt < 4; attempt++) { + if (waitpid(pid, &status, 0) >= 0 || errno != EINTR) { + break; } } - execv(bin, (char *const *)argv); - _exit(127); /* exec failed */ + return -1; } - long tail_pos = 0; - uint64_t last_activity = cbm_now_ms(); - bool timed_out = false; - int wstatus = 0; - for (;;) { - pid_t wr; - do { - wr = waitpid(pid, &wstatus, WNOHANG); - } while (wr < 0 && errno == EINTR); - bool done = (wr == pid); - - if (cbm_tail_log(opts->log_file, &tail_pos, opts->on_log_line, opts->log_ud)) { - last_activity = cbm_now_ms(); - } - if (done) { - break; - } - if (opts->quiet_timeout_ms > 0 && - (cbm_now_ms() - last_activity) >= (uint64_t)opts->quiet_timeout_ms) { - kill(pid, SIGKILL); - do { - wr = waitpid(pid, &wstatus, 0); - } while (wr < 0 && errno == EINTR); - timed_out = true; - break; - } - struct timespec ts = {0, 100000000L}; /* 100 ms poll */ - cbm_nanosleep(&ts, NULL); + process->pid = pid; + process->pgid = pid; + return 0; +} + +static bool cbm_posix_group_active(cbm_subprocess_t *process) { + if (kill(-process->pgid, 0) == 0) { + return true; } + return errno != ESRCH; /* EPERM/other errors fail closed as still active */ +} - if (opts->log_file && opts->delete_log_on_exit) { - (void)unlink(opts->log_file); +static void cbm_posix_begin_termination(cbm_subprocess_t *process, uint64_t now) { + if (process->termination_started) { + return; } + process->termination_started = true; + process->termination_started_ms = now; + (void)kill(-process->pgid, SIGTERM); +} - if (WIFEXITED(wstatus)) { - out->exit_code = WEXITSTATUS(wstatus); - out->term_signal = 0; - out->outcome = cbm_proc_classify(true, out->exit_code, 0, timed_out); - } else if (WIFSIGNALED(wstatus)) { - out->exit_code = -1; - out->term_signal = WTERMSIG(wstatus); - out->outcome = cbm_proc_classify(false, -1, out->term_signal, timed_out); +static void cbm_posix_force_tree(cbm_subprocess_t *process, uint64_t now) { + if (process->force_sent) { + return; + } + if (process->force_started_ms == 0) { + process->force_started_ms = now; + } + if (kill(-process->pgid, SIGKILL) == 0) { + process->result.forced = true; + process->force_sent = true; + } else if (errno == ESRCH) { + process->force_sent = true; } else { - out->exit_code = -1; - out->term_signal = 0; - out->outcome = timed_out ? CBM_PROC_HANG : CBM_PROC_KILLED; + process->containment_failed = true; + } +} + +static void cbm_posix_capture_root(cbm_subprocess_t *process, int status) { + process->root_reaped = true; + if (WIFEXITED(status)) { + process->result.exit_code = WEXITSTATUS(status); + process->result.term_signal = 0; + process->result.outcome = + cbm_proc_classify(true, process->result.exit_code, 0, process->timed_out); + } else if (WIFSIGNALED(status)) { + process->result.exit_code = -1; + process->result.term_signal = WTERMSIG(status); + process->result.outcome = + cbm_proc_classify(false, -1, process->result.term_signal, process->timed_out); + } else { + process->result.exit_code = -1; + process->result.term_signal = 0; + process->result.outcome = process->timed_out ? CBM_PROC_HANG : CBM_PROC_KILLED; + } +} + +static cbm_proc_poll_t cbm_subprocess_poll_posix(cbm_subprocess_t *process, + cbm_proc_result_t *out) { + cbm_subprocess_poll_log(process); + uint64_t now = cbm_now_ms(); + + if (!process->root_reaped) { + int status = 0; + pid_t waited = waitpid(process->pid, &status, WNOHANG); + if (waited == process->pid) { + cbm_posix_capture_root(process, status); + cbm_subprocess_poll_log(process); + } else if (waited < 0 && errno != EINTR) { + /* ECHILD means another reaper consumed the status. Other permanent + * wait failures are treated the same: retain containment, stop the + * tree, and never spin forever on the failed wait operation. */ + process->root_reaped = true; + process->result.outcome = process->timed_out ? CBM_PROC_HANG : CBM_PROC_KILLED; + process->result.exit_code = -1; + process->result.term_signal = 0; + cbm_posix_begin_termination(process, now); + } + } + + bool group_active = cbm_posix_group_active(process); + if (!process->termination_started) { + if (atomic_load_explicit(&process->cancellation_requested, memory_order_acquire)) { + cbm_posix_begin_termination(process, now); + } else if (!process->root_reaped && process->quiet_timeout_ms > 0 && + now - process->last_activity_ms >= (uint64_t)process->quiet_timeout_ms) { + process->timed_out = true; + cbm_posix_begin_termination(process, now); + } else if (process->root_reaped && group_active) { + /* A root that daemonizes children is not terminal. Preserve its exit + * classification, but drain the descendants through the same path. */ + cbm_posix_begin_termination(process, now); + } + } + if (process->termination_started && group_active && !process->force_sent && + now - process->termination_started_ms >= (uint64_t)process->cancel_grace_ms) { + cbm_posix_force_tree(process, now); + } + group_active = cbm_posix_group_active(process); + if (process->force_started_ms != 0 && group_active && + now - process->force_started_ms >= CBM_SUBPROCESS_FORCE_SETTLE_MS) { + return cbm_subprocess_finish_failed(process, out); + } + if (process->root_reaped && !group_active) { + return cbm_subprocess_finish(process, out); + } + return CBM_PROC_POLL_RUNNING; +} + +#endif /* _WIN32 */ + +int cbm_subprocess_spawn(const cbm_proc_opts_t *opts, cbm_subprocess_t **out) { + if (!out) { + return -1; + } + *out = NULL; + cbm_subprocess_t *process = cbm_subprocess_copy_opts(opts); + if (!process) { + return -1; + } +#ifdef _WIN32 + int spawn_rc = cbm_subprocess_spawn_win(process); +#else + int spawn_rc = cbm_subprocess_spawn_posix(process); +#endif + if (spawn_rc != 0) { + cbm_subprocess_free_config(process); + return -1; } + process->last_activity_ms = cbm_now_ms(); + *out = process; return 0; } +cbm_proc_poll_t cbm_subprocess_poll(cbm_subprocess_t *process, cbm_proc_result_t *out) { + if (!process) { + return CBM_PROC_POLL_ERROR; + } + if (atomic_load_explicit(&process->terminal, memory_order_acquire)) { + if (out) { + *out = process->result; + } + return CBM_PROC_POLL_TERMINAL; + } +#ifdef _WIN32 + return cbm_subprocess_poll_win(process, out); +#else + return cbm_subprocess_poll_posix(process, out); #endif +} + +bool cbm_subprocess_request_cancel(cbm_subprocess_t *process) { + if (!process || atomic_load_explicit(&process->terminal, memory_order_acquire)) { + return false; + } + atomic_store_explicit(&process->cancellation_requested, true, memory_order_release); + return true; +} + +void cbm_subprocess_destroy(cbm_subprocess_t *process) { + if (!process || !atomic_load_explicit(&process->terminal, memory_order_acquire)) { + return; + } +#ifdef _WIN32 + CloseHandle(process->process); + CloseHandle(process->job); +#endif + cbm_subprocess_free_config(process); +} int cbm_subprocess_run(const cbm_proc_opts_t *opts, cbm_proc_result_t *out) { cbm_proc_result_t local; if (!out) { out = &local; } - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; - if (!opts || !opts->bin || !opts->bin[0]) { + cbm_subprocess_result_init(out); + + cbm_subprocess_t *process = NULL; + if (cbm_subprocess_spawn(opts, &process) != 0) { return -1; } -#ifdef _WIN32 - return cbm_run_win(opts, out); -#else - return cbm_run_posix(opts, out); -#endif + for (;;) { + cbm_proc_poll_t state = cbm_subprocess_poll(process, out); + if (state == CBM_PROC_POLL_TERMINAL) { + bool supervised = out->tree_quiesced && !out->supervision_failed; + cbm_subprocess_destroy(process); + return supervised ? 0 : -1; + } + /* A valid owned handle has no ERROR state; invalid-argument errors are + * rejected before this compatibility loop is entered. */ + const struct timespec delay = {0, 10000000L}; /* 10 ms */ + (void)cbm_nanosleep(&delay, NULL); + } } diff --git a/src/foundation/subprocess.h b/src/foundation/subprocess.h index b2a87637d..266462b68 100644 --- a/src/foundation/subprocess.h +++ b/src/foundation/subprocess.h @@ -36,8 +36,12 @@ typedef enum { typedef struct { cbm_proc_outcome_t outcome; - int exit_code; /* WEXITSTATUS / GetExitCodeProcess; -1 when terminated by a POSIX signal */ - int term_signal; /* WTERMSIG on POSIX; 0 otherwise */ + int exit_code; /* WEXITSTATUS / GetExitCodeProcess; -1 for a POSIX signal */ + int term_signal; /* WTERMSIG on POSIX; 0 otherwise */ + bool cancellation_requested; /* an explicit cancel request was accepted before terminal */ + bool forced; /* force was needed after grace expiry or to reap descendants after root exit */ + bool tree_quiesced; /* the owned process tree has no surviving processes */ + bool supervision_failed; /* the bounded containment deadline expired; tree_quiesced is false */ } cbm_proc_result_t; /* Called for each newly-completed (newline-terminated) log line while the child @@ -53,12 +57,67 @@ typedef struct { void *log_ud; /* user data for on_log_line */ int quiet_timeout_ms; /* <= 0 => no timeout; else kill+HANG after this many * ms with no new completed log line */ + int cancel_grace_ms; /* graceful tree-termination window; <= 0 uses the finite + * CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS */ bool delete_log_on_exit; /* unlink log_file after reaping */ } cbm_proc_opts_t; +#define CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS 1000 +#define CBM_SUBPROCESS_MAX_CANCEL_GRACE_MS 1000 +#define CBM_SUBPROCESS_FORCE_SETTLE_MS 1000 + +/* Opaque, owned supervisor for one child and its contained descendant tree. + * POSIX implementations contain the child in its own process group; Windows + * implementations use a Job Object. The containment is what makes a terminal + * result stronger than merely observing that the direct child exited. */ +typedef struct cbm_subprocess cbm_subprocess_t; + +typedef enum { + CBM_PROC_POLL_ERROR = -1, /* invalid handle/arguments */ + CBM_PROC_POLL_RUNNING = 0, + CBM_PROC_POLL_TERMINAL = 1 +} cbm_proc_poll_t; + +/* Spawn opts->bin and return immediately with a supervisor handle. On success, + * *out owns the process until a terminal poll followed by destroy. Spawn copies + * the option strings/argv it needs after return; log_ud remains caller-owned until + * terminal. Returns 0 on success or -1 on validation/spawn failure (*out == NULL). + * + * A quiet timeout begins at successful spawn and is reset by each completed log + * line. Expiry starts the same graceful->forced tree shutdown as explicit cancel, + * but the terminal outcome remains CBM_PROC_HANG. */ +int cbm_subprocess_spawn(const cbm_proc_opts_t *opts, cbm_subprocess_t **out); + +/* Advance supervision without sleeping or waiting for the child. RUNNING means + * the caller must poll again. TERMINAL is returned only after the direct child is + * reaped AND the entire owned process tree is quiescent; *out is then filled with + * the cached immutable result. Every later poll returns TERMINAL with that same + * result. If an OS containment operation remains failed through the bounded + * force-settle deadline, TERMINAL is still returned but supervision_failed is + * true and tree_quiesced is false; callers must log this as a critical teardown + * failure. *out is optional and is not modified for RUNNING or ERROR. + * + * Poll performs the graceful->force state transition: after explicit cancel (or + * quiet-timeout), it requests graceful termination once, then force-terminates the + * tree when cancel_grace_ms elapses. Callers must keep polling to make progress. */ +cbm_proc_poll_t cbm_subprocess_poll(cbm_subprocess_t *process, cbm_proc_result_t *out); + +/* Record an explicit cancellation request without waiting. Safe to repeat and + * safe to call from a cancellation thread while one owner thread polls. The + * owner must stop cancellation producers before destroying the handle. true + * means the process is live and cancellation is now/already pending; false means + * process is NULL or already terminal. Poll performs signal delivery/escalation. */ +bool cbm_subprocess_request_cancel(cbm_subprocess_t *process); + +/* Release a terminal handle. This never waits or implicitly cancels; passing a + * still-running handle violates the API contract. NULL is a no-op. */ +void cbm_subprocess_destroy(cbm_subprocess_t *process); + /* Spawn opts->bin, supervise (tail + optional quiet-timeout), block until it ends, - * and classify the result into *out. Returns 0 if a child was spawned and reaped - * (out filled), or -1 if the spawn itself failed (out->outcome == CBM_PROC_SPAWN_FAILED). */ + * and classify the result into *out. Compatibility wrapper around spawn + repeated + * poll + bounded sleeps. Returns 0 if a child was spawned and its tree reached + * terminal (out filled), or -1 if spawning/supervision failed + * (out->outcome == CBM_PROC_SPAWN_FAILED). */ int cbm_subprocess_run(const cbm_proc_opts_t *opts, cbm_proc_result_t *out); /* Pure outcome classifier — exposed so the platform-specific exit-code mapping diff --git a/src/main.c b/src/main.c index 01083e809..4f23b0693 100644 --- a/src/main.c +++ b/src/main.c @@ -10,16 +10,19 @@ * --port=N Set HTTP UI port (persisted, default 9749) * --tool-profile=analysis|scout Expose a restricted agent tool surface * - * Signal handling: SIGTERM/SIGINT trigger graceful shutdown. - * Watcher runs in a background thread, polling for git changes. - * HTTP UI server (optional) runs in a background thread on localhost. + * Long-lived MCP and hook frontends are thin clients of one mandatory + * per-account daemon. One-shot CLI tool calls run in an isolated local server + * and never create or retain a daemon generation. */ #include "cbm.h" // cbm_alloc_init — bind 3rd-party allocators to mimalloc before any sqlite/git init +#include "daemon/application.h" +#include "daemon/bootstrap.h" +#include "daemon/frontend.h" +#include "daemon/host.h" +#include "daemon/project_lock.h" +#include "daemon/version_cohort.h" #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 "foundation/constants.h" @@ -30,6 +33,16 @@ enum { MAIN_FLAG_OFF = 5, /* strlen("--ui=") */ MAIN_PORT_OFF = 7, /* strlen("--port=") */ MAIN_MAX_PORT = 65536, + MAIN_PATH_CAP = 4096, + MAIN_CONNECT_TIMEOUT_MS = 1000, + MAIN_STARTUP_TIMEOUT_MS = 10000, + MAIN_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000, + MAIN_HOOK_CONNECT_TIMEOUT_MS = 250, + MAIN_HOOK_STARTUP_TIMEOUT_MS = 1500, + MAIN_HOOK_REQUEST_TIMEOUT_MS = 1500, + MAIN_HOOK_CLOSE_TIMEOUT_MS = 250, + MAIN_CLOSE_TIMEOUT_MS = 5000, + MAIN_COORDINATION_CLEANUP_MS = 500, PARENT_WATCHDOG_STACK_SIZE = 64 * CBM_SZ_1K, /* watchdog only polls — tiny stack suffices */ }; #define SLEN(s) (sizeof(s) - 1) @@ -44,18 +57,21 @@ enum { #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 #endif -#include "ui/config.h" #include "ui/http_server.h" #include "ui/embedded_assets.h" #include +#include #include #include #include #include #include #ifndef _WIN32 +#include +#include #include #endif @@ -65,39 +81,248 @@ enum { /* ── Globals for signal handling ────────────────────────────────── */ -static cbm_watcher_t *g_watcher = NULL; -static cbm_mcp_server_t *g_server = NULL; -static cbm_http_server_t *g_http_server = NULL; static atomic_int g_shutdown = 0; +static cbm_daemon_runtime_client_t *g_daemon_client = NULL; -/* 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 */ +static uint64_t main_deadline_after(uint32_t timeout_ms); + +static bool main_session_context(const char *preferred_root, + char root_out[MAIN_PATH_CAP], + char allowed_out[MAIN_PATH_CAP], + const char **allowed_out_ptr); + +typedef struct main_local_cli_lease main_local_cli_lease_t; + +struct main_local_cli_lease { + char *project; + cbm_project_lock_lease_t *lease; + main_local_cli_lease_t *next; +}; + +typedef struct { + cbm_project_lock_manager_t *manager; + main_local_cli_lease_t *leases; + FILE *feedback; + bool index_worker; + bool waiting_reported; +} main_local_cli_mutation_t; + +typedef struct { + cbm_mutex_t mutex; + cbm_mcp_server_t *server; +} main_local_maintenance_context_t; + +static void main_local_maintenance_context_init( + main_local_maintenance_context_t *context) { + memset(context, 0, sizeof(*context)); + cbm_mutex_init(&context->mutex); +} + +static void main_local_maintenance_context_destroy( + main_local_maintenance_context_t *context) { + cbm_mutex_destroy(&context->mutex); + memset(context, 0, sizeof(*context)); +} + +static void main_local_maintenance_server_bind( + main_local_maintenance_context_t *context, cbm_mcp_server_t *server) { + if (!context) { + return; + } + cbm_mutex_lock(&context->mutex); + context->server = server; + cbm_mutex_unlock(&context->mutex); +} + +static bool main_local_command_cancel(void *opaque) { + main_local_maintenance_context_t *context = opaque; + if (!context) { + return false; + } + cbm_mutex_lock(&context->mutex); + bool cancelled = context->server && + cbm_mcp_server_cancel_active(context->server); + cbm_mutex_unlock(&context->mutex); + return cancelled; +} + +static void main_local_maintenance_finish( + cbm_daemon_maintenance_monitor_t **monitor, + main_local_maintenance_context_t *context, bool context_initialized, + const char *participant) { + if (monitor && *monitor && + !cbm_daemon_maintenance_monitor_stop(monitor)) { + /* The observer still borrows context (and may be inside cancellation). + * Freeing command/server/manager memory would be a cross-thread UAF. */ + cbm_log_error("participant.maintenance_join_failed", "participant", + participant, "action", "process_exit"); + (void)fflush(stdout); + (void)fflush(stderr); + _Exit(EXIT_FAILURE); } + if (context_initialized) { + main_local_maintenance_context_destroy(context); + } +} - /* 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); +static _Noreturn void main_coordination_cleanup_fail_stop( + const char *component) { + cbm_log_error("coordination.cleanup_timeout", "component", component, + "action", "process_exit"); + (void)fprintf(stderr, + "codebase-memory-mcp: coordination cleanup timed out (%s); " + "terminating so the OS releases retained claims\n", + component ? component : "unknown"); + (void)fflush(stdout); + (void)fflush(stderr); + _Exit(EXIT_FAILURE); +} + +static void main_project_lock_release_fully(cbm_project_lock_lease_t **lease) { + uint64_t deadline = + main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); + while (lease && *lease) { + (void)cbm_project_lock_lease_release(lease); + if (!*lease) { + return; } + if (cbm_now_ms() >= deadline) { + main_coordination_cleanup_fail_stop("project_lock_cleanup"); + } + cbm_usleep(1000); } - /* Release pipeline lock to prevent stale lock on restart */ - cbm_pipeline_unlock(); +} - if (g_watcher) { - cbm_watcher_stop(g_watcher); +/* Test-only ownership proof for the real-binary POSIX smoke. The environment + * variable is otherwise inert, and only a supervised physical worker may + * publish it. Publication occurs after the native project lease is acquired, + * so a marker from the worker also proves that its polling supervisor did not + * retain the same exclusive lease. */ +static bool main_test_worker_project_lock_marker( + const main_local_cli_mutation_t *mutation) { +#ifdef _WIN32 + (void)mutation; + return true; +#else + if (!mutation || !mutation->index_worker) { + return true; + } + char marker_path[MAIN_PATH_CAP] = {0}; + if (!cbm_safe_getenv("CBM_TEST_WORKER_PROJECT_LOCK_PID_FILE", + marker_path, sizeof(marker_path), NULL) || + !marker_path[0]) { + return true; } - if (g_http_server) { - cbm_http_server_stop(g_http_server); + int flags = O_WRONLY | O_CREAT | O_EXCL; +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif + int marker = open(marker_path, flags, 0600); + if (marker < 0) { + return false; } - /* Close stdin to unblock getline in the MCP server loop */ - (void)fclose(stdin); + char identity[96]; + int length = snprintf(identity, sizeof(identity), "%ld %ld\n", + (long)getpid(), (long)getpgrp()); + bool written = length > 0 && length < (int)sizeof(identity) && + write(marker, identity, (size_t)length) == + (ssize_t)length; + return close(marker) == 0 && written; +#endif +} + +static bool main_local_cli_mutation_begin(void *context, const char *project) { + main_local_cli_mutation_t *mutation = context; + if (!mutation || !mutation->manager || !project || !project[0]) { + return false; + } + for (;;) { + uint64_t now = cbm_now_ms(); + uint64_t deadline = now > UINT64_MAX - 100U ? UINT64_MAX : now + 100U; + cbm_project_lock_lease_t *lease = NULL; + cbm_private_file_lock_status_t status = cbm_project_lock_acquire( + mutation->manager, project, deadline, NULL, &lease); + if (status == CBM_PRIVATE_FILE_LOCK_OK && lease) { + main_local_cli_lease_t *held = calloc(1, sizeof(*held)); + if (held) { + held->project = cbm_strdup(project); + } + if (!held || !held->project) { + free(held); + main_project_lock_release_fully(&lease); + return false; + } + held->lease = lease; + held->next = mutation->leases; + mutation->leases = held; + if (!main_test_worker_project_lock_marker(mutation)) { + mutation->leases = held->next; + main_project_lock_release_fully(&held->lease); + free(held->project); + free(held); + return false; + } + return true; + } + main_project_lock_release_fully(&lease); + if (status != CBM_PRIVATE_FILE_LOCK_BUSY) { + cbm_log_error("cli.project_lock_failed", "project", project, + "action", "refuse_mutation"); + return false; + } + if (mutation->feedback && !mutation->waiting_reported) { + (void)fprintf(mutation->feedback, + "Waiting for another CBM mutation of %s...\n", project); + (void)fflush(mutation->feedback); + mutation->waiting_reported = true; + } + } +} + +static void main_local_cli_mutation_end(void *context, const char *project) { + main_local_cli_mutation_t *mutation = context; + if (!mutation || !project) { + return; + } + main_local_cli_lease_t **cursor = &mutation->leases; + while (*cursor && strcmp((*cursor)->project, project) != 0) { + cursor = &(*cursor)->next; + } + main_local_cli_lease_t *held = *cursor; + if (held) { + *cursor = held->next; + main_project_lock_release_fully(&held->lease); + free(held->project); + free(held); + } +} + +static void main_local_cli_mutation_release_all( + main_local_cli_mutation_t *mutation) { + while (mutation && mutation->leases) { + main_local_cli_lease_t *held = mutation->leases; + mutation->leases = held->next; + main_project_lock_release_fully(&held->lease); + free(held->project); + free(held); + } +} + +/* Signal handlers only publish intent and close stdin. The daemon host observes + * the atomic; an MCP thin client unblocks its reader and closes its authenticated + * daemon connection from normal thread context. */ +static void request_shutdown(void) { + if (atomic_exchange(&g_shutdown, 1)) { + return; /* already shutting down */ + } +#ifdef _WIN32 + (void)_close(_fileno(stdin)); +#else + (void)close(STDIN_FILENO); +#endif } static void signal_handler(int sig) { @@ -116,8 +341,14 @@ static void signal_handler(int sig) { * and we shut down. Windows is unaffected (job objects handle this) — #ifndef. */ #ifndef _WIN32 +typedef struct { + pid_t initial_ppid; + bool kill_worker_group; + bool exit_on_parent_death; +} parent_watchdog_config_t; + static void *parent_watchdog_thread(void *arg) { - pid_t initial_ppid = *(pid_t *)arg; + parent_watchdog_config_t config = *(parent_watchdog_config_t *)arg; const unsigned int poll_interval_us = 500000; /* 500ms */ while (!atomic_load(&g_shutdown)) { @@ -127,83 +358,108 @@ static void *parent_watchdog_thread(void *arg) { } /* 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) { + if (config.initial_ppid > 1 && getppid() != config.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); + if (config.kill_worker_group) { + /* Valid workers establish pgid == pid before any stateful work. + * SIGKILL is deliberate: no non-escaped descendant may continue + * after the owning daemon disappears. */ + (void)kill(-getpid(), SIGKILL); + } + if (config.exit_on_parent_death) { + /* Kernel EOF on every inherited daemon socket is the most + * reliable cancellation signal when an agent disappears. */ + _exit(0); + } + request_shutdown(); + break; } } return NULL; } -#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; -} -/* ── HTTP UI background thread ──────────────────────────────────── */ - -static void *http_thread(void *arg) { - cbm_http_server_t *srv = arg; - cbm_http_server_run(srv); - return NULL; +static bool worker_prepare_process_group(void) { + pid_t process_id = getpid(); + return (setpgid(0, 0) == 0 || getpgrp() == process_id) && getpgrp() == process_id; } -/* ── 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; +/* Test-only crash-orphan probe used by tests/test_worker_watchdog.sh. It is + * created before the watchdog thread so fork never occurs in a multithreaded + * worker, and inherits the worker's isolated process group. */ +static bool worker_start_watchdog_test_descendant(void) { + char pid_path[CBM_SZ_4K] = {0}; + if (!cbm_safe_getenv("CBM_TEST_WORKER_DESCENDANT_PID_FILE", pid_path, sizeof(pid_path), NULL) || + !pid_path[0]) { + return true; } - - /* Non-blocking: skip if another pipeline is already running. The - * positive return keeps the watcher's baselines uncommitted so the - * change is retried on the next poll cycle (5-60s) instead of being - * recorded as seen and lost (#937). */ - if (!cbm_pipeline_try_lock()) { - cbm_log_info("watcher.skip", "project", project_name, "reason", "pipeline_busy"); - return 1; + pid_t descendant = fork(); + if (descendant < 0) { + return false; } - - 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. The pipeline lock (already held) still serialises re-indexes. - * 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_pipeline_unlock(); - return 0; + if (descendant == 0) { + (void)signal(SIGTERM, SIG_IGN); + for (;;) { + cbm_usleep(100000); } - /* resp == NULL → spawn-failure degrade → fall through to in-process. */ } + int open_flags = O_WRONLY | O_CREAT | O_EXCL; +#ifdef O_NOFOLLOW + open_flags |= O_NOFOLLOW; +#endif + int pid_file = open(pid_path, open_flags, 0600); + if (pid_file < 0) { + (void)kill(descendant, SIGKILL); + (void)waitpid(descendant, NULL, 0); + return false; + } + char pid_text[32]; + int pid_length = snprintf(pid_text, sizeof(pid_text), "%ld\n", (long)descendant); + bool written = pid_length > 0 && pid_length < (int)sizeof(pid_text) && + write(pid_file, pid_text, (size_t)pid_length) == (ssize_t)pid_length; + written = close(pid_file) == 0 && written; + if (!written) { + (void)unlink(pid_path); + (void)kill(descendant, SIGKILL); + (void)waitpid(descendant, NULL, 0); + } + return written; +} - cbm_pipeline_t *p = cbm_pipeline_new(root_path, NULL, CBM_MODE_FULL); - if (!p) { - cbm_pipeline_unlock(); - return CBM_NOT_FOUND; +static bool worker_start_parent_watchdog(pid_t initial_ppid) { + static parent_watchdog_config_t worker_config; + worker_config.initial_ppid = initial_ppid; + worker_config.kill_worker_group = true; + worker_config.exit_on_parent_death = true; + cbm_thread_t worker_watchdog_tid; + if (cbm_thread_create(&worker_watchdog_tid, PARENT_WATCHDOG_STACK_SIZE, + parent_watchdog_thread, &worker_config) != 0) { + return false; } + return cbm_thread_detach(&worker_watchdog_tid) == 0; +} - int rc = cbm_pipeline_run(p); - cbm_pipeline_free(p); - cbm_pipeline_unlock(); - return rc; +static bool client_start_parent_watchdog(pid_t initial_ppid) { + if (initial_ppid <= 1) { + return true; + } + static parent_watchdog_config_t client_config; + client_config.initial_ppid = initial_ppid; + client_config.kill_worker_group = false; + client_config.exit_on_parent_death = true; + cbm_thread_t watchdog; + if (cbm_thread_create(&watchdog, PARENT_WATCHDOG_STACK_SIZE, + parent_watchdog_thread, &client_config) != 0) { + return false; + } + if (cbm_thread_detach(&watchdog) != 0) { + atomic_store(&g_shutdown, 1); + (void)cbm_thread_join(&watchdog); + return false; + } + return true; } +#endif /* ── CLI mode ───────────────────────────────────────────────────── */ @@ -333,13 +589,25 @@ static bool cli_first_nonspace_is_brace(const char *s) { return *s == '{'; } -static int run_cli(int argc, char **argv) { +static void cli_progress_worker_log(const char *line, void *context) { + (void)context; + cbm_progress_sink_fn(line); +} + +static int run_cli(int argc, char **argv, + cbm_project_lock_manager_t *project_locks, + main_local_maintenance_context_t *maintenance_context) { + if (argc == 1 && argv && + (strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0)) { + (void)fputs(CLI_USAGE, stdout); + return 0; + } if (argc < MAIN_MIN_ARGC) { (void)fprintf(stderr, CLI_USAGE); return SKIP_ONE; } - bool progress = cli_strip_flag(&argc, argv, "--progress"); + bool progress_requested = 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 @@ -347,31 +615,16 @@ static int run_cli(int argc, char **argv) { * 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"); + (void)cli_strip_flag_value(&argc, argv, CBM_INDEX_WORKER_BUILD_ARG); 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 + (void)cli_strip_flag_value(&argc, argv, CBM_INDEX_WORKER_MEMORY_BUDGET_ARG); + bool worker_single_thread = cli_strip_flag(&argc, argv, CBM_INDEX_WORKER_SINGLE_THREAD_ARG); + const char *worker_marker = cli_strip_flag_value(&argc, argv, CBM_INDEX_WORKER_MARKER_ARG); + const char *worker_quarantine = + cli_strip_flag_value(&argc, argv, CBM_INDEX_WORKER_QUARANTINE_ARG); + cbm_index_set_worker_role_options(index_worker, response_out, worker_single_thread, + worker_marker, worker_quarantine, + cbm_index_worker_memory_budget_bytes()); if (argc < MAIN_MIN_ARGC) { (void)fprintf(stderr, CLI_USAGE); @@ -450,23 +703,101 @@ static int run_cli(int argc, char **argv) { } } + bool progress = !index_worker && cbm_cli_progress_enabled( + progress_requested, cli_isatty(2) != 0); + uint64_t progress_started_ms = cbm_now_ms(); if (progress) { cbm_progress_sink_init(stderr); + cbm_cli_progress_start(stderr, tool_name); + } + + if (!index_worker && strcmp(tool_name, "index_repository") == 0) { + char self_path[MAIN_PATH_CAP] = {0}; + if (!cbm_http_server_resolve_binary_path(NULL, self_path, + sizeof(self_path)) || + !cbm_index_supervisor_capture_build_fingerprint()) { + (void)fprintf(stderr, + "error: exact CLI worker identity could not be verified\n"); + if (progress) { + cbm_progress_sink_fini(); + cbm_cli_progress_finish(stderr, tool_name, false, + cbm_now_ms() - progress_started_ms); + } + free(heap_args); + return SKIP_ONE; + } + cbm_http_server_set_binary_path(self_path); + cbm_index_supervisor_mark_host(); } cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - if (!srv) { - (void)fprintf(stderr, "error: failed to create server\n"); + char *result = NULL; + main_local_cli_mutation_t mutation = { + .manager = project_locks, + .feedback = progress ? stderr : NULL, + .index_worker = index_worker, + }; + if (srv) { + /* Both the one-shot parent and its in-process worker are standalone + * instances: neither may launch MCP-session background tasks. The + * worker receives project_locks from its own process-level + * coordination setup and therefore owns the mutation lease while it + * performs the physical write. */ + cbm_mcp_server_set_background_tasks(srv, false); + if (project_locks) { + cbm_mcp_server_set_project_mutation_guard( + srv, main_local_cli_mutation_begin, + main_local_cli_mutation_end, &mutation); + } + } + if (srv && !index_worker) { + char session_root[MAIN_PATH_CAP]; + char allowed_root[MAIN_PATH_CAP]; + const char *allowed_root_ptr = NULL; + if (progress) { + cbm_mcp_server_set_index_log_callback(srv, cli_progress_worker_log, + NULL); + } + if (!main_session_context(NULL, session_root, allowed_root, + &allowed_root_ptr) || + !cbm_mcp_server_set_session_context(srv, session_root, + allowed_root_ptr)) { + cbm_mcp_server_free(srv); + srv = NULL; + } + } + bool maintenance_binding_failed = srv && !maintenance_context; + if (srv && maintenance_context) { + main_local_maintenance_server_bind(maintenance_context, srv); + result = cbm_mcp_handle_tool(srv, tool_name, args_json); + /* Unbind under the same mutex used by cancellation before any server + * teardown. The process-level monitor remains active across all + * parsing and cleanup, but can no longer race a freed server. */ + main_local_maintenance_server_bind(maintenance_context, NULL); + } + if (!result) { + if (maintenance_binding_failed) { + (void)fprintf( + stderr, + "error: local %s maintenance cancellation could not bind safely\n", + index_worker ? "worker" : "CLI"); + } else { + (void)fprintf(stderr, "error: failed to run local %s server\n", + index_worker ? "worker" : "CLI"); + } + cbm_mcp_server_free(srv); + main_local_cli_mutation_release_all(&mutation); if (progress) { cbm_progress_sink_fini(); + cbm_cli_progress_finish(stderr, tool_name, false, + cbm_now_ms() - progress_started_ms); } + free(heap_args); 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(); @@ -479,6 +810,10 @@ static int run_cli(int argc, char **argv) { } if (raw_json) { printf("%s\n", result); + /* Raw JSON changes presentation only. Preserve a failing process + * status for MCP tool errors so scripts and activation-driven + * cancellation cannot be reported as successful work. */ + exit_code = cbm_cli_mcp_result_is_error(result) ? SKIP_ONE : 0; } else { exit_code = cli_print_mcp_result(result); } @@ -496,8 +831,11 @@ static int run_cli(int argc, char **argv) { } cbm_mcp_server_free(srv); + main_local_cli_mutation_release_all(&mutation); if (progress) { cbm_progress_sink_fini(); + cbm_cli_progress_finish(stderr, tool_name, exit_code == 0, + cbm_now_ms() - progress_started_ms); } free(heap_args); return exit_code; @@ -509,8 +847,10 @@ 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]\n"); + printf(" codebase-memory-mcp cli [--progress] [--json] [args]\n"); + printf(" Run one tool locally, then exit\n"); + printf(" codebase-memory-mcp install [-y|-n] [--force] [--dry-run] " + "[--dir=] [--skip-config]\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"); @@ -546,7 +886,10 @@ static void print_help(void) { /* 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) { +static int handle_subcommand(int argc, char **argv, + cbm_project_lock_manager_t *project_locks, + main_local_maintenance_context_t + *maintenance_context) { /* First scan: global flags */ for (int i = SKIP_ONE; i < argc; i++) { if (strcmp(argv[i], "--profile") == 0) { @@ -563,8 +906,10 @@ static int handle_subcommand(int argc, char **argv) { 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); + cbm_mem_init_with_cap(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram), + cbm_index_worker_memory_budget_bytes()); + return run_cli(argc - i - SKIP_ONE, argv + i + SKIP_ONE, + project_locks, maintenance_context); } if (strcmp(argv[i], "hook-augment") == 0) { cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram)); @@ -586,26 +931,31 @@ static int handle_subcommand(int argc, char **argv) { return CBM_NOT_FOUND; } -/* Parse --ui= and --port= flags. Returns true if config was modified. */ -static bool parse_ui_flags(int argc, char **argv, cbm_ui_config_t *cfg, bool *explicit_enable) { - bool changed = false; +/* Parse --ui= and --port= into a per-field daemon mutation. */ +static uint8_t parse_ui_flags(int argc, char **argv, bool *ui_enabled, + int *ui_port, bool *explicit_enable) { + uint8_t update_mask = 0; for (int i = SKIP_ONE; i < argc; i++) { if (strncmp(argv[i], "--ui=", SLEN("--ui=")) == 0) { - cfg->ui_enabled = (strcmp(argv[i] + MAIN_FLAG_OFF, "true") == 0); - if (explicit_enable && cfg->ui_enabled) { + *ui_enabled = strcmp(argv[i] + MAIN_FLAG_OFF, "true") == 0; + if (explicit_enable && *ui_enabled) { *explicit_enable = true; } - changed = true; + update_mask |= CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED; } if (strncmp(argv[i], "--port=", SLEN("--port=")) == 0) { - int p = (int)strtol(argv[i] + MAIN_PORT_OFF, NULL, CBM_DECIMAL_BASE); - if (p > 0 && p < MAIN_MAX_PORT) { - cfg->ui_port = p; - changed = true; + const char *value = argv[i] + MAIN_PORT_OFF; + char *end = NULL; + errno = 0; + long port = strtol(value, &end, CBM_DECIMAL_BASE); + if (errno == 0 && end != value && end && *end == '\0' && + port > 0 && port < MAIN_MAX_PORT) { + *ui_port = (int)port; + update_mask |= CBM_DAEMON_APPLICATION_UI_CONFIG_PORT; } } } - return changed; + return update_mask; } /* Install platform-specific signal handlers. */ @@ -665,19 +1015,284 @@ static char **cbm_win_utf8_argv(int *out_argc) { } #endif /* _WIN32 */ +static bool main_resolve_executable(const char *argv0, + char out[MAIN_PATH_CAP]) { + char resolved[MAIN_PATH_CAP]; + return cbm_http_server_resolve_binary_path(argv0, resolved, + sizeof(resolved)) && + cbm_canonical_path(resolved, out, MAIN_PATH_CAP); +} + +static bool main_build_identity(cbm_daemon_build_identity_t *identity) { + if (!identity || !cbm_index_supervisor_capture_build_fingerprint()) { + return false; + } + const char *fingerprint = cbm_index_supervisor_build_fingerprint(); + if (!fingerprint) { + return false; + } + *identity = (cbm_daemon_build_identity_t){ + .semantic_version = CBM_VERSION, + .build_fingerprint = fingerprint, + .protocol_abi = CBM_DAEMON_RUNTIME_WIRE_ABI, + .store_abi = 1, + .feature_abi = 1, + }; + return true; +} + +static uint64_t main_deadline_after(uint32_t timeout_ms) { + uint64_t now_ms = cbm_now_ms(); + return now_ms > UINT64_MAX - timeout_ms ? UINT64_MAX + : now_ms + timeout_ms; +} + +static bool main_local_cli_feedback_enabled(int argc, char **argv) { + bool requested = false; + for (int index = 1; index < argc; index++) { + if (argv[index] && strcmp(argv[index], "--progress") == 0) { + requested = true; + break; + } + } + return cbm_cli_progress_enabled(requested, cli_isatty(2) != 0); +} + +static int main_local_transition_acquire( + const cbm_daemon_ipc_endpoint_t *endpoint, FILE *feedback, + cbm_daemon_ipc_local_transition_t **transition_out) { + uint64_t deadline = main_deadline_after(MAIN_STARTUP_TIMEOUT_MS); + bool waiting_reported = false; + for (;;) { + int status = cbm_daemon_ipc_local_transition_try_acquire( + endpoint, transition_out); + if (status != 0 || cbm_now_ms() >= deadline) { + return status; + } + if (feedback && !waiting_reported) { + (void)fputs("Waiting for CBM startup coordination...\n", feedback); + (void)fflush(feedback); + waiting_reported = true; + } + cbm_usleep(10000); + } +} + +static bool main_version_cohort_close( + cbm_version_cohort_lease_t **lease, + cbm_version_cohort_manager_t **manager) { + bool ok = true; + uint64_t deadline = + main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); + while (lease && *lease) { + cbm_private_file_lock_status_t status = + cbm_version_cohort_lease_release(lease); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + ok = false; + } + if (*lease) { + if (cbm_now_ms() >= deadline) { + main_coordination_cleanup_fail_stop( + "cohort_lease_cleanup"); + } + cbm_usleep(1000); + } + } + deadline = main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); + while (manager && *manager) { + cbm_private_file_lock_status_t status = + cbm_version_cohort_manager_free(manager); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + ok = false; + } + if (*manager) { + if (cbm_now_ms() >= deadline) { + main_coordination_cleanup_fail_stop( + "cohort_manager_cleanup"); + } + cbm_usleep(1000); + } + } + return ok; +} + +static bool main_project_lock_manager_close( + cbm_project_lock_manager_t **manager) { + bool ok = true; + uint64_t deadline = + main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); + while (manager && *manager) { + cbm_private_file_lock_status_t status = + cbm_project_lock_manager_free(manager); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + ok = false; + } + if (*manager) { + if (cbm_now_ms() >= deadline) { + main_coordination_cleanup_fail_stop( + "project_lock_manager_cleanup"); + } + cbm_usleep(1000); + } + } + return ok; +} + +static bool main_local_transition_close( + cbm_daemon_ipc_local_transition_t **transition) { + bool ok = true; + uint64_t deadline = + main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); + while (transition && *transition) { + bool released = + cbm_daemon_ipc_local_transition_release(transition); + if (!released) { + ok = false; + } + if (*transition) { + if (cbm_now_ms() >= deadline) { + main_coordination_cleanup_fail_stop( + "local_transition_cleanup"); + } + cbm_usleep(1000); + } + } + return ok; +} + +static bool main_session_context(const char *preferred_root, + char root_out[MAIN_PATH_CAP], + char allowed_out[MAIN_PATH_CAP], + const char **allowed_out_ptr) { + const char *root = preferred_root && preferred_root[0] ? preferred_root : "."; + if (!cbm_canonical_path(root, root_out, MAIN_PATH_CAP)) { + return false; + } + char configured[MAIN_PATH_CAP]; + const char *allowed = cbm_safe_getenv("CBM_ALLOWED_ROOT", configured, + sizeof(configured), NULL); + if (allowed && allowed[0]) { + if (!cbm_canonical_path(allowed, allowed_out, MAIN_PATH_CAP)) { + return false; + } + *allowed_out_ptr = allowed_out; + } else { + allowed_out[0] = '\0'; + *allowed_out_ptr = NULL; + } + return true; +} + +static bool main_set_client_context(cbm_daemon_runtime_client_t *client, + const char *preferred_root, + cbm_mcp_tool_profile_t tool_profile, + const char *hook_event, + const char *hook_dialect, + uint32_t timeout_ms) { + char root[MAIN_PATH_CAP]; + char allowed[MAIN_PATH_CAP]; + const char *allowed_ptr = NULL; + if (!main_session_context(preferred_root, root, allowed, &allowed_ptr)) { + return false; + } + return cbm_daemon_application_client_set_context( + client, root, allowed_ptr, tool_profile, hook_event, + hook_dialect, timeout_ms) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; +} + +static char *main_hook_cwd(const char *input_json) { + if (!input_json) { + return NULL; + } + yyjson_doc *document = yyjson_read(input_json, strlen(input_json), 0); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + yyjson_val *cwd_value = yyjson_is_obj(root) ? yyjson_obj_get(root, "cwd") : NULL; + const char *cwd = yyjson_is_str(cwd_value) ? yyjson_get_str(cwd_value) : NULL; + char *copy = NULL; + if (cwd && cbm_hook_path_is_abs(cwd)) { + size_t length = strlen(cwd); + copy = malloc(length + 1U); + if (copy) { + memcpy(copy, cwd, length + 1U); + } + } + if (document) { + yyjson_doc_free(document); + } + return copy; +} + +static bool main_hook_options(int argc, char **argv, + const char **event_out, + const char **dialect_out) { + if (!argv || !event_out || !dialect_out) { + return false; + } + *event_out = NULL; + *dialect_out = NULL; + int hook_index = -1; + for (int index = 1; index < argc; index++) { + if (argv[index] && strcmp(argv[index], "hook-augment") == 0) { + hook_index = index; + break; + } + } + if (hook_index < 0) { + return false; + } + for (int index = hook_index + 1; index < argc; index++) { + if (strcmp(argv[index], "--event") == 0 && index + 1 < argc) { + *event_out = argv[++index]; + } else if (strcmp(argv[index], "--dialect") == 0 && + index + 1 < argc) { + *dialect_out = argv[++index]; + } else { + return false; + } + } + return cbm_hook_augment_invocation_supported(*event_out, *dialect_out); +} + +static int main_run_hook_frontend(cbm_daemon_runtime_client_t *client, + const char *hook_event, + const char *hook_dialect) { + char *input = cbm_hook_augment_read_stdin(); + if (!input) { + return 0; + } + char *hook_cwd = main_hook_cwd(input); + bool context_set = main_set_client_context( + client, hook_cwd, CBM_MCP_TOOL_PROFILE_ALL, hook_event, + hook_dialect, MAIN_HOOK_CONNECT_TIMEOUT_MS); + free(hook_cwd); + if (!context_set) { + free(input); + return 0; + } + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t status = + cbm_daemon_application_client_hook_augment( + client, input, &response, &response_length, + MAIN_HOOK_REQUEST_TIMEOUT_MS); + free(input); + if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response && + response_length > 0) { + (void)fwrite(response, 1, response_length, stdout); + (void)fflush(stdout); + } + free(response); + return 0; /* hooks always fail open */ +} + 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. */ + /* Must remain the first statement: see allocator binding contract above. */ cbm_alloc_init(); +#ifndef _WIN32 + pid_t process_initial_ppid = getppid(); +#endif #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); @@ -687,179 +1302,522 @@ int main(int argc, char **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_daemon_process_role_t role = cbm_daemon_process_role(argc, argv); + if (role == CBM_DAEMON_PROCESS_INVALID) { + (void)fprintf(stderr, + "codebase-memory-mcp: invalid internal process arguments\n"); + return EXIT_FAILURE; + } + 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_profile_init(); cbm_log_init_from_env(); - int subcmd = handle_subcommand(argc, argv); - if (subcmd >= 0) { - return subcmd; - } + cbm_mcp_tool_profile_t tool_profile = CBM_MCP_TOOL_PROFILE_ALL; - if (cbm_mcp_parse_tool_profile_args(argc, (const char *const *)argv, &tool_profile) != 0) { - (void)fprintf(stderr, "codebase-memory-mcp: --tool-profile requires the supported value " - "'analysis' or 'scout'\n"); + if (role == CBM_DAEMON_PROCESS_MCP_CLIENT && + cbm_mcp_parse_tool_profile_args( + argc, (const char *const *)argv, &tool_profile) != 0) { + (void)fprintf( + stderr, + "codebase-memory-mcp: --tool-profile requires the supported value 'analysis' or 'scout'\n"); return 2; } - bool restricted_tool_profile = tool_profile != CBM_MCP_TOOL_PROFILE_ALL; + const char *hook_event = NULL; + const char *hook_dialect = NULL; + if (role == CBM_DAEMON_PROCESS_HOOK_CLIENT && + !main_hook_options(argc, argv, &hook_event, &hook_dialect)) { + return EXIT_SUCCESS; /* hook adapters are contractually fail-open */ + } - /* 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. */ + /* Hook augmentation is contractually fail-open and time-bounded. Arm its + * deadline before executable hashing, daemon startup, and IPC connection; + * arming only inside the request frontend leaves bootstrap outside the + * guard. Windows is the exception: its fixed 300 ms budget covers only + * the post-authentication hook operation because a cold daemon bootstrap + * has its own finite 1500 ms bound and cannot fit that budget. */ + if (role == CBM_DAEMON_PROCESS_HOOK_CLIENT) { #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"); - } + cbm_hook_augment_arm_deadline(); #endif + } - /* Default: MCP server on stdio */ - cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram)); - /* Store binary path for subprocess spawning + hook log sink */ - cbm_http_server_set_binary_path(argv[0]); - cbm_log_set_sink_ex(cbm_ui_log_append, CBM_LOG_SINK_TEE); - cbm_log_info("server.start", "version", CBM_VERSION); - cbm_diag_start(); /* starts if CBM_DIAGNOSTICS=1 */ - - /* Parse --ui and --port flags (persisted config) */ - cbm_ui_config_t ui_cfg; - cbm_ui_config_load(&ui_cfg); - bool explicit_ui_enable = false; - if (!restricted_tool_profile && parse_ui_flags(argc, argv, &ui_cfg, &explicit_ui_enable)) { - cbm_ui_config_save(&ui_cfg); - } - /* If the user explicitly asked for the UI but this binary has no embedded - * frontend, the HTTP server can never start (see below). The warning that - * covers this goes to the log sink, which a user running `--ui=true` on a - * terminal won't see — so tell them plainly on stderr why nothing happens - * and which build to use (#350). */ - if (explicit_ui_enable && CBM_EMBEDDED_FILE_COUNT == 0) { - (void)fprintf(stderr, - "codebase-memory-mcp: --ui requested, but this binary was built without the " - "embedded UI, so the HTTP server will not start.\n" - "Use the UI release asset (codebase-memory-mcp-ui) or rebuild with: " - "make -f Makefile.cbm cbm-with-ui\n"); + if (role == CBM_DAEMON_PROCESS_STATELESS) { + int result = handle_subcommand(argc, argv, NULL, NULL); + return result >= 0 ? result : EXIT_FAILURE; } - setup_signal_handlers(); + if (role == CBM_DAEMON_PROCESS_LOCAL_CLI) { + bool feedback_enabled = main_local_cli_feedback_enabled(argc, argv); + FILE *feedback = feedback_enabled ? stderr : NULL; + if (feedback) { + (void)fputs("Preparing one-shot local CBM command...\n", feedback); + (void)fflush(feedback); + } + cbm_daemon_ipc_endpoint_t *local_endpoint = + cbm_daemon_bootstrap_endpoint_new(NULL); + char local_executable[MAIN_PATH_CAP]; + cbm_daemon_build_identity_t local_identity; + cbm_project_lock_manager_t *project_locks = + local_endpoint ? cbm_project_lock_manager_new(local_endpoint) : NULL; + cbm_version_cohort_manager_t *cohort_manager = + local_endpoint ? cbm_version_cohort_manager_new(local_endpoint) : NULL; + cbm_version_cohort_lease_t *cohort_lease = NULL; + cbm_daemon_ipc_local_transition_t *local_transition = NULL; + main_local_maintenance_context_t maintenance_context; + bool maintenance_context_initialized = false; + cbm_daemon_maintenance_monitor_t *maintenance_monitor = NULL; + cbm_daemon_conflict_t cohort_conflict; + cbm_version_cohort_status_t cohort_status = CBM_VERSION_COHORT_IO; + int result = CBM_NOT_FOUND; + int exit_code = EXIT_FAILURE; + bool cleanup_ok = true; + if (!local_endpoint || !project_locks || !cohort_manager || + !main_resolve_executable(argv[0], local_executable) || + !main_build_identity(&local_identity)) { + (void)fprintf(stderr, + "codebase-memory-mcp: secure CLI coordination could not be created\n"); + goto local_cli_cleanup; + } + cbm_http_server_set_binary_path(local_executable); + + cohort_status = cbm_version_cohort_acquire( + cohort_manager, &local_identity, + main_deadline_after(MAIN_STARTUP_TIMEOUT_MS), &cohort_lease, + &cohort_conflict); + if (cohort_status != CBM_VERSION_COHORT_OK) { + char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; + bool formatted = cohort_status == CBM_VERSION_COHORT_CONFLICT && + cbm_daemon_conflict_format( + &cohort_conflict, message, sizeof(message)); + if (cohort_status == CBM_VERSION_COHORT_CONFLICT) { + (void)cbm_version_cohort_log_conflict(&cohort_conflict); + } + (void)fprintf( + stderr, "codebase-memory-mcp: %s\n", + formatted + ? message + : "CLI exact-build admission could not be verified; retry after active CBM operations exit"); + goto local_cli_cleanup; + } + main_local_maintenance_context_init(&maintenance_context); + maintenance_context_initialized = true; + maintenance_monitor = cbm_daemon_maintenance_monitor_start( + cohort_manager, main_local_command_cancel, &maintenance_context, + EXIT_FAILURE, "CLI command"); + if (!maintenance_monitor) { + (void)fprintf( + stderr, + "codebase-memory-mcp: CLI maintenance observer could not start safely\n"); + goto local_cli_cleanup; + } + + int transition_status = main_local_transition_acquire( + local_endpoint, feedback, &local_transition); + if (transition_status != 1 || !local_transition) { + (void)fprintf( + stderr, + "codebase-memory-mcp: CLI startup coordination %s; retry after the active CBM transition exits\n", + transition_status == 0 ? "remained busy" : "could not be verified safely"); + goto local_cli_cleanup; + } + int seal_status = + cbm_daemon_ipc_local_transition_seal_legacy(local_transition); + if (seal_status != 1) { + if (seal_status == 0) { + (void)cbm_version_cohort_log_uncoordinated_daemon( + &local_identity); + } + (void)fprintf( + stderr, + "codebase-memory-mcp: CBM CLI could not start because a pre-coordination or unverified CBM generation is active; close all CBM sessions and commands, then retry\n"); + goto local_cli_cleanup; + } - /* 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 && !restricted_tool_profile) { - snprintf(config_dir, sizeof(config_dir), "%s", cbm_resolve_cache_dir()); - runtime_config = cbm_config_open(config_dir); + cbm_version_cohort_daemon_presence_t daemon_presence = + cbm_version_cohort_daemon_presence_under_transition( + cohort_manager, local_endpoint, local_transition); + if (daemon_presence != CBM_VERSION_COHORT_DAEMON_ABSENT && + daemon_presence != CBM_VERSION_COHORT_DAEMON_COORDINATED) { + if (daemon_presence == + CBM_VERSION_COHORT_DAEMON_UNCOORDINATED) { + (void)cbm_version_cohort_log_uncoordinated_daemon( + &local_identity); + (void)fprintf( + stderr, + "codebase-memory-mcp: CBM CLI could not start because " + "an active pre-coordination or unverified CBM daemon is " + "running. Close all CBM sessions and commands, then " + "retry.\n"); + } else { + (void)fprintf( + stderr, + "codebase-memory-mcp: active daemon coordination could " + "not be verified safely; retry after active CBM sessions " + "exit\n"); + } + goto local_cli_cleanup; + } + if (!cbm_daemon_ipc_local_transition_begin_work(local_transition)) { + (void)fprintf( + stderr, + "codebase-memory-mcp: CLI startup coordination could not enter local work safely\n"); + goto local_cli_cleanup; + } + + result = handle_subcommand(argc, argv, project_locks, + &maintenance_context); + exit_code = result >= 0 ? result : EXIT_FAILURE; + + local_cli_cleanup: + main_local_maintenance_finish( + &maintenance_monitor, &maintenance_context, + maintenance_context_initialized, "CLI command"); + cleanup_ok = main_project_lock_manager_close(&project_locks) && + cleanup_ok; + cleanup_ok = main_local_transition_close(&local_transition) && + cleanup_ok; + /* Lifetime is the final coordination token released. The mutation + * barrier must not prove every old participant gone while this process + * still owns a local transition or project mutation lease. */ + cleanup_ok = + main_version_cohort_close(&cohort_lease, &cohort_manager) && + cleanup_ok; + cbm_daemon_ipc_endpoint_free(local_endpoint); + if (!cleanup_ok) { + (void)fprintf(stderr, + "codebase-memory-mcp: CLI coordination cleanup failed\n"); + return EXIT_FAILURE; + } + return exit_code; + } + + char executable_path[MAIN_PATH_CAP]; + cbm_daemon_build_identity_t identity; + if (!main_resolve_executable(argv[0], executable_path) || + !main_build_identity(&identity)) { + (void)fprintf(stderr, + "codebase-memory-mcp: exact executable identity could not be verified\n"); + return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS + : EXIT_FAILURE; } + cbm_http_server_set_binary_path(executable_path); + + if (role == CBM_DAEMON_PROCESS_WORKER) { + cbm_index_worker_invocation_t invocation; + cbm_index_worker_argv_status_t worker_status = + cbm_index_worker_parse_process_argv(argc, argv, &invocation); + if (worker_status != CBM_INDEX_WORKER_ARGV_VALID) { + (void)fprintf(stderr, "CBM index worker could not start: %s\n", + cbm_index_worker_argv_status_message(worker_status)); + return EXIT_FAILURE; + } + cbm_daemon_ipc_endpoint_t *worker_endpoint = + cbm_daemon_bootstrap_endpoint_new(NULL); + cbm_project_lock_manager_t *worker_project_locks = + worker_endpoint + ? cbm_project_lock_manager_new(worker_endpoint) + : NULL; + cbm_version_cohort_manager_t *worker_cohort_manager = + worker_endpoint + ? cbm_version_cohort_manager_new(worker_endpoint) + : NULL; + cbm_version_cohort_lease_t *worker_cohort_lease = NULL; + cbm_daemon_ipc_local_transition_t *worker_transition = NULL; + main_local_maintenance_context_t worker_maintenance_context; + bool worker_maintenance_context_initialized = false; + cbm_daemon_maintenance_monitor_t *worker_maintenance_monitor = NULL; + cbm_daemon_conflict_t worker_conflict; + int result = CBM_NOT_FOUND; + bool worker_cleanup_ok = true; + cbm_version_cohort_status_t worker_cohort_status = + worker_project_locks && worker_cohort_manager + ? cbm_version_cohort_acquire( + worker_cohort_manager, &identity, + main_deadline_after(MAIN_STARTUP_TIMEOUT_MS), + &worker_cohort_lease, &worker_conflict) + : CBM_VERSION_COHORT_IO; + if (worker_cohort_status != CBM_VERSION_COHORT_OK) { + char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; + bool formatted = + worker_cohort_status == CBM_VERSION_COHORT_CONFLICT && + cbm_daemon_conflict_format(&worker_conflict, message, + sizeof(message)); + if (worker_cohort_status == CBM_VERSION_COHORT_CONFLICT) { + (void)cbm_version_cohort_log_conflict(&worker_conflict); + } + (void)fprintf( + stderr, "CBM index worker could not start: %s\n", + formatted ? message : "exact-build admission failed"); + goto worker_cleanup; + } + + main_local_maintenance_context_init(&worker_maintenance_context); + worker_maintenance_context_initialized = true; + worker_maintenance_monitor = cbm_daemon_maintenance_monitor_start( + worker_cohort_manager, main_local_command_cancel, + &worker_maintenance_context, EXIT_FAILURE, "index worker"); + if (!worker_maintenance_monitor) { + (void)fprintf( + stderr, + "CBM index worker could not start: maintenance observer unavailable\n"); + goto worker_cleanup; + } - /* Create MCP server */ - g_server = cbm_mcp_server_new(NULL); - if (!g_server) { - cbm_log_error("server.err", "msg", "failed to create server"); - cbm_config_close(runtime_config); + int worker_transition_status = main_local_transition_acquire( + worker_endpoint, NULL, &worker_transition); + if (worker_transition_status != 1 || !worker_transition) { + (void)fprintf( + stderr, + "CBM index worker could not start: local coordination %s\n", + worker_transition_status == 0 + ? "remained busy" + : "could not be verified safely"); + goto worker_cleanup; + } + int worker_seal_status = + cbm_daemon_ipc_local_transition_seal_legacy(worker_transition); + if (worker_seal_status != 1) { + if (worker_seal_status == 0) { + (void)cbm_version_cohort_log_uncoordinated_daemon(&identity); + } + (void)fprintf( + stderr, + "CBM index worker could not start: a pre-coordination or unverified CBM generation is active\n"); + goto worker_cleanup; + } + cbm_version_cohort_daemon_presence_t worker_daemon_presence = + cbm_version_cohort_daemon_presence_under_transition( + worker_cohort_manager, worker_endpoint, worker_transition); + if (worker_daemon_presence != CBM_VERSION_COHORT_DAEMON_ABSENT && + worker_daemon_presence != + CBM_VERSION_COHORT_DAEMON_COORDINATED) { + if (worker_daemon_presence == + CBM_VERSION_COHORT_DAEMON_UNCOORDINATED) { + (void)cbm_version_cohort_log_uncoordinated_daemon(&identity); + } + (void)fprintf( + stderr, + "CBM index worker could not start: active daemon coordination could not be verified safely\n"); + goto worker_cleanup; + } + if (!cbm_daemon_ipc_local_transition_begin_work(worker_transition)) { + (void)fprintf( + stderr, + "CBM index worker could not start: local coordination could not enter worker execution\n"); + goto worker_cleanup; + } + cbm_index_set_worker_role_options( + true, invocation.response_out, invocation.single_thread, invocation.marker_file, + invocation.quarantine_file, invocation.memory_budget_bytes); #ifndef _WIN32 - if (parent_watchdog_started) { - atomic_store(&g_shutdown, 1); - cbm_thread_join(&parent_watchdog_tid); + if (!worker_prepare_process_group() || process_initial_ppid <= 1 || + getppid() != process_initial_ppid || + !worker_start_watchdog_test_descendant() || + !worker_start_parent_watchdog(process_initial_ppid)) { + static const char message[] = + "CBM index worker could not start: process-tree containment unavailable\n"; + (void)write(STDERR_FILENO, message, sizeof(message) - 1); + (void)kill(-getpid(), SIGKILL); + _exit(EXIT_FAILURE); } #endif - return SKIP_ONE; + cbm_index_supervisor_mark_host(); + result = handle_subcommand(argc, argv, worker_project_locks, + &worker_maintenance_context); + + worker_cleanup: + main_local_maintenance_finish( + &worker_maintenance_monitor, &worker_maintenance_context, + worker_maintenance_context_initialized, "index worker"); + worker_cleanup_ok = + main_project_lock_manager_close(&worker_project_locks) && + worker_cleanup_ok; + worker_cleanup_ok = + main_local_transition_close(&worker_transition) && + worker_cleanup_ok; + /* As in the parent CLI, release cohort lifetime last so activation + * cannot overtake physical-worker coordination cleanup. */ + worker_cleanup_ok = + main_version_cohort_close(&worker_cohort_lease, + &worker_cohort_manager) && + worker_cleanup_ok; + cbm_daemon_ipc_endpoint_free(worker_endpoint); + if (!worker_cleanup_ok || + worker_cohort_status != CBM_VERSION_COHORT_OK || result < 0) { + return EXIT_FAILURE; + } + return result; } - cbm_mcp_server_set_tool_profile(g_server, tool_profile); - /* Create and start watcher in background thread */ - /* Initialize log mutex before any threads are created */ - cbm_ui_log_init(); + cbm_daemon_ipc_endpoint_t *endpoint = + cbm_daemon_bootstrap_endpoint_new(NULL); + if (!endpoint) { + (void)fprintf(stderr, + "codebase-memory-mcp: secure daemon endpoint could not be created\n"); + return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS + : EXIT_FAILURE; + } - cbm_store_t *watch_store = NULL; - if (!restricted_tool_profile) { - 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); + if (role == CBM_DAEMON_PROCESS_DAEMON) { + setup_signal_handlers(); + cbm_daemon_host_config_t host_config = { + .endpoint = endpoint, + .identity = identity, + .executable_path = executable_path, + .stop_requested = &g_shutdown, + }; + int result = cbm_daemon_host_run(&host_config); + cbm_daemon_ipc_endpoint_free(endpoint); + return result == 0 ? EXIT_SUCCESS : EXIT_FAILURE; } - 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; + cbm_version_cohort_manager_t *client_cohort_manager = + cbm_version_cohort_manager_new(endpoint); + cbm_version_cohort_lease_t *client_cohort_lease = NULL; + cbm_daemon_conflict_t client_cohort_conflict; + cbm_version_cohort_status_t client_cohort_status = + client_cohort_manager + ? cbm_version_cohort_acquire( + client_cohort_manager, &identity, + main_deadline_after(role == CBM_DAEMON_PROCESS_HOOK_CLIENT + ? MAIN_HOOK_STARTUP_TIMEOUT_MS + : MAIN_STARTUP_TIMEOUT_MS), + &client_cohort_lease, &client_cohort_conflict) + : CBM_VERSION_COHORT_IO; + if (client_cohort_status != CBM_VERSION_COHORT_OK) { + char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; + bool formatted = client_cohort_status == CBM_VERSION_COHORT_CONFLICT && + cbm_daemon_conflict_format( + &client_cohort_conflict, message, + sizeof(message)); + if (client_cohort_status == CBM_VERSION_COHORT_CONFLICT) { + (void)cbm_version_cohort_log_conflict(&client_cohort_conflict); } + (void)fprintf( + stderr, "codebase-memory-mcp: %s\n", + formatted ? message : "client exact-build admission failed"); + (void)main_version_cohort_close(&client_cohort_lease, + &client_cohort_manager); + cbm_daemon_ipc_endpoint_free(endpoint); + return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS + : EXIT_FAILURE; } - /* Optionally start HTTP UI server in background thread */ - cbm_thread_t http_tid; - bool http_started = false; - - if (cbm_mcp_tool_profile_allows_http(tool_profile) && ui_cfg.ui_enabled && - CBM_EMBEDDED_FILE_COUNT > 0) { - g_http_server = cbm_http_server_new(ui_cfg.ui_port); - if (g_http_server) { - cbm_http_server_set_watcher(g_http_server, g_watcher); - if (cbm_thread_create(&http_tid, 0, http_thread, g_http_server) == 0) { - http_started = true; - } - } - } else if (cbm_mcp_tool_profile_allows_http(tool_profile) && ui_cfg.ui_enabled && - CBM_EMBEDDED_FILE_COUNT == 0) { - cbm_log_warn("ui.no_assets", "hint", "rebuild with: make -f Makefile.cbm cbm-with-ui"); + cbm_daemon_bootstrap_config_t bootstrap_config = { + .role = role, + .endpoint = endpoint, + .identity = &identity, + .executable_path = executable_path, + .connect_timeout_ms = role == CBM_DAEMON_PROCESS_HOOK_CLIENT + ? MAIN_HOOK_CONNECT_TIMEOUT_MS + : MAIN_CONNECT_TIMEOUT_MS, + .startup_timeout_ms = role == CBM_DAEMON_PROCESS_HOOK_CLIENT + ? MAIN_HOOK_STARTUP_TIMEOUT_MS + : MAIN_STARTUP_TIMEOUT_MS, + }; + cbm_daemon_bootstrap_result_t bootstrap_result; + cbm_daemon_bootstrap_status_t bootstrap_status = + cbm_daemon_bootstrap_execute(&bootstrap_config, &bootstrap_result); + cbm_daemon_ipc_endpoint_free(endpoint); + if (bootstrap_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || + !bootstrap_result.client) { + (void)main_version_cohort_close(&client_cohort_lease, + &client_cohort_manager); + return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS + : EXIT_FAILURE; } - /* 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 */ + g_daemon_client = bootstrap_result.client; - /* Shutdown */ - cbm_log_info("server.shutdown"); + if (role == CBM_DAEMON_PROCESS_MCP_CLIENT && + !main_set_client_context( + g_daemon_client, NULL, tool_profile, NULL, NULL, + MAIN_CONNECT_TIMEOUT_MS)) { + (void)fprintf(stderr, + "codebase-memory-mcp: daemon session context was rejected\n"); + (void)cbm_daemon_runtime_client_close(g_daemon_client, + MAIN_CLOSE_TIMEOUT_MS); + g_daemon_client = NULL; + (void)main_version_cohort_close(&client_cohort_lease, + &client_cohort_manager); + return EXIT_FAILURE; + } + /* Persist UI mutations only after the exact-build HELLO succeeds. A + * conflicting binary must be observationally read-only: applying its + * flags before bootstrap could reconfigure the already-running daemon + * even though that client was then rejected. */ + if (role == CBM_DAEMON_PROCESS_MCP_CLIENT && + cbm_mcp_tool_profile_allows_http(tool_profile)) { + bool ui_enabled = false; + int ui_port = 0; + bool explicitly_enabled = false; + uint8_t update_mask = parse_ui_flags( + argc, argv, &ui_enabled, &ui_port, &explicitly_enabled); + if (update_mask != 0 && + cbm_daemon_application_client_set_ui_config( + g_daemon_client, update_mask, ui_enabled, ui_port, + MAIN_CONNECT_TIMEOUT_MS) != + CBM_DAEMON_RUNTIME_APPLICATION_OK) { + (void)fprintf( + stderr, + "codebase-memory-mcp: daemon UI configuration update failed\n"); + (void)cbm_daemon_runtime_client_close(g_daemon_client, + MAIN_CLOSE_TIMEOUT_MS); + g_daemon_client = NULL; + (void)main_version_cohort_close(&client_cohort_lease, + &client_cohort_manager); + return EXIT_FAILURE; + } + if (explicitly_enabled && CBM_EMBEDDED_FILE_COUNT == 0) { + (void)fprintf(stderr, + "codebase-memory-mcp: --ui requested, but this binary was built " + "without the embedded UI; rebuild with `make -f Makefile.cbm " + "cbm-with-ui`.\n"); + } + } #ifndef _WIN32 - if (parent_watchdog_started) { - cbm_thread_join(&parent_watchdog_tid); + if (!client_start_parent_watchdog(process_initial_ppid)) { + (void)fprintf(stderr, + "codebase-memory-mcp: parent-death watchdog could not start\n"); + (void)cbm_daemon_runtime_client_close(g_daemon_client, + role == CBM_DAEMON_PROCESS_HOOK_CLIENT + ? MAIN_HOOK_CLOSE_TIMEOUT_MS + : MAIN_CLOSE_TIMEOUT_MS); + g_daemon_client = NULL; + (void)main_version_cohort_close(&client_cohort_lease, + &client_cohort_manager); + return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS + : EXIT_FAILURE; } #endif - if (http_started) { - cbm_http_server_stop(g_http_server); - cbm_thread_join(&http_tid); - cbm_http_server_free(g_http_server); - g_http_server = NULL; - } - - if (watcher_started) { - cbm_watcher_stop(g_watcher); - cbm_thread_join(&watcher_tid); + int result = EXIT_FAILURE; + if (role == CBM_DAEMON_PROCESS_HOOK_CLIENT) { + /* POSIX carries its configurable deadline from process startup. + * Windows retains the upstream fixed 300 ms augmentation budget but + * starts it only after authenticated bootstrap, whose separate finite + * bound cannot fit inside that budget. */ +#ifdef _WIN32 + cbm_hook_augment_arm_deadline(); +#endif + result = main_run_hook_frontend(g_daemon_client, hook_event, + hook_dialect); + (void)cbm_daemon_runtime_client_close(g_daemon_client, + MAIN_HOOK_CLOSE_TIMEOUT_MS); + g_daemon_client = NULL; + } else { + setup_signal_handlers(); + result = cbm_daemon_frontend_mcp_run( + g_daemon_client, client_cohort_manager, stdin, stdout); + g_daemon_client = NULL; /* frontend consumed the handle */ } - cbm_watcher_free(g_watcher); - if (watch_store) { - cbm_store_close(watch_store); + bool client_cohort_cleanup = main_version_cohort_close( + &client_cohort_lease, &client_cohort_manager); + atomic_store(&g_shutdown, 1); + if (!client_cohort_cleanup && + role != CBM_DAEMON_PROCESS_HOOK_CLIENT) { + return EXIT_FAILURE; } - cbm_mcp_server_free(g_server); - cbm_config_close(runtime_config); - - g_watcher = NULL; - g_server = NULL; - cbm_diag_stop(); - - return rc; + return result < 0 ? EXIT_FAILURE : result; } diff --git a/src/mcp/index_supervisor.c b/src/mcp/index_supervisor.c index 976c10d54..667acef59 100644 --- a/src/mcp/index_supervisor.c +++ b/src/mcp/index_supervisor.c @@ -3,29 +3,45 @@ */ #include "index_supervisor.h" -#include "foundation/compat.h" /* cbm_setenv, cbm_unsetenv */ +#include "daemon/runtime.h" +#include "foundation/compat.h" #include "foundation/compat_fs.h" /* cbm_mkdir_p, cbm_fopen */ +#include "foundation/constants.h" #include "foundation/log.h" -#include "foundation/platform.h" /* cbm_resolve_cache_dir */ +#include "foundation/platform.h" /* cbm_safe_getenv, path normalization */ #include "foundation/profile.h" /* cbm_profile_active (keep worker log under CBM_PROFILE) */ #include "ui/http_server.h" /* cbm_http_server_resolve_binary_path */ #include +#include #include #include +#include #ifdef _WIN32 -#include /* _getpid */ -#define cbm_getpid _getpid +#include +#include +#define worker_close _close +#define worker_getpid _getpid #else -#include /* getpid */ -#define cbm_getpid getpid +#include +#include +#include +#define worker_close close +#define worker_getpid getpid #endif +_Static_assert(CBM_INDEX_WORKER_BUILD_FINGERPRINT_SIZE == + CBM_DAEMON_BUILD_FINGERPRINT_SIZE, + "worker and daemon build fingerprint sizes must match"); + /* ── Worker-role state ────────────────────────────────────────────── */ static bool g_worker_active = false; -static char g_worker_response_out[1024] = {0}; +static char g_worker_response_out[CBM_SZ_4K] = {0}; +static size_t g_worker_memory_budget_bytes = 0; +static bool g_build_fingerprint_capture_attempted = false; +static char g_build_fingerprint[CBM_INDEX_WORKER_BUILD_FINGERPRINT_SIZE] = {0}; void cbm_index_set_worker_role(bool is_worker, const char *response_out) { g_worker_active = is_worker; @@ -36,6 +52,27 @@ void cbm_index_set_worker_role(bool is_worker, const char *response_out) { } } +static void worker_set_local_env(const char *name, const char *value) { + if (value && value[0]) { + (void)cbm_setenv(name, value, 1); + } else { + (void)cbm_unsetenv(name); + } +} + +void cbm_index_set_worker_role_options(bool is_worker, const char *response_out, bool single_thread, + const char *marker_file, const char *quarantine_file, + size_t memory_budget_bytes) { + cbm_index_set_worker_role(is_worker, response_out); + g_worker_memory_budget_bytes = is_worker ? memory_budget_bytes : 0; + if (!is_worker) { + return; + } + worker_set_local_env("CBM_INDEX_SINGLE_THREAD", single_thread ? "1" : NULL); + worker_set_local_env("CBM_INDEX_MARKER_FILE", marker_file); + worker_set_local_env("CBM_INDEX_QUARANTINE_FILE", quarantine_file); +} + bool cbm_index_worker_active(void) { return g_worker_active; } @@ -44,23 +81,168 @@ const char *cbm_index_worker_response_out(void) { return g_worker_response_out[0] ? g_worker_response_out : NULL; } -/* Test hook (#845): counts spawn ATTEMPTS (entry to cbm_index_spawn_worker), - * including ones that fail to resolve the self binary — an embedder must never - * even try to spawn. */ -static int g_spawn_count = 0; +size_t cbm_index_worker_memory_budget_bytes(void) { + return g_worker_memory_budget_bytes; +} + +bool cbm_index_supervisor_capture_build_fingerprint(void) { + if (g_build_fingerprint_capture_attempted) { + return g_build_fingerprint[0] != '\0'; + } + g_build_fingerprint_capture_attempted = true; + char captured[CBM_INDEX_WORKER_BUILD_FINGERPRINT_SIZE] = {0}; + if (!cbm_daemon_runtime_process_build_fingerprint((uint64_t)worker_getpid(), captured)) { + return false; + } + (void)snprintf(g_build_fingerprint, sizeof(g_build_fingerprint), "%s", captured); + return true; +} + +const char *cbm_index_supervisor_build_fingerprint(void) { + return g_build_fingerprint[0] ? g_build_fingerprint : NULL; +} + +static bool worker_fingerprint_valid(const char *fingerprint) { + if (!fingerprint || strlen(fingerprint) != CBM_INDEX_WORKER_BUILD_FINGERPRINT_LENGTH) { + return false; + } + for (size_t i = 0; i < CBM_INDEX_WORKER_BUILD_FINGERPRINT_LENGTH; i++) { + char ch = fingerprint[i]; + if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) { + return false; + } + } + return true; +} + +static bool worker_parse_positive_size(const char *text, size_t *value_out) { + if (!text || !text[0] || !value_out) { + return false; + } + size_t value = 0; + for (const unsigned char *cursor = (const unsigned char *)text; *cursor; cursor++) { + if (*cursor < '0' || *cursor > '9') { + return false; + } + size_t digit = (size_t)(*cursor - '0'); + if (value > (SIZE_MAX - digit) / 10U) { + return false; + } + value = value * 10U + digit; + } + if (value == 0) { + return false; + } + *value_out = value; + return true; +} + +cbm_index_worker_argv_status_t +cbm_index_worker_parse_process_argv(int argc, char *const argv[], + cbm_index_worker_invocation_t *invocation_out) { + if (invocation_out) { + memset(invocation_out, 0, sizeof(*invocation_out)); + } + bool contains_worker_role = false; + for (int index = 1; argv && index < argc; index++) { + if (argv[index] && strcmp(argv[index], "--index-worker") == 0) { + contains_worker_role = true; + break; + } + } + if (!contains_worker_role) { + return CBM_INDEX_WORKER_ARGV_NOT_WORKER; + } + if (!invocation_out || argc < 9 || !argv || !argv[0] || !argv[0][0] || + !argv[1] || strcmp(argv[1], "cli") != 0 || !argv[2] || + strcmp(argv[2], "--index-worker") != 0 || !argv[3] || + strcmp(argv[3], CBM_INDEX_WORKER_BUILD_ARG) != 0 || + !worker_fingerprint_valid(argv[4]) || !argv[5] || + strcmp(argv[5], "index_repository") != 0 || !argv[6] || !argv[6][0] || + !argv[7] || strcmp(argv[7], "--response-out") != 0 || !argv[8] || !argv[8][0]) { + return CBM_INDEX_WORKER_ARGV_INVALID; + } + + cbm_index_worker_invocation_t parsed = { + .expected_build_fingerprint = argv[4], + .args_json = argv[6], + .response_out = argv[8], + }; + int next = 9; + if (next < argc && argv[next] && + strcmp(argv[next], CBM_INDEX_WORKER_MEMORY_BUDGET_ARG) == 0) { + if (next + 1 >= argc || + !worker_parse_positive_size(argv[next + 1], &parsed.memory_budget_bytes)) { + return CBM_INDEX_WORKER_ARGV_INVALID; + } + next += 2; + } + if (next < argc && argv[next] && + strcmp(argv[next], CBM_INDEX_WORKER_SINGLE_THREAD_ARG) == 0) { + parsed.single_thread = true; + next++; + } + if (next < argc && argv[next] && strcmp(argv[next], CBM_INDEX_WORKER_MARKER_ARG) == 0) { + if (next + 1 >= argc || !argv[next + 1] || !argv[next + 1][0]) { + return CBM_INDEX_WORKER_ARGV_INVALID; + } + parsed.marker_file = argv[next + 1]; + next += 2; + } + if (next < argc && argv[next] && + strcmp(argv[next], CBM_INDEX_WORKER_QUARANTINE_ARG) == 0) { + if (next + 1 >= argc || !argv[next + 1] || !argv[next + 1][0]) { + return CBM_INDEX_WORKER_ARGV_INVALID; + } + parsed.quarantine_file = argv[next + 1]; + next += 2; + } + if (next != argc) { + return CBM_INDEX_WORKER_ARGV_INVALID; + } + if (!g_build_fingerprint[0]) { + return CBM_INDEX_WORKER_ARGV_BUILD_UNAVAILABLE; + } + if (strcmp(parsed.expected_build_fingerprint, g_build_fingerprint) != 0) { + return CBM_INDEX_WORKER_ARGV_BUILD_MISMATCH; + } + *invocation_out = parsed; + return CBM_INDEX_WORKER_ARGV_VALID; +} + +const char *cbm_index_worker_argv_status_message(cbm_index_worker_argv_status_t status) { + switch (status) { + case CBM_INDEX_WORKER_ARGV_VALID: + return "worker invocation accepted"; + case CBM_INDEX_WORKER_ARGV_INVALID: + return "invalid internal worker arguments"; + case CBM_INDEX_WORKER_ARGV_BUILD_UNAVAILABLE: + return "worker executable fingerprint could not be verified"; + case CBM_INDEX_WORKER_ARGV_BUILD_MISMATCH: + return "worker executable build conflicts with its supervisor; close all CBM sessions " + "and retry"; + case CBM_INDEX_WORKER_ARGV_NOT_WORKER: + default: + return "not an internal worker invocation"; + } +} + +/* Test hook (#845): counts worker-start ATTEMPTS, including ones that fail to + * resolve the self binary — an embedder must never even try to spawn. */ +static atomic_int g_spawn_count = 0; int cbm_index_supervisor_spawn_count(void) { - return g_spawn_count; + return atomic_load_explicit(&g_spawn_count, memory_order_relaxed); } /* Test hook: counts SINGLE-THREADED spawns. Production recovery is parallel- * only (there are no sequential production runs); this must stay ZERO on * every supervised path — any nonzero count means a recovery/probe regressed * to the sequential crawl that ground an 81k-file TS corpus for hours. */ -static int g_spawn_st_count = 0; +static atomic_int g_spawn_st_count = 0; int cbm_index_supervisor_spawn_st_count(void) { - return g_spawn_st_count; + return atomic_load_explicit(&g_spawn_st_count, memory_order_relaxed); } /* #845: opt-in host mark — see the header. Set once from the real binary's @@ -78,13 +260,18 @@ bool cbm_index_supervisor_should_wrap(void) { if (g_worker_active) { return false; /* I am the worker — run in-process, never re-supervise */ } - const char *sv = getenv("CBM_INDEX_SUPERVISOR"); - if (sv && strcmp(sv, "0") == 0) { - return false; /* kill switch → in-process */ - } + /* Supervision is a safety boundary for the physical CBM host. An ambient + * variable must never turn a long-lived MCP/CLI parent into the indexer. */ return true; } +static bool supervisor_disable_requested(void) { + char supervisor_setting[CBM_SZ_32] = {0}; + return cbm_safe_getenv("CBM_INDEX_SUPERVISOR", supervisor_setting, + sizeof(supervisor_setting), NULL) && + strcmp(supervisor_setting, "0") == 0; +} + /* Quiet-timeout (ms) for a supervised worker: killed + reported as a hang if it * emits no NEW log line within the window. This is a NO-PROGRESS timeout — every * completed log line the worker tails (per-batch parallel.extract.progress every @@ -94,9 +281,11 @@ bool cbm_index_supervisor_should_wrap(void) { * CBM_INDEX_WORKER_TIMEOUT_S override (seconds → ms) tightens it for tests. */ static int worker_quiet_timeout_ms(void) { enum { DEFAULT_QUIET_TIMEOUT_MS = 900000 }; /* 15 min with no progress */ - const char *e = getenv("CBM_INDEX_WORKER_TIMEOUT_S"); - if (e && e[0]) { - long s = atol(e); + char timeout_seconds[CBM_SZ_32] = {0}; + if (cbm_safe_getenv("CBM_INDEX_WORKER_TIMEOUT_S", timeout_seconds, sizeof(timeout_seconds), + NULL) && + timeout_seconds[0]) { + long s = atol(timeout_seconds); if (s > 0) { return (int)(s * 1000); } @@ -104,8 +293,17 @@ static int worker_quiet_timeout_ms(void) { return DEFAULT_QUIET_TIMEOUT_MS; } -/* Read an entire file into a heap string (NUL-terminated). NULL on error. */ -static char *slurp_file(const char *path) { +typedef enum { + WORKER_RESPONSE_READ_OK = 0, + WORKER_RESPONSE_READ_ERROR, + WORKER_RESPONSE_READ_TOO_LARGE, +} worker_response_read_status_t; + +/* Read at most one daemon-application payload. The worker is already reaped, + * but the byte count remains an explicit allocation/read boundary even if an + * escaped process still has the response file open. */ +static char *slurp_worker_response(const char *path, worker_response_read_status_t *status_out) { + *status_out = WORKER_RESPONSE_READ_ERROR; FILE *f = cbm_fopen(path, "rb"); if (!f) { return NULL; @@ -119,155 +317,501 @@ static char *slurp_file(const char *path) { (void)fclose(f); return NULL; } - (void)fseek(f, 0, SEEK_SET); + if ((uint64_t)n > (uint64_t)CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX) { + *status_out = WORKER_RESPONSE_READ_TOO_LARGE; + (void)fclose(f); + return NULL; + } + if (fseek(f, 0, SEEK_SET) != 0) { + (void)fclose(f); + return NULL; + } char *buf = (char *)malloc((size_t)n + 1); if (!buf) { (void)fclose(f); return NULL; } size_t rd = fread(buf, 1, (size_t)n, f); + bool complete = rd == (size_t)n && !ferror(f); (void)fclose(f); + if (!complete) { + free(buf); + return NULL; + } buf[rd] = '\0'; + *status_out = WORKER_RESPONSE_READ_OK; return buf; } -/* Resolve a per-run temp path /logs/.worker-. */ -static void worker_tmp_path(char *out, size_t out_sz, int pid, const char *suffix) { - const char *cdir = cbm_resolve_cache_dir(); - if (cdir && cdir[0]) { - char dir[900]; - snprintf(dir, sizeof(dir), "%s/logs", cdir); - cbm_mkdir_p(dir, 0755); - snprintf(out, out_sz, "%s/.worker-%d%s", dir, pid, suffix); - } else { - snprintf(out, out_sz, ".worker-%d%s", pid, suffix); +enum { + INDEX_WORKER_PATH_CAP = CBM_SZ_4K, + INDEX_WORKER_ARGV_CAP = 17, + INDEX_WORKER_SYNC_POLL_NS = 10000000, + INDEX_WORKER_RELAY_LINES_PER_POLL = 64, + INDEX_WORKER_RELAY_BYTES_PER_POLL = 64 * 1024, +}; + +struct cbm_index_worker_handle { + cbm_subprocess_t *process; + char response_path[INDEX_WORKER_PATH_CAP]; + char log_path[INDEX_WORKER_PATH_CAP]; + cbm_proc_log_cb log_callback; + void *log_context; + long relay_tail_pos; + bool process_terminal; + cbm_proc_result_t process_result; + atomic_bool terminal; + cbm_index_worker_result_t result; +}; + +/* The generic subprocess supervisor tails logs in bounded batches to preserve + * its nonblocking poll contract. A short-lived worker can therefore become + * terminal with more completed lines still on disk. Keep the request callback + * at this layer and delay the index-worker terminal result until its independent + * bounded cursor has caught up. This also keeps callback state request-scoped: + * no process-global sink is installed and concurrent workers never share a + * cursor or context. + * + * Returns true when the relay cursor is at EOF (or no callback was requested), + * false when another bounded poll is needed. A partial final line is deliberately + * left undelivered, matching cbm_proc_log_cb's completed-line contract. */ +static bool worker_relay_log(cbm_index_worker_handle_t *handle) { + if (!handle || !handle->log_callback) { + return true; + } + +#ifdef _WIN32 + FILE *log = cbm_fopen(handle->log_path, "rb"); +#else + int flags = O_RDONLY | O_NONBLOCK; +#ifdef O_CLOEXEC + flags |= O_CLOEXEC; +#endif +#ifdef O_NOFOLLOW + flags |= O_NOFOLLOW; +#endif + int descriptor = open(handle->log_path, flags); + if (descriptor < 0) { + return true; + } + struct stat status; + if (fstat(descriptor, &status) != 0 || !S_ISREG(status.st_mode)) { + (void)close(descriptor); + return true; } + FILE *log = fdopen(descriptor, "r"); +#endif + if (!log) { +#ifndef _WIN32 + (void)close(descriptor); +#endif + return true; + } + + bool caught_up = true; + if (fseek(log, handle->relay_tail_pos, SEEK_SET) == 0) { + char line[1024]; + size_t delivered_lines = 0; + size_t delivered_bytes = 0; + while (delivered_lines < INDEX_WORKER_RELAY_LINES_PER_POLL && + delivered_bytes < INDEX_WORKER_RELAY_BYTES_PER_POLL) { + long before = ftell(log); + if (!fgets(line, sizeof(line), log)) { + break; + } + size_t length = strlen(line); + delivered_bytes += length; + bool complete = length > 0 && line[length - 1] == '\n'; + if (complete) { + line[length - 1] = '\0'; + handle->relay_tail_pos = ftell(log); + delivered_lines++; + if (line[0]) { + handle->log_callback(line, handle->log_context); + } + } else if (length == sizeof(line) - 1) { + /* Consume an oversized line in bounded chunks so it cannot pin + * the cursor forever. This mirrors the subprocess tailer. */ + handle->relay_tail_pos = ftell(log); + delivered_lines++; + handle->log_callback(line, handle->log_context); + } else { + /* The worker may append the rest before a later running poll. + * Once its process tree is quiescent this remains intentionally + * undelivered because callbacks are for complete lines only. */ + handle->relay_tail_pos = before; + break; + } + } + if (delivered_lines == INDEX_WORKER_RELAY_LINES_PER_POLL || + delivered_bytes >= INDEX_WORKER_RELAY_BYTES_PER_POLL) { + /* Probe without advancing the durable cursor. The FILE is closed + * immediately, so consuming this byte only answers whether another + * bounded owner-thread poll is required. */ + caught_up = fgetc(log) == EOF; + } + } + (void)fclose(log); + return caught_up; } -int cbm_index_spawn_worker(const char *args_json, bool single_thread, const char *marker_file, - const char *quarantine_file, cbm_index_worker_result_t *result) { - g_spawn_count++; /* test hook (#845) — see cbm_index_supervisor_spawn_count */ - if (single_thread) { - g_spawn_st_count++; /* test hook — must stay 0: recovery is parallel-only */ +/* cbm_mkstemp's Windows compatibility implementation uses an internal buffer. + * Serialize just the name creation so concurrent daemon jobs remain safe; the + * spawned workers themselves are fully concurrent. */ +static atomic_flag g_worker_tmp_lock = ATOMIC_FLAG_INIT; + +static void worker_tmp_lock(void) { + while (atomic_flag_test_and_set_explicit(&g_worker_tmp_lock, memory_order_acquire)) { + cbm_usleep(1000); } +} + +static void worker_tmp_unlock(void) { + atomic_flag_clear_explicit(&g_worker_tmp_lock, memory_order_release); +} + +static void worker_result_init(cbm_index_worker_result_t *result) { + memset(result, 0, sizeof(*result)); result->outcome = CBM_PROC_SPAWN_FAILED; result->exit_code = -1; - result->term_signal = 0; - result->response = NULL; +} - char self[1024] = {0}; - if (!cbm_http_server_resolve_binary_path(NULL, self, sizeof(self)) || !self[0]) { - cbm_log_warn("index.supervisor.no_self_path", "action", "degrade_in_process"); - return -1; +/* Resolve into caller-owned storage. The generic resolver returns a shared + * static buffer, which is unsuitable for concurrent daemon starts. */ +static bool worker_cache_dir(char out[INDEX_WORKER_PATH_CAP]) { + char configured[INDEX_WORKER_PATH_CAP] = {0}; + if (cbm_safe_getenv("CBM_CACHE_DIR", configured, sizeof(configured), NULL) && configured[0]) { + int written = snprintf(out, INDEX_WORKER_PATH_CAP, "%s", configured); + if (written <= 0 || written >= INDEX_WORKER_PATH_CAP) { + return false; + } + cbm_normalize_path_sep(out); + return true; + } + char home[INDEX_WORKER_PATH_CAP] = {0}; + if (!cbm_safe_getenv("HOME", home, sizeof(home), NULL) || !home[0]) { + (void)cbm_safe_getenv("USERPROFILE", home, sizeof(home), NULL); + } + if (!home[0]) { + return false; } + int written = snprintf(out, INDEX_WORKER_PATH_CAP, "%s/.cache/codebase-memory-mcp", home); + if (written <= 0 || written >= INDEX_WORKER_PATH_CAP) { + return false; + } + cbm_normalize_path_sep(out); + return true; +} - int pid = (int)cbm_getpid(); - char resp_path[1024]; - char log_path[1024]; - worker_tmp_path(resp_path, sizeof(resp_path), pid, ".response"); - worker_tmp_path(log_path, sizeof(log_path), pid, ".log"); - (void)remove(resp_path); /* clear any stale file */ - - /* No --progress: the worker's DEFAULT structured logging already provides the - * no-progress heartbeat (INFO parallel.extract.progress every 10 files + each - * pass boundary — all newline-terminated → tailed → reset the quiet-timeout). - * --progress would be strictly worse here: it installs a REPLACE-mode sink that - * suppresses those default lines and emits per-file extraction as a carriage- - * return in-place update (no trailing '\n'), which cbm_tail_log does not count - * as progress. (It would not corrupt the response either — that goes to the - * separate --response-out file, not stdout.) */ - const char *argv[8]; - int n = 0; - argv[n++] = self; - argv[n++] = "cli"; - argv[n++] = "--index-worker"; - argv[n++] = "index_repository"; - argv[n++] = args_json; - argv[n++] = "--response-out"; - argv[n++] = resp_path; - argv[n] = NULL; - - /* Recovery-run probe knobs → inherited env for the child. Spawns are - * sequential, so mutating the parent's environment around a single spawn is - * safe. Set only the requested knobs; unset them all again after reaping so - * a later attempt (or the caller) starts from a clean environment. */ - if (single_thread) { - cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); +static bool worker_unique_file(char *out, size_t out_size, const char *kind) { + if (!out || out_size == 0 || !kind || !kind[0]) { + return false; } - if (marker_file && marker_file[0]) { - cbm_setenv("CBM_INDEX_MARKER_FILE", marker_file, 1); + char cache_copy[INDEX_WORKER_PATH_CAP] = {0}; + bool have_cache = worker_cache_dir(cache_copy); + int written; + if (have_cache) { + char directory[INDEX_WORKER_PATH_CAP]; + written = snprintf(directory, sizeof(directory), "%s/logs", cache_copy); + if (written <= 0 || written >= (int)sizeof(directory) || !cbm_mkdir_p(directory, 0700)) { + return false; + } + written = snprintf(out, out_size, "%s/.worker-%s-XXXXXX", directory, kind); + } else { + written = snprintf(out, out_size, ".worker-%s-XXXXXX", kind); } - if (quarantine_file && quarantine_file[0]) { - cbm_setenv("CBM_INDEX_QUARANTINE_FILE", quarantine_file, 1); + if (written <= 0 || written >= (int)out_size) { + out[0] = '\0'; + return false; } + worker_tmp_lock(); + int descriptor = cbm_mkstemp(out); + worker_tmp_unlock(); + if (descriptor < 0) { + out[0] = '\0'; + return false; + } + (void)worker_close(descriptor); + return true; +} - cbm_proc_opts_t opts = {0}; - opts.bin = self; - opts.argv = argv; - opts.log_file = log_path; - opts.quiet_timeout_ms = worker_quiet_timeout_ms(); - /* We manage log deletion ourselves after reaping (below): keep it on failure - * for post-mortem, delete it only on a clean run. See the observability - * note at the reap site. */ - opts.delete_log_on_exit = false; +static bool worker_result_succeeded(const cbm_index_worker_result_t *result) { + return result && result->outcome == CBM_PROC_CLEAN && !result->cancellation_requested && + result->tree_quiesced && !result->supervision_failed; +} - cbm_proc_result_t r; - int run_rc = cbm_subprocess_run(&opts, &r); +static void worker_terminal_log(cbm_index_worker_handle_t *handle) { + char signal_text[16]; + char exit_text[16]; + (void)snprintf(signal_text, sizeof(signal_text), "%d", handle->result.term_signal); + (void)snprintf(exit_text, sizeof(exit_text), "%d", handle->result.exit_code); + cbm_log_info("index.supervisor.reap", "outcome", cbm_proc_outcome_str(handle->result.outcome), + "exit_code", exit_text, "signal", signal_text); + if (handle->result.response_rejected) { + cbm_log_error("index.supervisor.response_rejected", "reason", "payload_too_large", "log", + handle->log_path); + } else if (handle->result.supervision_failed || !handle->result.tree_quiesced) { + cbm_log_error("index.supervisor.containment_failed", "outcome", + cbm_proc_outcome_str(handle->result.outcome), "log", handle->log_path); + } else if (handle->result.cancellation_requested) { + cbm_log_warn("index.supervisor.worker_cancelled", "outcome", + cbm_proc_outcome_str(handle->result.outcome), "log", handle->log_path); + } else if (handle->result.outcome == CBM_PROC_CLEAN && !cbm_profile_active) { + (void)cbm_unlink(handle->log_path); + } else if (handle->result.outcome == CBM_PROC_CLEAN) { + cbm_log_info("index.supervisor.profile_log", "log", handle->log_path); + } else { + cbm_log_warn("index.supervisor.worker_failed", "outcome", + cbm_proc_outcome_str(handle->result.outcome), "exit_code", exit_text, "log", + handle->log_path); + } +} +int cbm_index_worker_start_with_log( + const char *args_json, size_t memory_budget_bytes, bool single_thread, + const char *marker_file, const char *quarantine_file, cbm_proc_log_cb log_callback, + void *log_context, cbm_index_worker_handle_t **handle_out) { + if (handle_out) { + *handle_out = NULL; + } + atomic_fetch_add_explicit(&g_spawn_count, 1, memory_order_relaxed); if (single_thread) { - cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + atomic_fetch_add_explicit(&g_spawn_st_count, 1, memory_order_relaxed); + } + if (!handle_out || !args_json || !args_json[0]) { + return -1; + } + if (g_host_marked && !g_worker_active && supervisor_disable_requested()) { + /* Keep the old variable as a deterministic refusal seam, but never as + * a production escape hatch into unsafe in-process indexing. */ + cbm_log_error("index.supervisor.disable_refused", "action", "fail_closed"); + return -1; + } + const char *build_fingerprint = cbm_index_supervisor_build_fingerprint(); + if (!build_fingerprint) { + cbm_log_error("index.supervisor.build_fingerprint_unavailable", "action", "refuse_spawn"); + return -1; + } + + char self[INDEX_WORKER_PATH_CAP] = {0}; + if (!cbm_http_server_resolve_binary_path(NULL, self, sizeof(self)) || !self[0]) { + cbm_log_error("index.supervisor.no_self_path", "action", "fail_closed"); + return -1; + } + cbm_index_worker_handle_t *handle = calloc(1, sizeof(*handle)); + if (!handle) { + return -1; + } + atomic_init(&handle->terminal, false); + handle->log_callback = log_callback; + handle->log_context = log_context; + worker_result_init(&handle->result); + if (!worker_unique_file(handle->response_path, sizeof(handle->response_path), "response") || + !worker_unique_file(handle->log_path, sizeof(handle->log_path), "log")) { + (void)cbm_unlink(handle->response_path); + (void)cbm_unlink(handle->log_path); + free(handle); + return -1; + } + + const char *argv[INDEX_WORKER_ARGV_CAP]; + size_t argc = 0; + argv[argc++] = self; + argv[argc++] = "cli"; + argv[argc++] = "--index-worker"; + argv[argc++] = CBM_INDEX_WORKER_BUILD_ARG; + argv[argc++] = build_fingerprint; + argv[argc++] = "index_repository"; + argv[argc++] = args_json; + argv[argc++] = "--response-out"; + argv[argc++] = handle->response_path; + char memory_budget_text[CBM_SZ_32]; + if (memory_budget_bytes > 0) { + (void)snprintf(memory_budget_text, sizeof(memory_budget_text), "%zu", memory_budget_bytes); + argv[argc++] = CBM_INDEX_WORKER_MEMORY_BUDGET_ARG; + argv[argc++] = memory_budget_text; + } + if (single_thread) { + argv[argc++] = CBM_INDEX_WORKER_SINGLE_THREAD_ARG; } if (marker_file && marker_file[0]) { - cbm_unsetenv("CBM_INDEX_MARKER_FILE"); + argv[argc++] = CBM_INDEX_WORKER_MARKER_ARG; + argv[argc++] = marker_file; } if (quarantine_file && quarantine_file[0]) { - cbm_unsetenv("CBM_INDEX_QUARANTINE_FILE"); + argv[argc++] = CBM_INDEX_WORKER_QUARANTINE_ARG; + argv[argc++] = quarantine_file; } + argv[argc] = NULL; - if (run_rc != 0) { - (void)remove(resp_path); - (void)remove(log_path); /* empty/partial log from a failed spawn — nothing to keep */ - cbm_log_warn("index.supervisor.spawn_failed", "action", "degrade_in_process"); + cbm_proc_opts_t options = {0}; + options.bin = self; + options.argv = argv; + options.log_file = handle->log_path; + /* The subprocess tail remains authoritative for quiet-timeout activity. + * Request callbacks use the supervisor's independent cursor so a terminal + * child cannot strand a bounded tail backlog. */ + options.on_log_line = NULL; + options.log_ud = NULL; + options.quiet_timeout_ms = worker_quiet_timeout_ms(); + options.delete_log_on_exit = false; + if (cbm_subprocess_spawn(&options, &handle->process) != 0) { + (void)cbm_unlink(handle->response_path); + (void)cbm_unlink(handle->log_path); + free(handle); + cbm_log_error("index.supervisor.spawn_failed", "action", "fail_closed"); return -1; } + *handle_out = handle; + return 0; +} - result->outcome = r.outcome; - result->exit_code = r.exit_code; - result->term_signal = r.term_signal; - if (r.outcome == CBM_PROC_CLEAN) { - result->response = slurp_file(resp_path); - } - (void)remove(resp_path); - - char sig[16]; - char exit_buf[16]; - snprintf(sig, sizeof(sig), "%d", r.term_signal); - snprintf(exit_buf, sizeof(exit_buf), "%d", r.exit_code); - cbm_log_info("index.supervisor.reap", "outcome", cbm_proc_outcome_str(r.outcome), "exit_code", - exit_buf, "signal", sig); - - /* Observability: on a CLEAN run the worker log is noise → delete it. On - * ANY failure keep it and surface its path + raw exit code, so the worker's own - * stdout/stderr (pipeline logs, any assert/abort text, the exact exit code) is - * available post-mortem instead of vanishing. Previously the log was ALWAYS - * deleted and only outcome+signal were logged, so a worker that exited non-zero - * left nothing to diagnose — the CI blind spot that hid this bug (a mangled JSON - * arg → "repo_path is required" exit) behind a generic "crashed on a file". - * - * Exception: under CBM_PROFILE the log IS the deliverable — the worker's - * msg=prof pass/sub-phase report is only written there, and deleting it on - * success made profiling clean runs impossible. Keep it and say where it is. */ - if (r.outcome == CBM_PROC_CLEAN && !cbm_profile_active) { - (void)remove(log_path); - } else if (r.outcome == CBM_PROC_CLEAN) { - cbm_log_info("index.supervisor.profile_log", "log", log_path); +int cbm_index_worker_start(const char *args_json, size_t memory_budget_bytes, bool single_thread, + const char *marker_file, const char *quarantine_file, + cbm_index_worker_handle_t **handle_out) { + return cbm_index_worker_start_with_log(args_json, memory_budget_bytes, single_thread, + marker_file, quarantine_file, NULL, NULL, handle_out); +} + +cbm_index_worker_poll_t cbm_index_worker_poll(cbm_index_worker_handle_t *handle, + const cbm_index_worker_result_t **result_out) { + if (result_out) { + *result_out = NULL; + } + if (!handle || !result_out || !handle->process) { + return CBM_INDEX_WORKER_POLL_ERROR; + } + if (atomic_load_explicit(&handle->terminal, memory_order_acquire)) { + *result_out = &handle->result; + return CBM_INDEX_WORKER_POLL_TERMINAL; + } + bool relay_caught_up = true; + if (!handle->process_terminal) { + cbm_proc_result_t process_result; + cbm_proc_poll_t state = cbm_subprocess_poll(handle->process, &process_result); + relay_caught_up = worker_relay_log(handle); + if (state == CBM_PROC_POLL_RUNNING) { + return CBM_INDEX_WORKER_POLL_RUNNING; + } + if (state != CBM_PROC_POLL_TERMINAL) { + return CBM_INDEX_WORKER_POLL_ERROR; + } + handle->process_result = process_result; + handle->process_terminal = true; } else { - cbm_log_warn("index.supervisor.worker_failed", "outcome", cbm_proc_outcome_str(r.outcome), - "exit_code", exit_buf, "log", log_path); + relay_caught_up = worker_relay_log(handle); + } + if (!relay_caught_up) { + /* The process tree is already quiescent, but one bounded callback batch + * remains. Keep the public poll nonblocking and report terminal only + * after every completed worker log line has reached its request sink. */ + return CBM_INDEX_WORKER_POLL_RUNNING; } + const cbm_proc_result_t *process_result = &handle->process_result; + handle->result.outcome = process_result->outcome; + handle->result.exit_code = process_result->exit_code; + handle->result.term_signal = process_result->term_signal; + handle->result.cancellation_requested = process_result->cancellation_requested; + handle->result.forced = process_result->forced; + handle->result.tree_quiesced = process_result->tree_quiesced; + handle->result.supervision_failed = process_result->supervision_failed; + if (worker_result_succeeded(&handle->result)) { + worker_response_read_status_t response_status; + handle->result.response = slurp_worker_response(handle->response_path, &response_status); + if (response_status == WORKER_RESPONSE_READ_TOO_LARGE) { + /* A clean exit with an out-of-contract response is a contained + * worker failure, not a fallback to in-process indexing. */ + handle->result.response_rejected = true; + handle->result.outcome = CBM_PROC_EXIT_NONZERO; + handle->result.exit_code = -1; + } + } + (void)cbm_unlink(handle->response_path); + worker_terminal_log(handle); + atomic_store_explicit(&handle->terminal, true, memory_order_release); + *result_out = &handle->result; + return CBM_INDEX_WORKER_POLL_TERMINAL; +} + +bool cbm_index_worker_request_cancel(cbm_index_worker_handle_t *handle) { + return handle && !atomic_load_explicit(&handle->terminal, memory_order_acquire) && + cbm_subprocess_request_cancel(handle->process); +} + +const char *cbm_index_worker_response_path(const cbm_index_worker_handle_t *handle) { + return handle ? handle->response_path : NULL; +} + +const char *cbm_index_worker_log_path(const cbm_index_worker_handle_t *handle) { + return handle ? handle->log_path : NULL; +} + +void cbm_index_worker_destroy(cbm_index_worker_handle_t *handle) { + if (!handle) { + return; + } + if (!atomic_load_explicit(&handle->terminal, memory_order_acquire)) { + cbm_log_error("index.supervisor.destroy_running", "action", "ignored"); + return; + } + cbm_subprocess_destroy(handle->process); + free(handle->result.response); + handle->result.response = NULL; + free(handle); +} + +int cbm_index_spawn_worker_with_log_cancel( + const char *args_json, bool single_thread, const char *marker_file, + const char *quarantine_file, cbm_proc_log_cb log_callback, void *log_context, + const atomic_int *cancel_requested, cbm_index_worker_result_t *result) { + if (!result) { + return -1; + } + worker_result_init(result); + cbm_index_worker_handle_t *handle = NULL; + if (cbm_index_worker_start_with_log(args_json, 0, single_thread, marker_file, + quarantine_file, log_callback, log_context, + &handle) != 0) { + return -1; + } + const cbm_index_worker_result_t *cached = NULL; + bool cancellation_forwarded = false; + for (;;) { + if (!cancellation_forwarded && cancel_requested && + atomic_load_explicit(cancel_requested, memory_order_acquire) != 0) { + cancellation_forwarded = cbm_index_worker_request_cancel(handle); + } + cbm_index_worker_poll_t state = cbm_index_worker_poll(handle, &cached); + if (state == CBM_INDEX_WORKER_POLL_TERMINAL) { + break; + } + if (state == CBM_INDEX_WORKER_POLL_ERROR) { + (void)cbm_index_worker_request_cancel(handle); + } + const struct timespec pause = {0, INDEX_WORKER_SYNC_POLL_NS}; + (void)cbm_nanosleep(&pause, NULL); + } + *result = *cached; + result->response = cached->response ? cbm_strdup(cached->response) : NULL; + cbm_index_worker_destroy(handle); return 0; } +int cbm_index_spawn_worker_with_log( + const char *args_json, bool single_thread, const char *marker_file, + const char *quarantine_file, cbm_proc_log_cb log_callback, void *log_context, + cbm_index_worker_result_t *result) { + return cbm_index_spawn_worker_with_log_cancel( + args_json, single_thread, marker_file, quarantine_file, log_callback, + log_context, NULL, result); +} + +int cbm_index_spawn_worker(const char *args_json, bool single_thread, const char *marker_file, + const char *quarantine_file, cbm_index_worker_result_t *result) { + return cbm_index_spawn_worker_with_log(args_json, single_thread, marker_file, + quarantine_file, NULL, NULL, result); +} + void cbm_index_worker_result_free(cbm_index_worker_result_t *result) { if (result) { free(result->response); diff --git a/src/mcp/index_supervisor.h b/src/mcp/index_supervisor.h index e15526140..ac271bf9d 100644 --- a/src/mcp/index_supervisor.h +++ b/src/mcp/index_supervisor.h @@ -4,9 +4,9 @@ * A single pathological file can hard-crash (SIGSEGV / stack overflow / abort) or * hang the native indexer, and today that takes down the whole MCP server or CLI. * The supervisor runs the actual index in a CHILD process (the same binary - * re-invoked as `cli --index-worker index_repository …`), reaps it, and classifies - * how it ended. A crash/hang is contained to the child; the parent survives and - * reports it instead of dying. + * re-invoked with a build-bound `cli --index-worker …` grammar), reaps it, and + * classifies how it ended. A crash/hang is contained to the child; the parent + * survives and reports it instead of dying. * * This module owns only the spawn/reap MECHANISM and the worker-role state. The * MCP handler (mcp.c) owns the gate placement and the response building, so this @@ -21,6 +21,8 @@ #define CBM_INDEX_SUPERVISOR_H #include +#include +#include #include "foundation/subprocess.h" /* cbm_proc_outcome_t */ @@ -29,8 +31,56 @@ * (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. */ void cbm_index_set_worker_role(bool is_worker, const char *response_out); + +/* Hidden recovery values arrive on the worker argv, then are installed as + * worker-local environment only after exec. The supervisor process must never + * mutate these process-wide variables around a concurrent spawn. */ +#define CBM_INDEX_WORKER_SINGLE_THREAD_ARG "--index-worker-single-thread" +#define CBM_INDEX_WORKER_MARKER_ARG "--index-worker-marker" +#define CBM_INDEX_WORKER_QUARANTINE_ARG "--index-worker-quarantine" +#define CBM_INDEX_WORKER_MEMORY_BUDGET_ARG "--index-worker-memory-budget-bytes" +#define CBM_INDEX_WORKER_BUILD_ARG "--index-worker-build" +#define CBM_INDEX_WORKER_BUILD_FINGERPRINT_LENGTH 64U +#define CBM_INDEX_WORKER_BUILD_FINGERPRINT_SIZE 65U +void cbm_index_set_worker_role_options(bool is_worker, const char *response_out, bool single_thread, + const char *marker_file, const char *quarantine_file, + size_t memory_budget_bytes); bool cbm_index_worker_active(void); const char *cbm_index_worker_response_out(void); +size_t cbm_index_worker_memory_budget_bytes(void); + +/* Capture the exact executable-image fingerprint once, during process startup + * before any worker can be launched. Repeated calls return the original capture + * and never re-hash a pathname that an installer may since have replaced. */ +bool cbm_index_supervisor_capture_build_fingerprint(void); +const char *cbm_index_supervisor_build_fingerprint(void); + +typedef struct { + const char *expected_build_fingerprint; + const char *args_json; + const char *response_out; + bool single_thread; + const char *marker_file; + const char *quarantine_file; + size_t memory_budget_bytes; +} cbm_index_worker_invocation_t; + +typedef enum { + CBM_INDEX_WORKER_ARGV_NOT_WORKER = 0, + CBM_INDEX_WORKER_ARGV_VALID, + CBM_INDEX_WORKER_ARGV_INVALID, + CBM_INDEX_WORKER_ARGV_BUILD_UNAVAILABLE, + CBM_INDEX_WORKER_ARGV_BUILD_MISMATCH, +} cbm_index_worker_argv_status_t; + +/* Parse the one internal worker grammar. Any argv containing --index-worker + * outside this exact shape is INVALID, never an ordinary CLI request. A valid + * shape is admitted only when its expected fingerprint matches the image + * captured by this process before stateful initialization. */ +cbm_index_worker_argv_status_t +cbm_index_worker_parse_process_argv(int argc, char *const argv[], + cbm_index_worker_invocation_t *invocation_out); +const char *cbm_index_worker_argv_status_message(cbm_index_worker_argv_status_t status); /* Host marking (#845): the supervisor gate is OPT-IN per process. Only the real * codebase-memory-mcp binary calls this (first thing in main(), before any @@ -43,15 +93,16 @@ const char *cbm_index_worker_response_out(void); * VM-map pressure during the 2026-07-04 host panics). */ void cbm_index_supervisor_mark_host(void); -/* True when handle_index_repository should wrap the run in a supervised child: +/* True when handle_index_repository must wrap the run in a supervised child: * this process called cbm_index_supervisor_mark_host() (i.e. it IS the real - * binary, not an embedder), is not itself a worker, AND the kill switch - * (CBM_INDEX_SUPERVISOR=0) is not set. */ + * binary, not an embedder) and is not itself a worker. Supervision is mandatory + * for a marked host; ambient configuration cannot select in-process indexing. */ bool cbm_index_supervisor_should_wrap(void); -/* TEST HOOK (#845): process-wide count of worker-spawn attempts, incremented on - * entry to cbm_index_spawn_worker. Embedder tests assert the count is unchanged - * across an index_repository call to prove indexing ran IN-PROCESS. */ +/* TEST HOOK (#845): process-wide count of worker-start attempts, incremented on + * entry to cbm_index_worker_start (including calls made by the synchronous + * wrapper). Embedder tests assert the count is unchanged across an + * index_repository call to prove indexing ran IN-PROCESS. */ int cbm_index_supervisor_spawn_count(void); /* Test hook: single-threaded spawn count — must stay ZERO (production @@ -62,29 +113,94 @@ typedef struct { cbm_proc_outcome_t outcome; /* how the worker ended */ int exit_code; /* worker exit code (-1 if signalled) */ int term_signal; /* POSIX terminating signal, else 0 */ - char *response; /* worker's result string on CLEAN exit (caller frees); else NULL */ + bool cancellation_requested; + bool forced; + bool tree_quiesced; + bool supervision_failed; + bool response_rejected; /* clean worker exceeded the bounded response protocol */ + char *response; /* worker result only after a contained, uncancelled CLEAN exit; + * borrowed for async polls, caller-owned from the sync wrapper */ } cbm_index_worker_result_t; -/* Spawn ` cli --index-worker index_repository --response-out `, +/* Daemon-owned, nonblocking supervisor for one contained worker process tree. */ +typedef struct cbm_index_worker_handle cbm_index_worker_handle_t; + +typedef enum { + CBM_INDEX_WORKER_POLL_ERROR = -1, + CBM_INDEX_WORKER_POLL_RUNNING = 0, + CBM_INDEX_WORKER_POLL_TERMINAL = 1, +} cbm_index_worker_poll_t; + +/* Start returns after process creation. All string arguments are copied by the + * contained subprocess layer. Recovery values are encoded only as hidden argv + * and applied after exec by cbm_index_set_worker_role_options(). */ +int cbm_index_worker_start(const char *args_json, size_t memory_budget_bytes, bool single_thread, + const char *marker_file, const char *quarantine_file, + cbm_index_worker_handle_t **handle_out); + +/* Request-scoped variant used by interactive local CLI calls. The callback is + * invoked by the owner thread while it polls the contained worker; log_context + * remains caller-owned until terminal. No process-global sink is installed. + * Poll stays bounded while draining log bursts and does not report terminal + * until every completed worker log line has reached this callback. */ +int cbm_index_worker_start_with_log( + const char *args_json, size_t memory_budget_bytes, bool single_thread, + const char *marker_file, const char *quarantine_file, cbm_proc_log_cb log_callback, + void *log_context, cbm_index_worker_handle_t **handle_out); + +/* Strictly nonblocking and called by one owner thread/event loop. result_out is + * set to NULL while running and to a borrowed immutable cached result only at + * terminal; repeated terminal polls return the same result until destroy. */ +cbm_index_worker_poll_t cbm_index_worker_poll(cbm_index_worker_handle_t *handle, + const cbm_index_worker_result_t **result_out); + +/* Nonblocking and idempotent. Poll drives graceful-to-forced process-tree + * termination; terminal is reported only after containment is quiescent (or a + * bounded containment failure is explicitly surfaced in the result). The + * owner must stop concurrent cancellation producers before destroy. */ +bool cbm_index_worker_request_cancel(cbm_index_worker_handle_t *handle); + +/* Borrowed diagnostic paths, stable until destroy. Every start uses securely + * created unique files, so concurrent jobs in one daemon cannot collide. The + * response file is always removed at terminal. A clean log is removed unless + * profiling is active; failure/cancellation logs are retained. */ +const char *cbm_index_worker_response_path(const cbm_index_worker_handle_t *handle); +const char *cbm_index_worker_log_path(const cbm_index_worker_handle_t *handle); + +/* Terminal handles only; never waits or implicitly cancels. */ +void cbm_index_worker_destroy(cbm_index_worker_handle_t *handle); + +/* Spawn ` cli --index-worker --index-worker-build + * index_repository --response-out `, * supervise it (quiet-timeout for hangs), reap, and classify. On a clean exit, * result->response holds the worker's response string (read from the temp file). - * Returns 0 if a worker was spawned and reaped (result filled), or -1 if the - * child could not be spawned (caller degrades to in-process). + * Returns 0 if a worker reached a terminal result (including an explicitly + * flagged bounded containment failure), or -1 only if no child was spawned. * - * Probe knobs for the skip-and-continue recovery re-run (Stage 3c) are passed to - * the child as inherited env vars around the spawn (set before, unset after — - * safe because spawns are sequential): - * - single_thread → CBM_INDEX_SINGLE_THREAD=1: the pipeline uses exactly one - * worker, so a per-file marker pins the EXACT crasher. - * - marker_file → CBM_INDEX_MARKER_FILE: the worker writes the rel_path of - * the file it is about to process here before touching it. - * - quarantine_file → CBM_INDEX_QUARANTINE_FILE: newline-delimited rel_paths to - * skip and report as phase="crash". + * Probe knobs for the skip-and-continue recovery re-run (Stage 3c) are passed as + * hidden worker argv and installed only inside the exec'd worker: + * - single_thread → CBM_INDEX_SINGLE_THREAD=1 + * - marker_file → CBM_INDEX_MARKER_FILE + * - quarantine_file → CBM_INDEX_QUARANTINE_FILE * Any of the three may be false/NULL to leave that knob unset (the normal first * attempt passes single_thread=false, marker_file=NULL, quarantine_file=NULL). */ int cbm_index_spawn_worker(const char *args_json, bool single_thread, const char *marker_file, const char *quarantine_file, cbm_index_worker_result_t *result); +int cbm_index_spawn_worker_with_log( + const char *args_json, bool single_thread, const char *marker_file, + const char *quarantine_file, cbm_proc_log_cb log_callback, void *log_context, + cbm_index_worker_result_t *result); + +/* Synchronous request-owned variant. A nonzero cancellation flag is forwarded + * once to the contained worker; polling continues until the complete process + * tree is terminal, so callers never drop supervision authority on cancel. */ +int cbm_index_spawn_worker_with_log_cancel( + const char *args_json, bool single_thread, const char *marker_file, + const char *quarantine_file, cbm_proc_log_cb log_callback, + void *log_context, const atomic_int *cancel_requested, + cbm_index_worker_result_t *result); + void cbm_index_worker_result_free(cbm_index_worker_result_t *result); #endif /* CBM_INDEX_SUPERVISOR_H */ diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 804222436..b43ba5bbf 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -32,12 +32,14 @@ enum { MCP_CONTENT_PREFIX = 15, /* strlen("Content-Length:") */ MCP_RETURN_2 = 2, MCP_TOOLS_PAGE_SIZE = 8, + MCP_MAX_CROSS_REPO_TARGETS = 4096, }; #define MCP_MS_TO_US 1000LL #define MCP_S_TO_US 1000000LL #define SLEN(s) (sizeof(s) - 1) #include "mcp/mcp.h" +#include "mcp/mcp_internal.h" #include "store/store.h" #include #include "cypher/cypher.h" @@ -62,9 +64,11 @@ enum { #include "pipeline/artifact.h" #ifdef _WIN32 +#include "foundation/win_utf8.h" #include #include #include +#include #define getpid _getpid #else #include @@ -73,6 +77,7 @@ enum { #endif #include #include +#include #include // int64_t #include #include @@ -80,6 +85,7 @@ enum { #include #include #include +#include /* ── Constants ────────────────────────────────────────────────── */ @@ -99,6 +105,12 @@ enum { #define JSONRPC_INVALID_PARAMS (-32602) #define JSONRPC_INTERNAL_ERROR (-32603) +/* MCP stdio framing limits. The body limit is also the upper bound used by the + * daemon IPC transport; headers stay deliberately small to prevent a peer from + * growing getline buffers without bound through ignored extension headers. */ +#define MCP_MAX_MESSAGE_SIZE ((size_t)10U * 1024U * 1024U) +#define MCP_MAX_HEADER_SIZE ((size_t)8U * 1024U) + /* ── Helpers ────────────────────────────────────────────────────── */ static char *heap_strdup(const char *s) { @@ -1228,6 +1240,20 @@ static char *canonicalize_repo_path_if_exists(char *repo_path) { return repo_path; } +static bool repo_path_is_absolute(const char *path) { + if (!path || path[0] == '\0') { + return false; + } +#ifdef _WIN32 + /* Path separators are normalized before this helper is called. A drive- + * relative path such as "C:repo" is deliberately not considered absolute. */ + return (path[0] == '/' && path[1] == '/') || + (isalpha((unsigned char)path[0]) && path[1] == ':' && path[2] == '/'); +#else + return path[0] == '/'; +#endif +} + static char *normalize_project_arg(char *project) { if (!project || (!strchr(project, '/') && !strchr(project, '\\'))) { return project; @@ -1371,12 +1397,26 @@ struct cbm_mcp_server { char session_root[CBM_SZ_1K]; /* detected project root path */ char session_project[CBM_SZ_256]; /* derived project name */ bool session_detected; /* true after first detection attempt */ + char *allowed_root; /* explicit per-session boundary (heap, nullable) */ + bool allowed_root_policy_set; /* true even when explicit policy is unrestricted */ + bool background_tasks; /* per-server update/auto-index work enabled */ struct cbm_watcher *watcher; /* external watcher ref (not owned) */ struct cbm_config *config; /* external config ref (not owned) */ + cbm_mcp_index_executor_fn index_executor; + void *index_executor_context; + cbm_proc_log_cb index_log_callback; + void *index_log_context; + cbm_mcp_project_mutation_begin_fn mutation_begin; + cbm_mcp_project_mutation_end_fn mutation_end; + void *mutation_context; + cbm_mcp_quarantine_test_hook_fn quarantine_test_hook; + void *quarantine_test_context; cbm_thread_t autoindex_tid; bool autoindex_active; /* true if auto-index thread was started */ /* Active pipeline tracking for cancellation support */ + atomic_int pipeline_cancel_requested; + atomic_int active_pipeline_running; cbm_pipeline_t *active_pipeline; /* non-NULL while index_repository runs */ int64_t active_request_id; /* JSON-RPC id of the in-progress tool call */ char *active_request_id_str; /* string JSON-RPC id of the in-progress tool call */ @@ -1388,6 +1428,8 @@ cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path) { if (!srv) { return NULL; } + atomic_init(&srv->pipeline_cancel_requested, 0); + atomic_init(&srv->active_pipeline_running, 0); /* If a store_path is given, open that project directly. * Otherwise, create an in-memory store for test/embedded use. */ @@ -1399,6 +1441,7 @@ cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path) { } srv->owns_store = true; srv->tool_profile = CBM_MCP_TOOL_PROFILE_ALL; + srv->background_tasks = true; return srv; } @@ -1433,6 +1476,99 @@ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) { } } +bool cbm_mcp_server_set_session_context(cbm_mcp_server_t *srv, const char *session_root, + const char *allowed_root) { + if (!srv || !session_root || session_root[0] == '\0' || + strlen(session_root) >= sizeof(srv->session_root)) { + return false; + } + + char *project = cbm_project_name_from_path(session_root); + if (!project || project[0] == '\0' || strlen(project) >= sizeof(srv->session_project)) { + free(project); + return false; + } + + char *allowed_copy = allowed_root ? heap_strdup(allowed_root) : NULL; + if (allowed_root && !allowed_copy) { + free(project); + return false; + } + + snprintf(srv->session_root, sizeof(srv->session_root), "%s", session_root); + snprintf(srv->session_project, sizeof(srv->session_project), "%s", project); + free(project); + + free(srv->allowed_root); + srv->allowed_root = allowed_copy; + srv->allowed_root_policy_set = true; + srv->session_detected = true; + return true; +} + +const char *cbm_mcp_server_session_root(const cbm_mcp_server_t *srv) { + return srv ? srv->session_root : NULL; +} + +const char *cbm_mcp_server_session_project(const cbm_mcp_server_t *srv) { + return srv ? srv->session_project : NULL; +} + +const char *cbm_mcp_server_allowed_root(const cbm_mcp_server_t *srv) { + return srv ? srv->allowed_root : NULL; +} + +void cbm_mcp_server_set_background_tasks(cbm_mcp_server_t *srv, bool enabled) { + if (srv) { + srv->background_tasks = enabled; + } +} + +void cbm_mcp_server_set_index_executor(cbm_mcp_server_t *srv, + cbm_mcp_index_executor_fn executor, + void *context) { + if (srv) { + srv->index_executor = executor; + srv->index_executor_context = context; + } +} + +void cbm_mcp_server_set_index_log_callback(cbm_mcp_server_t *srv, + cbm_proc_log_cb callback, + void *context) { + if (srv) { + srv->index_log_callback = callback; + srv->index_log_context = callback ? context : NULL; + } +} + +void cbm_mcp_server_set_project_mutation_guard( + cbm_mcp_server_t *srv, cbm_mcp_project_mutation_begin_fn begin, + cbm_mcp_project_mutation_end_fn end, void *context) { + if (!srv) { + return; + } + /* A half-configured guard could acquire without releasing (or mutate + * without acquiring), so accept only complete callback pairs. */ + if ((begin == NULL) != (end == NULL)) { + return; + } + srv->mutation_begin = begin; + srv->mutation_end = end; + srv->mutation_context = begin ? context : NULL; +} + +static bool mcp_project_mutation_begin(cbm_mcp_server_t *srv, const char *project) { + return !srv->mutation_begin || + srv->mutation_begin(srv->mutation_context, project); +} + +static void mcp_project_mutation_end(cbm_mcp_server_t *srv, const char *project) { + if (srv->mutation_end) { + srv->mutation_end(srv->mutation_context, project); + } +} + void cbm_mcp_server_free(cbm_mcp_server_t *srv) { if (!srv) { return; @@ -1447,6 +1583,7 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv) { cbm_store_close(srv->store); } free(srv->current_project); + free(srv->allowed_root); free(srv->active_request_id_str); free(srv); } @@ -1481,10 +1618,43 @@ bool cbm_mcp_server_has_cached_store(cbm_mcp_server_t *srv) { return (srv && srv->store != NULL) != 0; } +bool cbm_mcp_server_release_pristine_memory_store(cbm_mcp_server_t *srv) { + const char *db_path = + srv && srv->store ? cbm_store_db_path(srv->store) : NULL; + if (!srv || !srv->owns_store || !srv->store || srv->current_project || + srv->store_last_used != 0 || db_path != NULL) { + return false; + } + cbm_store_close(srv->store); + srv->store = NULL; + return true; +} + cbm_pipeline_t *cbm_mcp_server_active_pipeline(cbm_mcp_server_t *srv) { return srv ? srv->active_pipeline : NULL; } +bool cbm_mcp_server_cancel_active(cbm_mcp_server_t *srv) { + if (!srv) { + return false; + } + if (atomic_load_explicit(&srv->active_pipeline_running, memory_order_acquire) == 0) { + return false; + } + atomic_store_explicit(&srv->pipeline_cancel_requested, 1, memory_order_release); + return true; +} + +void cbm_mcp_server_set_quarantine_test_hook(cbm_mcp_server_t *srv, + cbm_mcp_quarantine_test_hook_fn hook, + void *context) { + if (!srv) { + return; + } + srv->quarantine_test_hook = hook; + srv->quarantine_test_context = context; +} + /* ── Cache dir + project DB path helpers ───────────────────────── */ /* Returns the cache directory. Writes to buf, returns buf for convenience. */ @@ -1527,10 +1697,194 @@ static bool db_internal_project_name(const char *full_path, char *name_out, size * passed name (drifted filename). Defined after is_project_db_file below. */ static cbm_store_t *resolve_store_fallback_scan(const char *project); +static bool reserve_unique_corrupt_pending(const char *path, char *pending, + size_t pending_size, char *backup, + size_t backup_size) { + static atomic_uint_fast64_t sequence = 0; + for (unsigned int attempt = 0; attempt < 128; attempt++) { + uint64_t token = cbm_now_ns() ^ ((uint64_t)(unsigned int)getpid() << 32) ^ + atomic_fetch_add_explicit(&sequence, 1, memory_order_relaxed); + int backup_written = snprintf(backup, backup_size, "%s.corrupt.%016llx", path, + (unsigned long long)token); + int pending_written = snprintf(pending, pending_size, + "%s.corrupt.pending.%016llx", path, + (unsigned long long)token); + if (backup_written <= 0 || (size_t)backup_written >= backup_size || + pending_written <= 0 || (size_t)pending_written >= pending_size) { + return false; + } + if (cbm_file_exists(backup)) { + continue; + } +#ifdef _WIN32 + wchar_t *wide = cbm_utf8_to_wide(pending); + HANDLE file = wide ? CreateFileW(wide, GENERIC_READ | GENERIC_WRITE, 0, NULL, + CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL) + : INVALID_HANDLE_VALUE; + DWORD create_error = file == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS; + free(wide); + if (file != INVALID_HANDLE_VALUE) { + CloseHandle(file); + return true; + } + if (create_error != ERROR_FILE_EXISTS && create_error != ERROR_ALREADY_EXISTS) { + return false; + } +#else + int fd = open(pending, O_WRONLY | O_CREAT | O_EXCL, 0600); + if (fd >= 0) { + (void)close(fd); + return true; + } + if (errno != EEXIST) { + return false; + } +#endif + } + return false; +} + +static void discard_corrupt_pending(const char *pending) { + if (!pending) { + return; + } + (void)cbm_remove_db_sidecars(pending); + (void)cbm_unlink(pending); +} + +#ifndef _WIN32 +static bool sync_parent_directory(const char *path) { + char directory[CBM_SZ_2K]; + int written = snprintf(directory, sizeof(directory), "%s", path ? path : ""); + if (written <= 0 || (size_t)written >= sizeof(directory)) { + return false; + } + char *slash = strrchr(directory, '/'); + if (!slash) { + snprintf(directory, sizeof(directory), "."); + } else if (slash == directory) { + slash[1] = '\0'; + } else { + *slash = '\0'; + } + int fd = open(directory, O_RDONLY | O_DIRECTORY); + if (fd < 0) { + return false; + } + int rc; + do { + rc = fsync(fd); + } while (rc != 0 && errno == EINTR); + (void)close(fd); + return rc == 0; +} +#endif + +/* Publish only a fully closed SQLite snapshot, without ever replacing a prior + * recovery file. POSIX link() and Windows MoveFileExW without REPLACE are + * atomic no-clobber operations within the cache directory. */ +static bool publish_corrupt_backup(const char *pending, const char *backup) { +#ifdef _WIN32 + wchar_t *wide_pending = cbm_utf8_to_wide(pending); + wchar_t *wide_backup = cbm_utf8_to_wide(backup); + bool published = wide_pending && wide_backup && + MoveFileExW(wide_pending, wide_backup, MOVEFILE_WRITE_THROUGH) != 0; + free(wide_pending); + free(wide_backup); + return published; +#else + if (link(pending, backup) != 0) { + return false; + } + if (!sync_parent_directory(backup)) { + (void)cbm_unlink(backup); + return false; + } + /* A crash before this cleanup merely leaves a second link to the same + * complete snapshot; the published recovery generation is already safe. */ + (void)cbm_unlink(pending); + (void)sync_parent_directory(backup); + return true; +#endif +} + +static bool quarantine_step_allowed(cbm_mcp_server_t *srv, const char *step) { + return !srv || !srv->quarantine_test_hook || + srv->quarantine_test_hook(srv->quarantine_test_context, step); +} + +/* Create one transactionally consistent, self-contained recovery snapshot + * (SQLite backup incorporates committed WAL frames), publish it atomically, + * and only then remove the corrupt live generation. A crash can therefore + * leave the live DB, the completed backup, or both, but never destroys the + * only recoverable generation. */ +static bool quarantine_corrupt_store(cbm_mcp_server_t *srv, const char *project, + const char *path, + char *backup_out, size_t backup_out_size) { + char backup[CBM_SZ_2K]; + char pending[CBM_SZ_2K]; + if (!reserve_unique_corrupt_pending(path, pending, sizeof(pending), backup, + sizeof(backup))) { + cbm_log_error("store.auto_clean_failed", "project", project, "path", path, + "reason", "cannot reserve unique backup"); + return false; + } + + if (cbm_store_backup_path(path, pending) != CBM_STORE_OK || + cbm_store_prepare_path_for_replace(pending) != CBM_STORE_OK) { + discard_corrupt_pending(pending); + cbm_log_error("store.auto_clean_failed", "project", project, "path", path, + "reason", "cannot create self-contained recovery snapshot"); + return false; + } + + cbm_store_t *snapshot = cbm_store_open_path_query(pending); + if (!snapshot) { + discard_corrupt_pending(pending); + cbm_log_error("store.auto_clean_failed", "project", project, "path", path, + "reason", "recovery snapshot cannot be reopened"); + return false; + } + cbm_store_close(snapshot); + + if (!quarantine_step_allowed(srv, "before_snapshot_publish") || + !publish_corrupt_backup(pending, backup)) { + discard_corrupt_pending(pending); + cbm_log_error("store.auto_clean_failed", "project", project, "path", path, + "reason", "cannot atomically publish recovery snapshot"); + return false; + } + discard_corrupt_pending(pending); + + if (!quarantine_step_allowed(srv, "after_snapshot_publish")) { + cbm_log_error("store.auto_clean_failed", "project", project, "path", path, + "reason", "backup complete; live generation retained", "backup", + backup); + return false; + } + + if (cbm_unlink(path) != 0 && errno != ENOENT) { + cbm_log_error("store.auto_clean_failed", "project", project, "path", path, + "reason", "backup complete; live database removal failed", "backup", + backup); + return false; + } + if (cbm_remove_db_sidecars(path) != 0) { + cbm_log_error("store.auto_clean_sidecars", "project", project, "path", path, + "reason", "backup complete; stale sidecar cleanup deferred"); + } + + if (backup_out && backup_out_size > 0) { + snprintf(backup_out, backup_out_size, "%s", backup); + } + return true; +} + /* Open the right project's .db file for query tools. * Caches the connection — reopens only when project changes. * Tracks last-access time so the event loop can evict idle stores. */ -static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { +static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *project, + bool mutation_already_held) { if (!project) { return NULL; /* project is required — no implicit fallback */ } @@ -1556,26 +1910,37 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { if (srv->store) { /* Check DB integrity — back up (never silently delete) a corrupt DB */ if (!cbm_store_check_integrity(srv->store)) { - cbm_log_error("store.auto_clean", "project", project, "path", path, "action", - "backing up corrupt db to .corrupt — re-index required"); cbm_store_close(srv->store); srv->store = NULL; - /* #557 (data loss): rename the corrupt DB to a .corrupt backup instead - * of unlinking it, so the user's graph is recoverable / reportable. - * Re-index rebuilds a fresh DB at `path`. WAL/SHM are transient. */ - char bak_path[MCP_FIELD_SIZE]; - snprintf(bak_path, sizeof(bak_path), "%s.corrupt", path); - cbm_unlink(bak_path); /* clear any prior backup so rename succeeds on Windows */ - if (rename(path, bak_path) != 0) { - cbm_unlink(path); /* rename failed (e.g. cross-device) — fall back to delete */ + bool mutation_acquired = mutation_already_held; + if (!mutation_acquired) { + mutation_acquired = mcp_project_mutation_begin(srv, project); + } + if (!mutation_acquired) { + return NULL; + } + + /* The lease may have waited behind a publisher. Re-open and trust + * only the current generation, never the stale pre-wait verdict. */ + srv->store = cbm_store_open_path_query(path); + bool current_valid = srv->store && cbm_store_check_integrity(srv->store); + if (!current_valid) { + cbm_store_close(srv->store); + srv->store = NULL; + char backup[CBM_SZ_2K] = {0}; + bool quarantined = quarantine_corrupt_store( + srv, project, path, backup, sizeof(backup)); + cbm_log_error("store.auto_clean", "project", project, "path", path, + "action", quarantined ? "corrupt generation quarantined" + : "corrupt generation preserved", + "backup", quarantined ? backup : "none"); + } + if (!mutation_already_held) { + mcp_project_mutation_end(srv, project); + } + if (!srv->store) { + return NULL; } - char wal_path[MCP_FIELD_SIZE]; - char shm_path[MCP_FIELD_SIZE]; - snprintf(wal_path, sizeof(wal_path), "%s-wal", path); - snprintf(shm_path, sizeof(shm_path), "%s-shm", path); - cbm_unlink(wal_path); - cbm_unlink(shm_path); - return NULL; } /* Verify the project actually exists in this database. @@ -1614,6 +1979,10 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { return srv->store; } +static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { + return resolve_store_internal(srv, project, false); +} + /* Forward decl — definition lives below alongside list_projects. */ static bool is_project_db_file(const char *name, size_t len); @@ -3615,6 +3984,11 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { if (!name) { return cbm_mcp_text_result("project is required", true); } + if (!mcp_project_mutation_begin(srv, name)) { + free(name); + return cbm_mcp_text_result( + "project operation cancelled or blocked by an active index", true); + } /* Close store if it's the project being deleted */ if (srv->current_project && strcmp(srv->current_project, name) == 0) { @@ -3665,6 +4039,7 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { } cbm_mem_collect(); /* return freed pages to OS after closing database */ + mcp_project_mutation_end(srv, name); yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); @@ -4935,12 +5310,8 @@ static char *read_file_lines(const char *path, int start, int end) { /* ── Helper: get project root_path from store ─────────────────── */ -static char *get_project_root(cbm_mcp_server_t *srv, const char *project) { - if (!project) { - return NULL; - } - cbm_store_t *store = resolve_store(srv, project); - if (!store) { +static char *project_root_from_store(cbm_store_t *store, const char *project) { + if (!store || !project) { return NULL; } cbm_project_t proj = {0}; @@ -4954,11 +5325,74 @@ static char *get_project_root(cbm_mcp_server_t *srv, const char *project) { return root; } +static char *get_project_root(cbm_mcp_server_t *srv, const char *project) { + return project_root_from_store(resolve_store(srv, project), project); +} + /* ── index_repository ─────────────────────────────────────────── */ +static int cross_repo_project_key_compare(const void *left, const void *right) { + const char *const *left_key = left; + const char *const *right_key = right; + return strcmp(*left_key, *right_key); +} + +static unsigned char cross_repo_project_lock_fold(unsigned char ch) { + return ch >= 'A' && ch <= 'Z' ? (unsigned char)(ch + ('a' - 'A')) : ch; +} + +/* Match daemon/project_lock.c's OS-key identity exactly: only ASCII A-Z folds. + * The raw strcmp tie-break gives qsort a total, input-order-independent order + * while keeping the caller's original project spelling as the lease value. */ +static int cross_repo_project_lock_key_compare_values(const char *left, + const char *right) { + const unsigned char *left_cursor = (const unsigned char *)left; + const unsigned char *right_cursor = (const unsigned char *)right; + while (*left_cursor && *right_cursor) { + unsigned char left_folded = cross_repo_project_lock_fold(*left_cursor); + unsigned char right_folded = cross_repo_project_lock_fold(*right_cursor); + if (left_folded != right_folded) { + return left_folded < right_folded ? -1 : 1; + } + left_cursor++; + right_cursor++; + } + if (*left_cursor != *right_cursor) { + return *left_cursor ? 1 : -1; + } + return strcmp(left, right); +} + +static int cross_repo_project_lock_key_compare(const void *left, const void *right) { + const char *const *left_key = left; + const char *const *right_key = right; + return cross_repo_project_lock_key_compare_values(*left_key, *right_key); +} + +static bool cross_repo_project_lock_keys_equivalent(const char *left, + const char *right) { + const unsigned char *left_cursor = (const unsigned char *)left; + const unsigned char *right_cursor = (const unsigned char *)right; + while (*left_cursor && *right_cursor) { + if (cross_repo_project_lock_fold(*left_cursor) != + cross_repo_project_lock_fold(*right_cursor)) { + return false; + } + left_cursor++; + right_cursor++; + } + return *left_cursor == *right_cursor; +} + /* Handle mode="cross-repo-intelligence" — extract to reduce complexity. */ -static char *handle_cross_repo_mode(const char *repo_path, const char *args) { - char *project = heap_strdup(cbm_project_name_from_path(repo_path)); +static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path, + const char *name_override, const char *args) { + if (name_override && name_override[0] && !cbm_validate_project_name(name_override)) { + return cbm_mcp_text_result("invalid project name", true); + } + char *project = name_override && name_override[0] + ? heap_strdup(name_override) + : cbm_project_name_from_path(repo_path); if (!project) { return cbm_mcp_text_result("cannot derive project name", true); } @@ -4976,28 +5410,146 @@ static char *handle_cross_repo_mode(const char *repo_path, const char *args) { true); } - int tp_count = (int)yyjson_arr_size(tp_arr); - const char **targets = malloc((size_t)tp_count * sizeof(char *)); + size_t target_count = yyjson_arr_size(tp_arr); + if (target_count > MCP_MAX_CROSS_REPO_TARGETS) { + yyjson_doc_free(jdoc); + free(project); + return cbm_mcp_text_result("too many cross-repo target projects", true); + } + int tp_count = (int)target_count; + const char **targets = malloc((size_t)tp_count * sizeof(*targets)); + const char **lease_keys = malloc(((size_t)tp_count + 1U) * sizeof(*lease_keys)); + if (!targets || !lease_keys) { + free(targets); + free(lease_keys); + yyjson_doc_free(jdoc); + free(project); + return cbm_mcp_text_result("failed to allocate cross-repo project leases", true); + } size_t idx; size_t max; yyjson_val *val; int ti = 0; + bool all_projects = false; + bool invalid_target = false; yyjson_arr_foreach(tp_arr, idx, max, val) { - targets[ti++] = yyjson_get_str(val); + const char *target = yyjson_is_str(val) ? yyjson_get_str(val) : NULL; + if (!target || !target[0] || strlen(target) >= CBM_SZ_256 || + (strcmp(target, "*") != 0 && !cbm_validate_project_name(target))) { + invalid_target = true; + break; + } + targets[ti++] = target; + all_projects = all_projects || strcmp(target, "*") == 0; + } + if (invalid_target || ti != tp_count) { + free(targets); + free(lease_keys); + yyjson_doc_free(jdoc); + free(project); + return cbm_mcp_text_result("target_projects must contain valid project names or '*'", + true); + } + if (all_projects && tp_count != 1) { + free(targets); + free(lease_keys); + yyjson_doc_free(jdoc); + free(project); + return cbm_mcp_text_result( + "target_projects wildcard '*' must be the only entry", true); + } + if (!all_projects) { + qsort(targets, (size_t)tp_count, sizeof(*targets), + cross_repo_project_key_compare); + int unique_count = 0; + for (int i = 0; i < tp_count; i++) { + if (unique_count == 0 || + strcmp(targets[i], targets[unique_count - 1]) != 0) { + targets[unique_count++] = targets[i]; + } + } + tp_count = unique_count; + } + + int lease_count = 0; + if (all_projects) { + lease_keys[lease_count++] = "*"; + } else { + lease_keys[lease_count++] = project; + for (int i = 0; i < tp_count; i++) { + lease_keys[lease_count++] = targets[i]; + } + qsort(lease_keys, (size_t)lease_count, sizeof(*lease_keys), + cross_repo_project_lock_key_compare); + int unique_count = 0; + for (int i = 0; i < lease_count; i++) { + if (unique_count == 0 || + !cross_repo_project_lock_keys_equivalent( + lease_keys[i], lease_keys[unique_count - 1])) { + lease_keys[unique_count++] = lease_keys[i]; + } + } + lease_count = unique_count; } - cbm_cross_repo_result_t result = cbm_cross_repo_match(project, targets, tp_count); + atomic_store_explicit(&srv->pipeline_cancel_requested, 0, memory_order_release); + atomic_store_explicit(&srv->active_pipeline_running, 1, memory_order_release); + int held_count = 0; + while (held_count < lease_count && + mcp_project_mutation_begin(srv, lease_keys[held_count])) { + held_count++; + } + bool cancelled = atomic_load_explicit(&srv->pipeline_cancel_requested, + memory_order_acquire) != 0; + if (held_count != lease_count || cancelled) { + while (held_count > 0) { + held_count--; + mcp_project_mutation_end(srv, lease_keys[held_count]); + } + atomic_store_explicit(&srv->active_pipeline_running, 0, memory_order_release); + free(targets); + free(lease_keys); + yyjson_doc_free(jdoc); + free(project); + return cbm_mcp_text_result( + "cross-repo operation cancelled or blocked by active indexing", true); + } + + cbm_cross_repo_result_t result = cbm_cross_repo_match_cancellable( + project, targets, tp_count, &srv->pipeline_cancel_requested); + atomic_store_explicit(&srv->active_pipeline_running, 0, memory_order_release); + while (held_count > 0) { + held_count--; + mcp_project_mutation_end(srv, lease_keys[held_count]); + } free(targets); + free(lease_keys); yyjson_doc_free(jdoc); + if (result.failed) { + free(project); + return cbm_mcp_text_result( + "cross-repo source or target project is missing, invalid, or not indexed", + true); + } + int total = result.http_edges + result.async_edges + result.channel_edges + result.grpc_edges + result.graphql_edges + result.trpc_edges; yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root); - yyjson_mut_obj_add_str(doc, root, "status", "success"); + yyjson_mut_obj_add_str(doc, root, "status", result.cancelled ? "cancelled" : "success"); yyjson_mut_obj_add_str(doc, root, "mode", "cross-repo-intelligence"); yyjson_mut_obj_add_strcpy(doc, root, "project", project); + if (result.cancelled) { + yyjson_mut_obj_add_bool(doc, root, "partial_results", result.partial_results); + yyjson_mut_obj_add_str( + doc, root, "message", + result.partial_results + ? "cross-repo operation cancelled with partial results; completed database " + "writes were retained" + : "cross-repo operation cancelled before database writes"); + } yyjson_mut_obj_add_int(doc, root, "projects_scanned", result.projects_scanned); yyjson_mut_obj_add_int(doc, root, "cross_http_calls", result.http_edges); yyjson_mut_obj_add_int(doc, root, "cross_async_calls", result.async_edges); @@ -5011,7 +5563,7 @@ static char *handle_cross_repo_mode(const char *repo_path, const char *args) { char *json = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); free(project); - char *out = cbm_mcp_text_result(json, false); + char *out = cbm_mcp_text_result(json, result.cancelled); free(json); return out; } @@ -5347,13 +5899,24 @@ static char *build_worker_failure_response(const char *args, cbm_proc_outcome_t yyjson_mut_doc_set_root(doc, root); yyjson_mut_obj_add_str(doc, root, "status", "error"); yyjson_mut_obj_add_str(doc, root, "outcome", cbm_proc_outcome_str(outcome)); - yyjson_mut_obj_add_str( - doc, root, "hint", - outcome == CBM_PROC_HANG - ? "Indexing worker timed out (a file made no progress). The worker was " - "terminated and the server survived. Re-run to retry." - : "Indexing worker crashed on a file. The crash was contained (the server " - "survived). Re-run to retry; a future release isolates the culprit file."); + const char *hint = NULL; + if (outcome == CBM_PROC_SPAWN_FAILED) { + hint = "Indexing worker could not be started. Supervision is mandatory in a CBM " + "host, so no in-process fallback was attempted."; + } else if (outcome == CBM_PROC_HANG) { + hint = "Indexing worker timed out (a file made no progress). The worker was " + "terminated and the server survived. Re-run to retry."; + } else if (outcome == CBM_PROC_CRASH) { + hint = "Indexing worker crashed on a file. The crash was contained (the server " + "survived). Re-run to retry; a future release isolates the culprit file."; + } else if (outcome == CBM_PROC_CLEAN) { + hint = "Indexing worker exited without a valid response. No in-process fallback " + "was attempted."; + } else { + hint = "Indexing worker did not complete successfully. The failure was contained " + "and no in-process fallback was attempted."; + } + yyjson_mut_obj_add_str(doc, root, "hint", hint); if (repo_path) { yyjson_mut_obj_add_strcpy(doc, root, "repo_path", repo_path); } @@ -5365,12 +5928,37 @@ static char *build_worker_failure_response(const char *args, cbm_proc_outcome_t return result; } +static char *build_worker_unsafe_terminal_response(const char *args, cbm_proc_outcome_t outcome, + bool cancellation_requested) { + char *repo_path = cbm_mcp_get_string_arg(args, "repo_path"); + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_str(doc, root, "status", "error"); + yyjson_mut_obj_add_str(doc, root, "outcome", cbm_proc_outcome_str(outcome)); + yyjson_mut_obj_add_str( + doc, root, "hint", + cancellation_requested + ? "Indexing worker was cancelled. No in-process retry was started." + : "Indexing worker process-tree containment failed. No in-process retry was " + "started; inspect daemon logs."); + if (repo_path) { + yyjson_mut_obj_add_strcpy(doc, root, "repo_path", repo_path); + } + char *json = yy_doc_to_str(doc); + yyjson_mut_doc_free(doc); + free(repo_path); + char *response = cbm_mcp_text_result(json, true); + free(json); + return response; +} + /* Drop the cached store so the next query reopens whatever the worker wrote (each * worker is a fresh process that deletes + recreates the .db). NULL-safe: the * background watcher path (main.c) has no MCP server / cached store — the child * writes the DB and the parent only needs the return code, so there is nothing * to invalidate. */ -static void supervisor_invalidate_store(cbm_mcp_server_t *srv) { +static void invalidate_cached_store(cbm_mcp_server_t *srv) { if (!srv) { return; } @@ -5499,6 +6087,22 @@ static bool supervisor_append_quarantine(const char *path, const char *rel, cons return true; } +cbm_mcp_supervised_result_disposition_t cbm_mcp_supervised_result_disposition( + int spawn_result, const cbm_index_worker_result_t *worker_result) { + if (spawn_result != 0 || !worker_result || worker_result->outcome == CBM_PROC_SPAWN_FAILED) { + return CBM_MCP_SUPERVISED_RESULT_FALLBACK; + } + if (worker_result->cancellation_requested || !worker_result->tree_quiesced || + worker_result->supervision_failed) { + return CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL; + } + if (worker_result->outcome == CBM_PROC_CLEAN) { + return worker_result->response ? CBM_MCP_SUPERVISED_RESULT_SUCCESS + : CBM_MCP_SUPERVISED_RESULT_FALLBACK; + } + return CBM_MCP_SUPERVISED_RESULT_CONTAINED_FAILURE; +} + /* Run index_repository in a supervised worker subprocess with skip-and-continue * (Stage 3c). Returns the response string (caller frees): * - the worker's own response on a clean first run (the common path); @@ -5508,30 +6112,39 @@ static bool supervisor_append_quarantine(const char *path, const char *rel, cons * - a best-effort PARTIAL index (one final quarantine-only run) if the recovery * loop cannot converge but at least one file was quarantined; * - a contained-failure response only if even that cannot produce a clean run. - * Returns NULL only when the worker could not be spawned at all, so the caller - * degrades to the in-process path. */ + * A physical CBM host never falls back to its in-process pipeline: an initial + * start/protocol failure is returned as an explicit error response. */ static char *index_run_supervised(cbm_mcp_server_t *srv, const char *args) { - supervisor_invalidate_store(srv); + invalidate_cached_store(srv); /* First attempt: normal parallel run. */ cbm_index_worker_result_t wr; - int rc = cbm_index_spawn_worker(args, false, NULL, NULL, &wr); - - if (rc != 0 || wr.outcome == CBM_PROC_SPAWN_FAILED) { + int rc = cbm_index_spawn_worker_with_log_cancel( + args, false, NULL, NULL, srv ? srv->index_log_callback : NULL, + srv ? srv->index_log_context : NULL, + srv ? &srv->pipeline_cancel_requested : NULL, &wr); + cbm_mcp_supervised_result_disposition_t disposition = + cbm_mcp_supervised_result_disposition(rc, &wr); + + if (disposition == CBM_MCP_SUPERVISED_RESULT_FALLBACK) { + cbm_proc_outcome_t outcome = wr.outcome; + cbm_index_worker_result_free(&wr); + invalidate_cached_store(srv); + return build_worker_failure_response(args, outcome); + } + if (disposition == CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL) { + char *failure = + build_worker_unsafe_terminal_response(args, wr.outcome, wr.cancellation_requested); cbm_index_worker_result_free(&wr); - supervisor_invalidate_store(srv); - return NULL; /* degrade to in-process */ - } - if (wr.outcome == CBM_PROC_CLEAN) { - /* Clean exit → transfer the worker's response (the common path). If the - * worker exited clean but wrote no response (a degenerate case, e.g. a - * self binary that does not act as an index worker), resp is NULL and the - * caller degrades to the in-process path — a clean run never needs the - * crash-recovery loop. */ + invalidate_cached_store(srv); + return failure; + } + if (disposition == CBM_MCP_SUPERVISED_RESULT_SUCCESS) { + /* Clean exit → transfer the worker's response (the common path). */ char *resp = wr.response; /* transfer ownership to caller (may be NULL) */ wr.response = NULL; cbm_index_worker_result_free(&wr); - supervisor_invalidate_store(srv); + invalidate_cached_store(srv); return resp; } @@ -5578,16 +6191,30 @@ static char *index_run_supervised(cbm_mcp_server_t *srv, const char *args) { int quarantined = 0; /* files pinned + added to the quarantine list so far */ char **prev_suspects = NULL; /* previous failed round's in-flight set */ int prev_n = 0; + bool unsafe_terminal = false; + bool terminal_cancelled = false; for (int i = 0; i < cap; i++) { cbm_index_worker_result_t wr2; - int rc2 = cbm_index_spawn_worker(args, /*single_thread=*/false, marker_path, - quarantine_path, &wr2); - if (rc2 != 0) { + int rc2 = cbm_index_spawn_worker_with_log_cancel( + args, /*single_thread=*/false, marker_path, quarantine_path, + srv ? srv->index_log_callback : NULL, + srv ? srv->index_log_context : NULL, + srv ? &srv->pipeline_cancel_requested : NULL, &wr2); + cbm_mcp_supervised_result_disposition_t recovery_disposition = + cbm_mcp_supervised_result_disposition(rc2, &wr2); + if (recovery_disposition == CBM_MCP_SUPERVISED_RESULT_FALLBACK) { last_outcome = wr2.outcome; cbm_index_worker_result_free(&wr2); break; /* spawn failed mid-recovery — give up */ } - if (wr2.outcome == CBM_PROC_CLEAN && wr2.response) { + if (recovery_disposition == CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL) { + last_outcome = wr2.outcome; + unsafe_terminal = true; + terminal_cancelled = wr2.cancellation_requested; + cbm_index_worker_result_free(&wr2); + break; + } + if (recovery_disposition == CBM_MCP_SUPERVISED_RESULT_SUCCESS) { resp = wr2.response; /* transfer ownership to caller */ wr2.response = NULL; cbm_index_worker_result_free(&wr2); @@ -5658,27 +6285,39 @@ static char *index_run_supervised(cbm_mcp_server_t *srv, const char *args) { * a PARTIAL index (all good files indexed, all known crashers/hangs reported * as skips) rather than a hard failure. Bounded by the same quiet-timeout, * so it cannot itself hang. Rare given monotonic progress. */ - if (!resp && quarantined > 0) { + if (!resp && !unsafe_terminal && quarantined > 0) { cbm_index_worker_result_t wrp; - int rcp = - cbm_index_spawn_worker(args, /*single_thread=*/false, NULL, quarantine_path, &wrp); - if (rcp == 0 && wrp.outcome == CBM_PROC_CLEAN && wrp.response) { + int rcp = cbm_index_spawn_worker_with_log_cancel( + args, /*single_thread=*/false, NULL, quarantine_path, + srv ? srv->index_log_callback : NULL, + srv ? srv->index_log_context : NULL, + srv ? &srv->pipeline_cancel_requested : NULL, &wrp); + cbm_mcp_supervised_result_disposition_t partial_disposition = + cbm_mcp_supervised_result_disposition(rcp, &wrp); + if (partial_disposition == CBM_MCP_SUPERVISED_RESULT_SUCCESS) { resp = wrp.response; /* transfer ownership to caller */ wrp.response = NULL; char qn[MCP_FIELD_SIZE]; snprintf(qn, sizeof(qn), "%d", quarantined); cbm_log_error("index.supervisor.partial", "quarantined", qn, "outcome", cbm_proc_outcome_str(last_outcome)); + } else if (partial_disposition == CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL) { + last_outcome = wrp.outcome; + unsafe_terminal = true; + terminal_cancelled = wrp.cancellation_requested; } cbm_index_worker_result_free(&wrp); } (void)remove(quarantine_path); - supervisor_invalidate_store(srv); + invalidate_cached_store(srv); if (resp) { return resp; } + if (unsafe_terminal) { + return build_worker_unsafe_terminal_response(args, last_outcome, terminal_cancelled); + } return build_worker_failure_response(args, last_outcome); } @@ -5712,17 +6351,65 @@ char *cbm_mcp_index_run_supervised_path(const char *root_path) { bool cbm_path_within_root(const char *root_path, const char *abs_path); /* defined below */ -static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { - /* Supervisor gate: run the index in a crash/hang-isolating worker subprocess - * unless this process IS the worker or the kill switch (CBM_INDEX_SUPERVISOR=0) - * is set. On spawn failure, fall through to the in-process path (degrade). */ - if (cbm_index_supervisor_should_wrap()) { - char *supervised = index_run_supervised(srv, args); - if (supervised) { - return supervised; - } +/* Resolve relative index requests against an explicitly supplied MCP session + * root, never against the long-lived daemon process cwd. */ +static bool resolve_session_repo_path(cbm_mcp_server_t *srv, char **repo_path) { + if (!srv || !repo_path || !*repo_path || !srv->allowed_root_policy_set || + srv->session_root[0] == '\0' || repo_path_is_absolute(*repo_path)) { + return true; + } + + size_t root_len = strlen(srv->session_root); + size_t path_len = strlen(*repo_path); + bool needs_separator = root_len > 0 && srv->session_root[root_len - 1] != '/'; + if (root_len > SIZE_MAX - path_len - (needs_separator ? 2U : 1U)) { + return false; + } + + size_t joined_size = root_len + (needs_separator ? 1U : 0U) + path_len + 1U; + char *joined = malloc(joined_size); + if (!joined) { + return false; + } + (void)snprintf(joined, joined_size, "%s%s%s", srv->session_root, needs_separator ? "/" : "", + *repo_path); + cbm_normalize_path_sep(joined); + free(*repo_path); + *repo_path = joined; + return true; +} + +/* Preserve every index option while replacing all caller-supplied repo_path + * keys with the one canonical path that was actually authorized. */ +static char *index_args_with_repo_path(const char *args, const char *canonical_repo_path) { + if (!args || !canonical_repo_path) { + return NULL; + } + yyjson_doc *source = yyjson_read(args, strlen(args), 0); + yyjson_val *source_root = source ? yyjson_doc_get_root(source) : NULL; + if (!source_root || !yyjson_is_obj(source_root)) { + yyjson_doc_free(source); + return NULL; + } + + yyjson_mut_doc *copy = yyjson_doc_mut_copy(source, NULL); + yyjson_doc_free(source); + yyjson_mut_val *copy_root = copy ? yyjson_mut_doc_get_root(copy) : NULL; + if (!copy_root || !yyjson_mut_is_obj(copy_root)) { + yyjson_mut_doc_free(copy); + return NULL; + } + (void)yyjson_mut_obj_remove_key(copy_root, "repo_path"); + if (!yyjson_mut_obj_add_strcpy(copy, copy_root, "repo_path", canonical_repo_path)) { + yyjson_mut_doc_free(copy); + return NULL; } + char *rewritten = yy_doc_to_str(copy); + yyjson_mut_doc_free(copy); + return rewritten; +} +static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { char *repo_path = cbm_mcp_get_string_arg(args, "repo_path"); char *mode_str = cbm_mcp_get_string_arg(args, "mode"); char *name_override = cbm_mcp_get_string_arg(args, "name"); @@ -5734,14 +6421,20 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result("repo_path is required", true); } + if (!resolve_session_repo_path(srv, &repo_path)) { + free(mode_str); + free(name_override); + free(repo_path); + return cbm_mcp_text_result("failed to resolve repo_path", true); + } + repo_path = canonicalize_repo_path_if_exists(repo_path); - /* Optional workspace boundary: when CBM_ALLOWED_ROOT is set (agentic / - * multi-tenant deployments where repo_path may be influenced by an - * untrusted caller), refuse to index a path that resolves outside it. - * Unset by default, so the standard "index the path I gave you" behaviour - * is unchanged. */ - const char *allowed_root = getenv("CBM_ALLOWED_ROOT"); + /* Optional workspace boundary. Embedded/daemon sessions always use their + * explicit policy, including an explicit NULL meaning unrestricted. A + * standalone server retains the process-wide CBM_ALLOWED_ROOT fallback. */ + const char *allowed_root = + srv->allowed_root_policy_set ? srv->allowed_root : getenv("CBM_ALLOWED_ROOT"); if (allowed_root && allowed_root[0] && repo_path && !cbm_path_within_root(allowed_root, repo_path)) { free(mode_str); @@ -5752,12 +6445,90 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) { free(mode_str); + char *result = handle_cross_repo_mode(srv, repo_path, name_override, args); free(name_override); - char *result = handle_cross_repo_mode(repo_path, args); free(repo_path); return result; } + /* A daemon session delegates the one physical write to its shared job + * registry only after path canonicalization and workspace authorization. */ + if (srv->index_executor) { + char *worker_args = index_args_with_repo_path(args, repo_path); + char *coordinated = worker_args + ? srv->index_executor(srv->index_executor_context, + repo_path, worker_args) + : NULL; + free(worker_args); + free(repo_path); + free(mode_str); + free(name_override); + return coordinated ? coordinated + : cbm_mcp_text_result( + "daemon index coordinator could not start the operation", true); + } + + /* Resolve the exact project key before choosing supervised or in-process + * execution. A supervised worker owns the OS mutation lease itself: if the + * CLI parent is killed, the worker must keep project exclusion until its + * parent-death watchdog reaps the complete worker tree. */ + char *mutation_project = cbm_project_name_from_path( + name_override && name_override[0] ? name_override : repo_path); + if (!mutation_project) { + free(repo_path); + free(mode_str); + free(name_override); + return cbm_mcp_text_result("could not resolve index project name", true); + } + + /* Supervisor gate: validate the canonical path and the host session's + * workspace policy before handing work to a crash/hang-isolating worker. + * The parent deliberately owns no project lease on this path; the worker + * installs the same guard before running the in-process pipeline. A marked + * host fails closed if preparation or worker startup cannot complete. */ + if (cbm_index_supervisor_should_wrap()) { + char *worker_args = index_args_with_repo_path(args, repo_path); + if (!worker_args) { + free(mutation_project); + free(repo_path); + free(mode_str); + free(name_override); + return cbm_mcp_text_result("failed to prepare supervised index request", true); + } + atomic_store_explicit(&srv->pipeline_cancel_requested, 0, + memory_order_release); + atomic_store_explicit(&srv->active_pipeline_running, 1, + memory_order_release); + char *supervised = index_run_supervised(srv, worker_args); + atomic_store_explicit(&srv->active_pipeline_running, 0, + memory_order_release); + free(worker_args); + if (supervised) { + free(mutation_project); + free(repo_path); + free(mode_str); + free(name_override); + return supervised; + } + free(mutation_project); + free(repo_path); + free(mode_str); + free(name_override); + return cbm_mcp_text_result( + "index supervision failed before a contained worker could start; no " + "in-process fallback was attempted", + true); + } + + if (!mcp_project_mutation_begin(srv, mutation_project)) { + free(mutation_project); + free(repo_path); + free(mode_str); + free(name_override); + return cbm_mcp_text_result( + "index operation blocked by another mutation for this project", true); + } + cbm_index_mode_t mode = CBM_MODE_FULL; if (mode_str && strcmp(mode_str, "fast") == 0) { mode = CBM_MODE_FAST; @@ -5770,12 +6541,16 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { cbm_pipeline_t *p = cbm_pipeline_new(repo_path, NULL, mode); if (!p) { + mcp_project_mutation_end(srv, mutation_project); + free(mutation_project); free(name_override); free(repo_path); return cbm_mcp_text_result("failed to create pipeline", true); } if (name_override && name_override[0] && !cbm_pipeline_set_project_name(p, name_override)) { cbm_pipeline_free(p); + mcp_project_mutation_end(srv, mutation_project); + free(mutation_project); free(name_override); free(repo_path); return cbm_mcp_text_result("invalid project name", true); @@ -5800,8 +6575,12 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { * Track active pipeline so signal handler and notifications/cancelled * can cancel it mid-run. */ cbm_pipeline_lock(); + atomic_store_explicit(&srv->pipeline_cancel_requested, 0, memory_order_release); + cbm_pipeline_bind_cancel_flag(p, &srv->pipeline_cancel_requested); srv->active_pipeline = p; + atomic_store_explicit(&srv->active_pipeline_running, 1, memory_order_release); int rc = cbm_pipeline_run(p); + atomic_store_explicit(&srv->active_pipeline_running, 0, memory_order_release); srv->active_pipeline = NULL; cbm_pipeline_unlock(); @@ -5868,6 +6647,9 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { free(project_name); free(repo_path); + mcp_project_mutation_end(srv, mutation_project); + free(mutation_project); + char *result = cbm_mcp_text_result(json, rc != 0); free(json); return result; @@ -5979,10 +6761,10 @@ static yyjson_doc *enrich_node_properties(yyjson_mut_doc *doc, yyjson_mut_val *o * across preprocessor branches, which defeats source-level tooling that parses * without the preprocessor (and left this function unindexed in the graph). */ static bool resolve_canonical_path(const char *path, char *out, size_t out_sz) { - /* cbm_canonical_path: realpath on POSIX; wide existence check + - * GetFullPathNameW on Windows (the old bare _fullpath was ANSI — - * CJK-locale corruption, #973 — and, unlike POSIX realpath, resolved - * nonexistent paths too; requiring existence aligns the platforms). */ + /* cbm_canonical_path: realpath on POSIX; an opened handle plus + * GetFinalPathNameByHandleW on Windows. The old bare _fullpath was ANSI + * (CJK-locale corruption, #973), accepted nonexistent paths, and lexical + * expansion alone did not resolve junctions for containment checks. */ if (!cbm_canonical_path(path, out, out_sz)) { return false; } @@ -5992,6 +6774,39 @@ static bool resolve_canonical_path(const char *path, char *out, size_t out_sz) { return true; } +static bool canonical_path_has_root(const char *root_path, const char *candidate_path) { +#ifdef _WIN32 + wchar_t *wide_root = cbm_utf8_to_wide(root_path); + wchar_t *wide_candidate = cbm_utf8_to_wide(candidate_path); + bool contained = false; + if (wide_root && wide_candidate) { + size_t root_len = wcslen(wide_root); + size_t candidate_len = wcslen(wide_candidate); + bool prefix_equal = root_len <= candidate_len && root_len <= INT_MAX && + CompareStringOrdinal(wide_candidate, (int)root_len, wide_root, + (int)root_len, TRUE) == CSTR_EQUAL; + bool root_ends_separator = + root_len > 0 && (wide_root[root_len - 1] == L'/' || wide_root[root_len - 1] == L'\\'); + bool boundary = root_ends_separator || root_len == candidate_len || + (root_len < candidate_len && + (wide_candidate[root_len] == L'/' || wide_candidate[root_len] == L'\\')); + contained = prefix_equal && boundary; + } + free(wide_root); + free(wide_candidate); + return contained; +#else + size_t root_len = strlen(root_path); + size_t candidate_len = strlen(candidate_path); + bool prefix_equal = + root_len <= candidate_len && strncmp(candidate_path, root_path, root_len) == 0; + bool root_ends_separator = root_len > 0 && root_path[root_len - 1] == '/'; + bool boundary = root_ends_separator || root_len == candidate_len || + (root_len < candidate_len && candidate_path[root_len] == '/'); + return prefix_equal && boundary; +#endif +} + bool cbm_path_within_root(const char *root_path, const char *abs_path) { if (!root_path || !abs_path) { return false; @@ -6000,9 +6815,7 @@ bool cbm_path_within_root(const char *root_path, const char *abs_path) { char real_file[CBM_SZ_4K]; if (resolve_canonical_path(root_path, real_root, sizeof(real_root)) && resolve_canonical_path(abs_path, real_file, sizeof(real_file))) { - size_t root_len = strlen(real_root); - if (strncmp(real_file, real_root, root_len) == 0 && - (real_file[root_len] == '/' || real_file[root_len] == '\0')) { + if (canonical_path_has_root(real_root, real_file)) { return true; } } @@ -7689,14 +8502,29 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { mode_str = heap_strdup("get"); } + bool mutation_held = false; + if (project) { + mutation_held = mcp_project_mutation_begin(srv, project); + if (!mutation_held) { + free(project); + free(mode_str); + free(content); + return cbm_mcp_text_result( + "project operation cancelled or blocked by an active index", true); + } + } + /* ADRs are stored in the SQLite store (project_summaries), the SAME * backend the UI /api/adr endpoints use — so writes via the MCP tool and * the UI are visible to each other (#256). */ - cbm_store_t *resolved = resolve_store(srv, project); + cbm_store_t *resolved = resolve_store_internal(srv, project, mutation_held); if (!resolved) { char *err = build_no_store_error(project); char *res = cbm_mcp_text_result(err, true); free(err); + if (mutation_held) { + mcp_project_mutation_end(srv, project); + } free(project); free(mode_str); free(content); @@ -7714,11 +8542,32 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { cbm_store_t *owned_rw = NULL; const char *resolved_db_path = cbm_store_db_path(resolved); if (resolved_db_path) { - owned_rw = cbm_store_open_path(resolved_db_path); + /* Atomic publication leaves a self-contained DELETE-mode database. + * Opening the writer switches it back to WAL, which requires an + * exclusive lock and therefore fails while resolve_store's read-only + * handle is still open. Copy the ACTUAL resolved path first (it may be + * a drifted-filename fallback), then release that cached reader before + * opening the writer. Request-scoped queries reopen it when needed. */ + char *rw_path = heap_strdup(resolved_db_path); + if (!rw_path) { + if (mutation_held) { + mcp_project_mutation_end(srv, project); + } + free(project); + free(mode_str); + free(content); + return cbm_mcp_text_result("failed to allocate ADR store path", true); + } + invalidate_cached_store(srv); + owned_rw = cbm_store_open_path(rw_path); + free(rw_path); if (!owned_rw) { char *err = build_no_store_error(project); char *res = cbm_mcp_text_result(err, true); free(err); + if (mutation_held) { + mcp_project_mutation_end(srv, project); + } free(project); free(mode_str); free(content); @@ -7734,7 +8583,7 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { memset(&adr, 0, sizeof(adr)); bool have_adr = (cbm_store_adr_get(store, project, &adr) == CBM_STORE_OK); if (!have_adr) { - char *root_path = get_project_root(srv, project); + char *root_path = project_root_from_store(store, project); char *legacy = adr_read_legacy_file(root_path); free(root_path); if (legacy) { @@ -7777,6 +8626,9 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { if (owned_rw) { cbm_store_close(owned_rw); } + if (mutation_held) { + mcp_project_mutation_end(srv, project); + } free(project); free(mode_str); free(content); @@ -7822,7 +8674,7 @@ static char *handle_ingest_traces(cbm_mcp_server_t *srv, const char *args) { /* ── Tool dispatch ────────────────────────────────────────────── */ -char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) { +static char *dispatch_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) { if (!tool_name) { return cbm_mcp_text_result("missing tool name", true); } @@ -7885,6 +8737,27 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch return cbm_mcp_text_result(msg, true); } +/* File-backed query stores are request-scoped. Keeping one open between MCP + * calls pins an old database generation after another process atomically + * replaces the project DB. On Windows it can also prevent that replacement + * entirely. Embedded/in-memory stores have no path and retain their existing + * process lifetime. */ +static void release_request_store(cbm_mcp_server_t *srv) { + if (!srv || !srv->owns_store || !srv->store || !cbm_store_db_path(srv->store)) { + return; + } + cbm_store_close(srv->store); + srv->store = NULL; + free(srv->current_project); + srv->current_project = NULL; +} + +char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) { + char *result = dispatch_tool(srv, tool_name, args_json); + release_request_store(srv); + return result; +} + /* ── Session detection + auto-index ────────────────────────────── */ /* Detect session root from CWD (fallback: single indexed project from DB). */ @@ -7947,11 +8820,11 @@ static void *autoindex_thread(void *arg) { cbm_log_info("autoindex.start", "project", srv->session_project, "path", srv->session_root); - /* #832: prefer the supervised worker subprocess. Indexing the whole session in + /* #832: use the supervised worker subprocess. Indexing the whole session in * this long-lived server thread ratchets RSS (mimalloc v3 does not reclaim the * pages worker threads abandon at exit); running it in a child that exits hands - * 100% of that memory back to the OS every cycle. Degrade to the in-process - * pipeline below when the supervisor is off (kill switch) or the spawn fails. */ + * 100% of that memory back to the OS every cycle. In a marked host this is a + * safety boundary: preparation/start failure stops the operation. */ if (cbm_index_supervisor_should_wrap()) { char *resp = index_run_supervised_path(srv, srv->session_root); if (resp) { @@ -7964,7 +8837,9 @@ static void *autoindex_thread(void *arg) { register_watcher_if_enabled(srv); return NULL; } - /* resp == NULL → spawn-failure degrade → fall through to in-process. */ + cbm_log_error("autoindex.supervision_failed", "project", srv->session_project, + "action", "fail_closed"); + return NULL; } cbm_pipeline_t *p = cbm_pipeline_new(srv->session_root, NULL, CBM_MODE_FULL); @@ -8177,10 +9052,9 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { /* Notifications (no id) → handle cancellation, then no response */ if (!req.has_id) { if (req.method && strcmp(req.method, "notifications/cancelled") == 0) { - if (srv->active_pipeline && - cbm_mcp_cancel_request_matches(req.params_raw, srv->active_request_id, - srv->active_request_id_str)) { - cbm_pipeline_cancel(srv->active_pipeline); + if (cbm_mcp_cancel_request_matches(req.params_raw, srv->active_request_id, + srv->active_request_id_str) && + cbm_mcp_server_cancel_active(srv)) { cbm_log_info("mcp.cancelled", "match", "true"); } } @@ -8195,9 +9069,11 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { bool request_logged = false; if (strcmp(req.method, "initialize") == 0) { - result_json = cbm_mcp_initialize_response_for_profile(req.params_raw, srv->tool_profile); + result_json = cbm_mcp_initialize_response_for_profile( + req.params_raw, srv->tool_profile); detect_session(srv); - if (srv->tool_profile == CBM_MCP_TOOL_PROFILE_ALL) { + if (srv->background_tasks && + srv->tool_profile == CBM_MCP_TOOL_PROFILE_ALL) { start_update_check(srv); maybe_auto_index(srv); } @@ -8300,36 +9176,185 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { return out; } -/* Handle a Content-Length-framed message (LSP-style transport). - * Reads headers, body, processes request, writes framed response. */ -static void handle_content_length_frame(cbm_mcp_server_t *srv, FILE *in, FILE *out, char **line, - size_t *cap, int content_len) { - /* Skip blank line(s) between header and body */ - while (cbm_getline(line, cap, in) > 0) { - size_t hlen = strlen(*line); - while (hlen > 0 && ((*line)[hlen - SKIP_ONE] == '\n' || (*line)[hlen - SKIP_ONE] == '\r')) { - (*line)[--hlen] = '\0'; +/* Read through one newline without ever growing the buffer beyond max_bytes. + * Returns 1 for a line (including a final unterminated line), 0 for clean EOF, + * and -1 for I/O, allocation, or size failure. */ +static int read_bounded_line(FILE *in, char **line, size_t *cap, size_t max_bytes, + size_t *out_len) { + if (!in || !line || !cap || !out_len || max_bytes == 0) { + return CBM_NOT_FOUND; + } + + size_t len = 0; + for (;;) { + int ch = fgetc(in); + if (ch == EOF) { + if (ferror(in)) { + return CBM_NOT_FOUND; + } + if (len == 0) { + return 0; + } + break; + } + if (len >= max_bytes) { + return CBM_NOT_FOUND; + } + + size_t needed = len + 2; /* byte plus trailing NUL */ + if (*cap < needed) { + size_t limit_cap = max_bytes + 1; + size_t new_cap = *cap ? *cap : (limit_cap < 256 ? limit_cap : 256); + while (new_cap < needed && new_cap < max_bytes + 1) { + size_t doubled = new_cap * 2; + new_cap = doubled > limit_cap ? limit_cap : doubled; + } + if (new_cap < needed) { + return CBM_NOT_FOUND; + } + char *grown = realloc(*line, new_cap); + if (!grown) { + return CBM_NOT_FOUND; + } + *line = grown; + *cap = new_cap; } - if (hlen == 0) { + + (*line)[len++] = (char)ch; + if (ch == '\n') { break; } } - char *body = malloc((size_t)content_len + SKIP_ONE); - if (!body) { - return; + (*line)[len] = '\0'; + *out_len = len; + return SKIP_ONE; +} + +static bool parse_content_length(const char *line, size_t *out) { + if (!line || !out || strncmp(line, "Content-Length:", SLEN("Content-Length:")) != 0) { + return false; } - size_t nread = fread(body, SKIP_ONE, (size_t)content_len, in); - body[nread] = '\0'; - char *resp = cbm_mcp_server_handle(srv, body); - free(body); + const char *cursor = line + MCP_CONTENT_PREFIX; + while (*cursor == ' ' || *cursor == '\t') { + cursor++; + } + if (!isdigit((unsigned char)*cursor)) { + return false; + } - if (resp) { - size_t rlen = strlen(resp); - (void)fprintf(out, "Content-Length: %zu\r\n\r\n%s", rlen, resp); - (void)fflush(out); - free(resp); + errno = 0; + char *end = NULL; + unsigned long long parsed = strtoull(cursor, &end, CBM_DECIMAL_BASE); + if (errno == ERANGE || end == cursor) { + return false; + } + while (*end == ' ' || *end == '\t') { + end++; + } + if (*end != '\0' || parsed == 0 || parsed > MCP_MAX_MESSAGE_SIZE) { + return false; + } + *out = (size_t)parsed; + return true; +} + +int cbm_mcp_read_message(FILE *in, char **message, bool *content_length_framed) { + if (!in || !message || !content_length_framed) { + return CBM_NOT_FOUND; + } + *message = NULL; + *content_length_framed = false; + + char *line = NULL; + size_t cap = 0; + for (;;) { + size_t line_read = 0; + int line_status = read_bounded_line(in, &line, &cap, MCP_MAX_MESSAGE_SIZE, &line_read); + if (line_status <= 0) { + free(line); + return line_status; + } + if (memchr(line, '\0', line_read) != NULL) { + free(line); + return CBM_NOT_FOUND; + } + + size_t len = line_read; + while (len > 0 && (line[len - SKIP_ONE] == '\n' || line[len - SKIP_ONE] == '\r')) { + line[--len] = '\0'; + } + if (len == 0) { + continue; + } + + if (strncmp(line, "Content-Length:", SLEN("Content-Length:")) != 0) { + *message = line; + return SKIP_ONE; + } + + size_t content_len = 0; + if (line_read > MCP_MAX_HEADER_SIZE || !parse_content_length(line, &content_len)) { + free(line); + return CBM_NOT_FOUND; + } + + bool found_separator = false; + size_t header_bytes = line_read; + for (;;) { + if (header_bytes >= MCP_MAX_HEADER_SIZE) { + free(line); + return CBM_NOT_FOUND; + } + size_t header_read = 0; + int header_status = read_bounded_line(in, &line, &cap, + MCP_MAX_HEADER_SIZE - header_bytes, &header_read); + if (header_status <= 0) { + break; + } + if (memchr(line, '\0', header_read) != NULL) { + free(line); + return CBM_NOT_FOUND; + } + header_bytes += header_read; + size_t header_len = header_read; + while (header_len > 0 && + (line[header_len - SKIP_ONE] == '\n' || line[header_len - SKIP_ONE] == '\r')) { + line[--header_len] = '\0'; + } + if (header_len == 0) { + found_separator = true; + break; + } + } + if (!found_separator) { + free(line); + return CBM_NOT_FOUND; + } + free(line); + + char *body = malloc(content_len + SKIP_ONE); + if (!body) { + return CBM_NOT_FOUND; + } + size_t total = 0; + while (total < content_len) { + size_t nread = fread(body + total, SKIP_ONE, content_len - total, in); + if (nread == 0) { + free(body); + return CBM_NOT_FOUND; + } + total += nread; + } + if (memchr(body, '\0', content_len) != NULL) { + free(body); + return CBM_NOT_FOUND; + } + body[content_len] = '\0'; + *message = body; + *content_length_framed = true; + return SKIP_ONE; } } @@ -8399,8 +9424,6 @@ static int poll_for_input_unix(cbm_mcp_server_t *srv, int fd, FILE *in) { /* ── Event loop ───────────────────────────────────────────────── */ int cbm_mcp_server_run(cbm_mcp_server_t *srv, FILE *in, FILE *out) { - char *line = NULL; - size_t cap = 0; int fd = cbm_fileno(in); for (;;) { @@ -8443,37 +9466,26 @@ int cbm_mcp_server_run(cbm_mcp_server_t *srv, FILE *in, FILE *out) { } #endif - if (cbm_getline(&line, &cap, in) <= 0) { + char *message = NULL; + bool content_length_framed = false; + if (cbm_mcp_read_message(in, &message, &content_length_framed) <= 0) { break; } - /* Trim trailing newline/CR */ - size_t len = strlen(line); - while (len > 0 && (line[len - SKIP_ONE] == '\n' || line[len - SKIP_ONE] == '\r')) { - line[--len] = '\0'; - } - if (len == 0) { - continue; - } - - /* Content-Length framing (LSP-style transport) */ - if (strncmp(line, "Content-Length:", SLEN("Content-Length:")) == 0) { - int content_len = (int)strtol(line + MCP_CONTENT_PREFIX, NULL, CBM_DECIMAL_BASE); - if (content_len > 0 && content_len <= MCP_DEFAULT_LIMIT * CBM_SZ_1K * CBM_SZ_1K) { - handle_content_length_frame(srv, in, out, &line, &cap, content_len); - } - continue; - } - - char *resp = cbm_mcp_server_handle(srv, line); + char *resp = cbm_mcp_server_handle(srv, message); + free(message); if (resp) { - (void)fprintf(out, "%s\n", resp); + if (content_length_framed) { + size_t response_len = strlen(resp); + (void)fprintf(out, "Content-Length: %zu\r\n\r\n%s", response_len, resp); + } else { + (void)fprintf(out, "%s\n", resp); + } (void)fflush(out); free(resp); } } - free(line); return 0; } diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index 09a7ed1ec..0da5e7f83 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -11,6 +11,8 @@ #include #include +#include "mcp/index_supervisor.h" + /* ── Forward declarations ─────────────────────────────────────── */ typedef struct cbm_store cbm_store_t; /* from store/store.h */ @@ -108,6 +110,21 @@ int cbm_mcp_parse_tool_profile_args(int argc, const char *const argv[const], /* Restricted servers must not start a second unrestricted HTTP/RPC surface. */ bool cbm_mcp_tool_profile_allows_http(cbm_mcp_tool_profile_t profile); +/* Optional daemon-owned physical index executor. repo_path is canonical and + * already authorized against this session; args_json contains that canonical + * path plus all caller options. The callback returns a complete malloc-owned + * MCP tool result. A NULL result is reported as an error and never degrades to + * an uncoordinated in-process index. */ +typedef char *(*cbm_mcp_index_executor_fn)(void *context, const char *repo_path, + const char *args_json); + +/* Daemon-owned exclusive lease for operations that mutate a published project + * database. begin may wait, but must remain cancellable by its session owner; + * every successful begin is paired with exactly one end. Standalone/worker + * servers leave these callbacks unset. */ +typedef bool (*cbm_mcp_project_mutation_begin_fn)(void *context, const char *project); +typedef void (*cbm_mcp_project_mutation_end_fn)(void *context, const char *project); + /* Create an MCP server. store_path is the SQLite database directory. */ cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path); @@ -123,6 +140,46 @@ void cbm_mcp_server_set_watcher(cbm_mcp_server_t *srv, struct cbm_watcher *w); /* Set external config store reference (for auto_index setting). Not owned. */ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg); +/* Set an explicit session context for an embedded/daemon-backed server. + * session_root is copied and its project name is derived using the same naming + * rule as indexing. allowed_root is copied when non-NULL; an explicit NULL + * disables the workspace boundary for this server instead of falling back to + * CBM_ALLOWED_ROOT. Returns false when the context is invalid or cannot be + * copied. */ +bool cbm_mcp_server_set_session_context(cbm_mcp_server_t *srv, const char *session_root, + const char *allowed_root); + +/* Read-only session context accessors. Returned strings are owned by srv. */ +const char *cbm_mcp_server_session_root(const cbm_mcp_server_t *srv); +const char *cbm_mcp_server_session_project(const cbm_mcp_server_t *srv); +const char *cbm_mcp_server_allowed_root(const cbm_mcp_server_t *srv); + +/* Enable/disable per-server update checks and automatic indexing. Enabled by + * default for standalone servers; daemon sessions disable these so the shared + * coordinator owns background work. */ +void cbm_mcp_server_set_background_tasks(cbm_mcp_server_t *srv, bool enabled); + +void cbm_mcp_server_set_index_executor(cbm_mcp_server_t *srv, + cbm_mcp_index_executor_fn executor, + void *context); + +/* Relay supervised worker logs to one local request (for CLI progress). The + * callback/context are borrowed until the synchronous tool call returns. */ +void cbm_mcp_server_set_index_log_callback(cbm_mcp_server_t *srv, + cbm_proc_log_cb callback, + void *context); + +void cbm_mcp_server_set_project_mutation_guard( + cbm_mcp_server_t *srv, cbm_mcp_project_mutation_begin_fn begin, + cbm_mcp_project_mutation_end_fn end, void *context); + +/* Read one complete MCP message from in. Supports newline-delimited JSON and + * Content-Length framing, including additional headers. Returns 1 on success, + * 0 on clean EOF, and -1 on invalid input or an I/O/allocation error. On + * success, *message is heap-allocated and *content_length_framed identifies the + * transport framing used by the request. */ +int cbm_mcp_read_message(FILE *in, char **message, bool *content_length_framed); + /* Run the MCP server event loop on the given streams (typically stdin/stdout). * Blocks until EOF on input. Returns 0 on success, -1 on error. */ int cbm_mcp_server_run(cbm_mcp_server_t *srv, FILE *in, FILE *out); @@ -138,14 +195,30 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch /* ── Supervised background index (RSS isolation, #832) ────────── */ +/* One shared policy gate for supervised-index callers. Only an explicitly safe + * terminal can yield a result or enter crash/hang recovery; cancellation, + * failed tree containment, and unavailable worker startup are terminal. The + * FALLBACK disposition is an internal classification used to build an explicit + * error response, never permission for a marked host to index in-process. + * Exposed so the security boundary is directly regression-tested. */ +typedef enum { + CBM_MCP_SUPERVISED_RESULT_FALLBACK = 0, + CBM_MCP_SUPERVISED_RESULT_SUCCESS, + CBM_MCP_SUPERVISED_RESULT_CONTAINED_FAILURE, + CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL, +} cbm_mcp_supervised_result_disposition_t; + +cbm_mcp_supervised_result_disposition_t cbm_mcp_supervised_result_disposition( + int spawn_result, const cbm_index_worker_result_t *worker_result); + /* Run a full index of root_path in a supervised worker SUBPROCESS (the same * crash/hang-isolating runner used by handle_index_repository), so the child * returns 100% of its RSS to the OS on exit instead of ratcheting the long-lived * parent. Builds {"repo_path": root_path} internally. Returns the worker's - * 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. */ + * response or an explicit contained-error response (caller frees). NULL is + * reserved for invalid input/request construction failure; a marked host must + * stop rather than use it as permission to index in-process. This is the shared + * entry the session auto-index routes through. */ char *cbm_mcp_index_run_supervised_path(const char *root_path); /* ── Idle store eviction ──────────────────────────────────────── */ @@ -175,6 +248,11 @@ struct cbm_pipeline; /* forward decl */ * Returns NULL if no pipeline is running. */ struct cbm_pipeline *cbm_mcp_server_active_pipeline(cbm_mcp_server_t *srv); +/* Thread-safe cancellation for daemon connection teardown. The active + * pipeline pointer is protected until cbm_pipeline_cancel() has recorded its + * atomic request, so the request thread cannot clear/free it concurrently. */ +bool cbm_mcp_server_cancel_active(cbm_mcp_server_t *srv); + /* ── URI helpers ───────────────────────────────────────────────── */ /* Parse a file:// URI and extract the filesystem path. diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h new file mode 100644 index 000000000..d3f1690e2 --- /dev/null +++ b/src/mcp/mcp_internal.h @@ -0,0 +1,19 @@ +#ifndef CBM_MCP_INTERNAL_H +#define CBM_MCP_INTERNAL_H + +#include "mcp/mcp.h" + +/* White-box fault injection for deterministic cross-platform quarantine + * safety tests. This header is internal and is not part of the MCP API. */ +typedef bool (*cbm_mcp_quarantine_test_hook_fn)(void *context, const char *step); + +void cbm_mcp_server_set_quarantine_test_hook(cbm_mcp_server_t *srv, + cbm_mcp_quarantine_test_hook_fn hook, + void *context); + +/* Release only the constructor-created pristine in-memory store. Public + * cbm_mcp_server_new(NULL) semantics remain unchanged; daemon sessions use + * this immediately before publication so idle sessions retain no SQLite DB. */ +bool cbm_mcp_server_release_pristine_memory_store(cbm_mcp_server_t *srv); + +#endif diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index d233c422b..0b4d58012 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -16,6 +16,7 @@ #include "foundation/platform.h" #include "foundation/compat.h" #include "foundation/compat_fs.h" +#include "foundation/str_util.h" #include #include @@ -27,12 +28,14 @@ /* ── Constants ───────────────────────────────────────────────────── */ enum { - CR_PATH_BUF = 1024, + CR_PATH_BUF = 4096, CR_QN_BUF = 512, CR_PROPS_BUF = 2048, CR_MAX_EDGES = 4096, CR_DB_EXT_LEN = 3, /* strlen(".db") */ CR_INIT_CAP = 32, + CR_MAX_PROJECTS = 4096, + CR_MAX_CACHE_ENTRIES = 16384, CR_COL_3 = 3, CR_COL_4 = 4, CR_SCHEME_SKIP = 3, /* strlen("://") */ @@ -43,6 +46,43 @@ enum { #define CR_MS_PER_SEC 1000.0 #define CR_NS_PER_MS 1000000.0 +typedef enum { + CR_RUN_OK = 0, + CR_RUN_FAILED, + CR_RUN_CANCELLED, +} cr_run_status_t; + +typedef struct { + const atomic_int *cancelled; + bool cancellation_observed; + bool mutated; +} cr_run_context_t; + +typedef struct { + int count; + cr_run_status_t status; +} cr_match_result_t; + +static bool cr_cancel_requested(cr_run_context_t *ctx) { + if (!ctx) { + return false; + } + if (!ctx->cancellation_observed && ctx->cancelled && + atomic_load_explicit(ctx->cancelled, memory_order_acquire) != 0) { + ctx->cancellation_observed = true; + } + return ctx->cancellation_observed; +} + +static cr_match_result_t cr_match_finish(cr_run_context_t *ctx, int count, bool failed) { + cr_match_result_t result = { + .count = count, + .status = + failed ? CR_RUN_FAILED : (cr_cancel_requested(ctx) ? CR_RUN_CANCELLED : CR_RUN_OK), + }; + return result; +} + /* TLS buffer for integer-to-string in log calls. */ static CBM_TLS char cr_ibuf[CBM_SZ_32]; static const char *cr_itoa(int v) { @@ -57,8 +97,52 @@ static const char *cr_cache_dir(void) { return dir ? dir : cbm_tmpdir(); } -static void cr_db_path(const char *project, char *buf, size_t bufsz) { - snprintf(buf, bufsz, "%s/%s.db", cr_cache_dir(), project); +static bool cr_db_path(const char *project, char *buf, size_t bufsz) { + if (!project || !cbm_validate_project_name(project)) { + return false; + } + int written = snprintf(buf, bufsz, "%s/%s.db", cr_cache_dir(), project); + return written > 0 && (size_t)written < bufsz; +} + +static int cr_project_compare(const void *left, const void *right) { + const char *const *left_project = left; + const char *const *right_project = right; + return strcmp(*left_project, *right_project); +} + +static bool cr_store_has_exact_project(cbm_store_t *store, const char *project) { + cbm_project_t *projects = NULL; + int count = 0; + bool matches = store && cbm_store_check_integrity(store) && + cbm_store_list_projects(store, &projects, &count) == CBM_STORE_OK && + count == 1 && projects[0].name && strcmp(projects[0].name, project) == 0; + cbm_store_free_projects(projects, count); + return matches; +} + +static bool cr_project_exists(const char *project) { + char path[CR_PATH_BUF]; + if (!cr_db_path(project, path, sizeof(path))) { + return false; + } + cbm_store_t *store = cbm_store_open_path_query(path); + bool exists = cr_store_has_exact_project(store, project); + cbm_store_close(store); + return exists; +} + +static cbm_store_t *cr_open_existing_project(const char *project) { + char path[CR_PATH_BUF]; + if (!cr_db_path(project, path, sizeof(path))) { + return NULL; + } + cbm_store_t *store = cbm_store_open_path_existing(path); + if (!cr_store_has_exact_project(store, project)) { + cbm_store_close(store); + return NULL; + } + return store; } /* Extract a JSON string property from properties_json. @@ -108,22 +192,44 @@ static void build_cross_props(char *buf, size_t bufsz, const char *target_projec snprintf(buf + n, bufsz - (size_t)n, "}"); } -/* Delete all CROSS_* edges for a project from a store. */ -static void delete_cross_edges(cbm_store_t *store, const char *project) { - cbm_store_delete_edges_by_type(store, project, "CROSS_HTTP_CALLS"); - cbm_store_delete_edges_by_type(store, project, "CROSS_ASYNC_CALLS"); - cbm_store_delete_edges_by_type(store, project, "CROSS_CHANNEL"); - cbm_store_delete_edges_by_type(store, project, "CROSS_GRPC_CALLS"); - cbm_store_delete_edges_by_type(store, project, "CROSS_GRAPHQL_CALLS"); - cbm_store_delete_edges_by_type(store, project, "CROSS_TRPC_CALLS"); +/* Delete all CROSS_* edges for a project from a store. Cancellation is checked + * before every destructive statement so a pre-cancelled request never erases + * the previous generation. */ +static cr_run_status_t delete_cross_edges(cbm_store_t *store, const char *project, + cr_run_context_t *ctx) { + static const char *const edge_types[] = { + "CROSS_HTTP_CALLS", "CROSS_ASYNC_CALLS", "CROSS_CHANNEL", + "CROSS_GRPC_CALLS", "CROSS_GRAPHQL_CALLS", "CROSS_TRPC_CALLS", + }; + struct sqlite3 *db = cbm_store_get_db(store); + if (!db) { + return CR_RUN_FAILED; + } + for (size_t i = 0; i < sizeof(edge_types) / sizeof(edge_types[0]); i++) { + if (cr_cancel_requested(ctx)) { + return CR_RUN_CANCELLED; + } + int changes_before = sqlite3_total_changes(db); + if (cbm_store_delete_edges_by_type(store, project, edge_types[i]) != CBM_STORE_OK) { + return CR_RUN_FAILED; + } + if (sqlite3_total_changes(db) > changes_before) { + ctx->mutated = true; + } + } + return cr_cancel_requested(ctx) ? CR_RUN_CANCELLED : CR_RUN_OK; } /* Insert a CROSS_* edge into a store. Idempotent by construction: the edges * table is UNIQUE(source_id, target_id, type) and cbm_store_insert_edge * upserts on conflict, so a pair reached from both match directions or on a * repeat run never duplicates a row. (#523) */ -static void insert_cross_edge(cbm_store_t *store, const char *project, int64_t from_id, - int64_t to_id, const char *edge_type, const char *props) { +static bool insert_cross_edge(cbm_store_t *store, const char *project, int64_t from_id, + int64_t to_id, const char *edge_type, const char *props, + cr_run_context_t *ctx) { + if (cr_cancel_requested(ctx)) { + return false; + } cbm_edge_t edge = { .project = project, .source_id = from_id, @@ -131,7 +237,11 @@ static void insert_cross_edge(cbm_store_t *store, const char *project, int64_t f .type = edge_type, .properties_json = props, }; - cbm_store_insert_edge(store, &edge); + bool inserted = cbm_store_insert_edge(store, &edge) > 0; + if (inserted) { + ctx->mutated = true; + } + return inserted; } /* Strip "scheme://host[:port]" from a stored HTTP_CALLS url, returning the @@ -153,17 +263,24 @@ static const char *cr_url_path(const char *url) { } /* Look up a node's name and file_path by id. */ -static void lookup_node_info(struct sqlite3 *db, int64_t node_id, char *name_out, size_t name_sz, +static bool lookup_node_info(struct sqlite3 *db, int64_t node_id, char *name_out, size_t name_sz, char *file_out, size_t file_sz) { name_out[0] = '\0'; file_out[0] = '\0'; + if (!db) { + return false; + } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(db, "SELECT name, file_path FROM nodes WHERE id = ?1", CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) { - return; + return false; } - sqlite3_bind_int64(st, SKIP_ONE, node_id); - if (sqlite3_step(st) == SQLITE_ROW) { + if (sqlite3_bind_int64(st, SKIP_ONE, node_id) != SQLITE_OK) { + sqlite3_finalize(st); + return false; + } + int step_rc = sqlite3_step(st); + if (step_rc == SQLITE_ROW) { const char *nm = (const char *)sqlite3_column_text(st, 0); const char *fp = (const char *)sqlite3_column_text(st, SKIP_ONE); if (nm) { @@ -173,7 +290,8 @@ static void lookup_node_info(struct sqlite3 *db, int64_t node_id, char *name_out snprintf(file_out, file_sz, "%s", fp); } } - sqlite3_finalize(st); + int finalize_rc = sqlite3_finalize(st); + return (step_rc == SQLITE_ROW || step_rc == SQLITE_DONE) && finalize_rc == SQLITE_OK; } /* ── Phase A: HTTP Route matching ────────────────────────────────── */ @@ -182,11 +300,13 @@ static void lookup_node_info(struct sqlite3 *db, int64_t node_id, char *name_out * node id, name, and file_path via HANDLES edges. Returns 0 if not found. */ static int64_t find_route_handler(cbm_store_t *target_store, const char *route_qn, char *handler_name, size_t name_sz, char *handler_file, - size_t file_sz) { + size_t file_sz, bool *failed) { + *failed = false; handler_name[0] = '\0'; handler_file[0] = '\0'; struct sqlite3 *db = cbm_store_get_db(target_store); if (!db) { + *failed = true; return 0; } @@ -195,15 +315,25 @@ static int64_t find_route_handler(cbm_store_t *target_store, const char *route_q if (sqlite3_prepare_v2( db, "SELECT id FROM nodes WHERE qualified_name = ?1 AND label = 'Route' LIMIT 1", CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) { + *failed = true; + return 0; + } + if (sqlite3_bind_text(s, SKIP_ONE, route_qn, CBM_NOT_FOUND, SQLITE_STATIC) != SQLITE_OK) { + sqlite3_finalize(s); + *failed = true; return 0; } - sqlite3_bind_text(s, SKIP_ONE, route_qn, CBM_NOT_FOUND, SQLITE_STATIC); int64_t route_id = 0; - if (sqlite3_step(s) == SQLITE_ROW) { + int step_rc = sqlite3_step(s); + if (step_rc == SQLITE_ROW) { route_id = sqlite3_column_int64(s, 0); + } else if (step_rc != SQLITE_DONE) { + *failed = true; + } + if (sqlite3_finalize(s) != SQLITE_OK) { + *failed = true; } - sqlite3_finalize(s); - if (route_id == 0) { + if (*failed || route_id == 0) { return 0; } @@ -213,11 +343,17 @@ static int64_t find_route_handler(cbm_store_t *target_store, const char *route_q "JOIN nodes n ON n.id = e.source_id " "WHERE e.target_id = ?1 AND e.type = 'HANDLES' LIMIT 1", CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) { + *failed = true; + return 0; + } + if (sqlite3_bind_int64(s, SKIP_ONE, route_id) != SQLITE_OK) { + sqlite3_finalize(s); + *failed = true; return 0; } - sqlite3_bind_int64(s, SKIP_ONE, route_id); int64_t handler_id = 0; - if (sqlite3_step(s) == SQLITE_ROW) { + step_rc = sqlite3_step(s); + if (step_rc == SQLITE_ROW) { handler_id = sqlite3_column_int64(s, 0); const char *n = (const char *)sqlite3_column_text(s, SKIP_ONE); const char *f = (const char *)sqlite3_column_text(s, PAIR_LEN); @@ -227,8 +363,12 @@ static int64_t find_route_handler(cbm_store_t *target_store, const char *route_q if (f) { snprintf(handler_file, file_sz, "%s", f); } + } else if (step_rc != SQLITE_DONE) { + *failed = true; + } + if (sqlite3_finalize(s) != SQLITE_OK) { + *failed = true; } - sqlite3_finalize(s); return handler_id; } @@ -288,18 +428,27 @@ static bool cr_path_matches_template(const char *concrete, const char *templ) { static int64_t find_route_handler_fuzzy(cbm_store_t *target_store, const char *concrete_path, const char *method, char *out_qn, size_t out_qn_sz, char *handler_name, size_t name_sz, char *handler_file, - size_t file_sz) { + size_t file_sz, bool *failed, cr_run_context_t *ctx) { + *failed = false; struct sqlite3 *db = cbm_store_get_db(target_store); if (!db || !concrete_path || !concrete_path[0]) { + if (!db) { + *failed = true; + } return 0; } sqlite3_stmt *s = NULL; - if (sqlite3_prepare_v2(db, "SELECT qualified_name FROM nodes WHERE label = 'Route'", + if (sqlite3_prepare_v2(db, "SELECT qualified_name FROM nodes WHERE label = 'Route' ORDER BY id", CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) { + *failed = true; return 0; } int64_t found = 0; - while (sqlite3_step(s) == SQLITE_ROW) { + int scanned = 0; + int step_rc = SQLITE_DONE; + while (scanned < CR_MAX_EDGES && !cr_cancel_requested(ctx) && + (step_rc = sqlite3_step(s)) == SQLITE_ROW) { + scanned++; const char *qn = (const char *)sqlite3_column_text(s, 0); if (!qn || strncmp(qn, "__route__", CR_ROUTE_PREFIX_LEN) != 0) { continue; @@ -328,82 +477,114 @@ static int64_t find_route_handler_fuzzy(cbm_store_t *target_store, const char *c * "{id}" variant and its canonical "{}" form). Only accept a Route that * actually has a HANDLES edge — the handler is attached to the * canonical node. Keep scanning otherwise. */ - int64_t hid = - find_route_handler(target_store, qn, handler_name, name_sz, handler_file, file_sz); + int64_t hid = find_route_handler(target_store, qn, handler_name, name_sz, handler_file, + file_sz, failed); + if (*failed) { + step_rc = SQLITE_DONE; + break; + } if (hid != 0) { snprintf(out_qn, out_qn_sz, "%s", qn); found = hid; + step_rc = SQLITE_DONE; break; } } - sqlite3_finalize(s); + if (!cr_cancel_requested(ctx) && scanned < CR_MAX_EDGES && step_rc != SQLITE_DONE) { + *failed = true; + } + if (sqlite3_finalize(s) != SQLITE_OK) { + *failed = true; + } return found; } -/* Emit CROSS_* edge for a route match: forward into source, reverse into target. */ -static void emit_cross_route_bidirectional(cbm_store_t *src_store, const char *src_project, - struct sqlite3 *src_db, int64_t caller_id, - int64_t local_route_id, cbm_store_t *tgt_store, - const char *tgt_project, int64_t handler_id, - const char *route_qn, const char *handler_name, - const char *handler_file, const char *url_path, - const char *method, const char *edge_type) { +/* Emit CROSS_* edge for a route match: forward into source, reverse into target. + * A pair is successful only when both writes complete. */ +static bool emit_cross_route_bidirectional( + cbm_store_t *src_store, const char *src_project, struct sqlite3 *src_db, int64_t caller_id, + int64_t local_route_id, cbm_store_t *tgt_store, const char *tgt_project, int64_t handler_id, + const char *route_qn, const char *handler_name, const char *handler_file, const char *url_path, + const char *method, const char *edge_type, cr_run_context_t *ctx) { /* Forward: caller → local Route in source DB */ char fwd[CR_PROPS_BUF]; build_cross_props(fwd, sizeof(fwd), tgt_project, handler_name, handler_file, url_path, "url_path", method); - insert_cross_edge(src_store, src_project, caller_id, local_route_id, edge_type, fwd); + if (!insert_cross_edge(src_store, src_project, caller_id, local_route_id, edge_type, fwd, + ctx)) { + return false; + } + + if (cr_cancel_requested(ctx)) { + return false; + } /* Reverse: handler → Route in target DB */ struct sqlite3 *tgt_db = cbm_store_get_db(tgt_store); if (!tgt_db) { - return; + return false; } sqlite3_stmt *rq = NULL; if (sqlite3_prepare_v2(tgt_db, "SELECT id FROM nodes WHERE qualified_name = ?1 LIMIT 1", CBM_NOT_FOUND, &rq, NULL) != SQLITE_OK) { - return; + return false; + } + if (sqlite3_bind_text(rq, SKIP_ONE, route_qn, CBM_NOT_FOUND, SQLITE_STATIC) != SQLITE_OK) { + sqlite3_finalize(rq); + return false; } - sqlite3_bind_text(rq, SKIP_ONE, route_qn, CBM_NOT_FOUND, SQLITE_STATIC); int64_t tgt_route_id = 0; - if (sqlite3_step(rq) == SQLITE_ROW) { + int step_rc = sqlite3_step(rq); + if (step_rc == SQLITE_ROW) { tgt_route_id = sqlite3_column_int64(rq, 0); } - sqlite3_finalize(rq); - if (tgt_route_id == 0) { - return; + int finalize_rc = sqlite3_finalize(rq); + if ((step_rc != SQLITE_ROW && step_rc != SQLITE_DONE) || finalize_rc != SQLITE_OK || + tgt_route_id == 0) { + return false; } char caller_name[CBM_SZ_256] = {0}; char caller_file[CBM_SZ_512] = {0}; - lookup_node_info(src_db, caller_id, caller_name, sizeof(caller_name), caller_file, - sizeof(caller_file)); + if (!lookup_node_info(src_db, caller_id, caller_name, sizeof(caller_name), caller_file, + sizeof(caller_file))) { + return false; + } char rev[CR_PROPS_BUF]; build_cross_props(rev, sizeof(rev), src_project, caller_name, caller_file, url_path, "url_path", method); - insert_cross_edge(tgt_store, tgt_project, handler_id, tgt_route_id, edge_type, rev); + return insert_cross_edge(tgt_store, tgt_project, handler_id, tgt_route_id, edge_type, rev, ctx); } -static int match_http_routes(cbm_store_t *src_store, const char *src_project, - cbm_store_t *tgt_store, const char *tgt_project) { +static cr_match_result_t match_http_routes(cbm_store_t *src_store, const char *src_project, + cbm_store_t *tgt_store, const char *tgt_project, + cr_run_context_t *ctx) { struct sqlite3 *src_db = cbm_store_get_db(src_store); if (!src_db) { - return 0; + return cr_match_finish(ctx, 0, true); } /* Find all HTTP_CALLS edges in source project */ sqlite3_stmt *s = NULL; if (sqlite3_prepare_v2(src_db, "SELECT e.source_id, e.target_id, e.properties FROM edges e " - "WHERE e.project = ?1 AND e.type = 'HTTP_CALLS'", + "WHERE e.project = ?1 AND e.type = 'HTTP_CALLS' ORDER BY e.id", CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) { - return 0; + return cr_match_finish(ctx, 0, true); + } + if (sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC) != SQLITE_OK) { + sqlite3_finalize(s); + return cr_match_finish(ctx, 0, true); } - sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC); int count = 0; - while (sqlite3_step(s) == SQLITE_ROW && count < CR_MAX_EDGES) { + int scanned = 0; + int step_rc = SQLITE_DONE; + bool failed = false; + while (scanned < CR_MAX_EDGES && !cr_cancel_requested(ctx) && + (step_rc = sqlite3_step(s)) == SQLITE_ROW) { + scanned++; int64_t caller_id = sqlite3_column_int64(s, 0); int64_t route_id = sqlite3_column_int64(s, SKIP_ONE); const char *props = (const char *)sqlite3_column_text(s, PAIR_LEN); @@ -426,57 +607,85 @@ static int match_http_routes(cbm_store_t *src_store, const char *src_project, char handler_name[CBM_SZ_256] = {0}; char handler_file[CBM_SZ_512] = {0}; + bool query_failed = false; int64_t handler_id = find_route_handler(tgt_store, route_qn, handler_name, sizeof(handler_name), - handler_file, sizeof(handler_file)); - if (handler_id == 0) { + handler_file, sizeof(handler_file), &query_failed); + if (!query_failed && handler_id == 0) { /* Try without method (ANY) */ snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s", curl); handler_id = find_route_handler(tgt_store, route_qn, handler_name, sizeof(handler_name), - handler_file, sizeof(handler_file)); + handler_file, sizeof(handler_file), &query_failed); } - if (handler_id == 0) { + if (!query_failed && handler_id == 0) { /* Exact QN lookup missed. A concrete client path ("/v2/orders/123") * never exact-matches a templated route ("/v2/orders/{}"), so fall * back to segment-wise template matching. (#523) */ - handler_id = find_route_handler_fuzzy( - tgt_store, curl, method[0] ? method : NULL, route_qn, sizeof(route_qn), - handler_name, sizeof(handler_name), handler_file, sizeof(handler_file)); + handler_id = + find_route_handler_fuzzy(tgt_store, curl, method[0] ? method : NULL, route_qn, + sizeof(route_qn), handler_name, sizeof(handler_name), + handler_file, sizeof(handler_file), &query_failed, ctx); + } + if (query_failed) { + failed = true; + break; } if (handler_id == 0) { continue; } - emit_cross_route_bidirectional(src_store, src_project, src_db, caller_id, route_id, - tgt_store, tgt_project, handler_id, route_qn, handler_name, - handler_file, url_path, method, "CROSS_HTTP_CALLS"); + if (cr_cancel_requested(ctx)) { + break; + } + + if (!emit_cross_route_bidirectional(src_store, src_project, src_db, caller_id, route_id, + tgt_store, tgt_project, handler_id, route_qn, + handler_name, handler_file, url_path, method, + "CROSS_HTTP_CALLS", ctx)) { + failed = !cr_cancel_requested(ctx); + break; + } count++; } - sqlite3_finalize(s); - return count; + if (!failed && !cr_cancel_requested(ctx) && scanned < CR_MAX_EDGES && step_rc != SQLITE_DONE) { + failed = true; + } + if (sqlite3_finalize(s) != SQLITE_OK) { + failed = true; + } + return cr_match_finish(ctx, count, failed); } /* ── Phase B: Async matching ─────────────────────────────────────── */ -static int match_async_routes(cbm_store_t *src_store, const char *src_project, - cbm_store_t *tgt_store, const char *tgt_project) { +static cr_match_result_t match_async_routes(cbm_store_t *src_store, const char *src_project, + cbm_store_t *tgt_store, const char *tgt_project, + cr_run_context_t *ctx) { struct sqlite3 *src_db = cbm_store_get_db(src_store); if (!src_db) { - return 0; + return cr_match_finish(ctx, 0, true); } sqlite3_stmt *s = NULL; if (sqlite3_prepare_v2(src_db, "SELECT e.source_id, e.target_id, e.properties FROM edges e " - "WHERE e.project = ?1 AND e.type = 'ASYNC_CALLS'", + "WHERE e.project = ?1 AND e.type = 'ASYNC_CALLS' ORDER BY e.id", CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) { - return 0; + return cr_match_finish(ctx, 0, true); + } + if (sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC) != SQLITE_OK) { + sqlite3_finalize(s); + return cr_match_finish(ctx, 0, true); } - sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC); int count = 0; - while (sqlite3_step(s) == SQLITE_ROW && count < CR_MAX_EDGES) { + int scanned = 0; + int step_rc = SQLITE_DONE; + bool failed = false; + while (scanned < CR_MAX_EDGES && !cr_cancel_requested(ctx) && + (step_rc = sqlite3_step(s)) == SQLITE_ROW) { + scanned++; int64_t caller_id = sqlite3_column_int64(s, 0); int64_t route_id = sqlite3_column_int64(s, SKIP_ONE); const char *props = (const char *)sqlite3_column_text(s, PAIR_LEN); @@ -495,34 +704,57 @@ static int match_async_routes(cbm_store_t *src_store, const char *src_project, char handler_name[CBM_SZ_256] = {0}; char handler_file[CBM_SZ_512] = {0}; + bool query_failed = false; int64_t handler_id = find_route_handler(tgt_store, route_qn, handler_name, sizeof(handler_name), - handler_file, sizeof(handler_file)); + handler_file, sizeof(handler_file), &query_failed); + if (query_failed) { + failed = true; + break; + } if (handler_id == 0) { continue; } + if (cr_cancel_requested(ctx)) { + break; + } + char edge_props[CR_PROPS_BUF]; build_cross_props(edge_props, sizeof(edge_props), tgt_project, handler_name, handler_file, url_path, "url_path", broker); - insert_cross_edge(src_store, src_project, caller_id, route_id, "CROSS_ASYNC_CALLS", - edge_props); + if (!insert_cross_edge(src_store, src_project, caller_id, route_id, "CROSS_ASYNC_CALLS", + edge_props, ctx)) { + failed = !cr_cancel_requested(ctx); + break; + } count++; } - sqlite3_finalize(s); - return count; + if (!failed && !cr_cancel_requested(ctx) && scanned < CR_MAX_EDGES && step_rc != SQLITE_DONE) { + failed = true; + } + if (sqlite3_finalize(s) != SQLITE_OK) { + failed = true; + } + return cr_match_finish(ctx, count, failed); } /* ── Phase C: Channel matching ───────────────────────────────────── */ -/* Try to find a matching listener in target DB for a channel name. */ -static bool try_match_channel_listener(cbm_store_t *src_store, const char *src_project, - cbm_store_t *tgt_store, const char *tgt_project, - const char *channel_name, const char *transport, - int64_t emitter_id, int64_t channel_id) { +/* Try to find a matching listener in target DB for a channel name. Returns 1 + * for a complete bidirectional pair, 0 for no match, and CBM_STORE_ERR when a + * query or write fails. */ +static int try_match_channel_listener(cbm_store_t *src_store, const char *src_project, + cbm_store_t *tgt_store, const char *tgt_project, + const char *channel_name, const char *transport, + int64_t emitter_id, int64_t channel_id, + cr_run_context_t *ctx) { + if (cr_cancel_requested(ctx)) { + return CBM_STORE_NOT_FOUND; + } struct sqlite3 *tgt_db = cbm_store_get_db(tgt_store); if (!tgt_db) { - return false; + return CBM_STORE_ERR; } sqlite3_stmt *tq = NULL; if (sqlite3_prepare_v2(tgt_db, @@ -531,47 +763,70 @@ static bool try_match_channel_listener(cbm_store_t *src_store, const char *src_p "JOIN nodes fn ON fn.id = e.source_id " "WHERE n.project = ?1 AND n.name = ?2 AND n.label = 'Channel' LIMIT 1", CBM_NOT_FOUND, &tq, NULL) != SQLITE_OK) { - return false; + return CBM_STORE_ERR; + } + if (sqlite3_bind_text(tq, SKIP_ONE, tgt_project, CBM_NOT_FOUND, SQLITE_STATIC) != SQLITE_OK || + sqlite3_bind_text(tq, PAIR_LEN, channel_name, CBM_NOT_FOUND, SQLITE_STATIC) != SQLITE_OK) { + sqlite3_finalize(tq); + return CBM_STORE_ERR; + } + + int64_t tgt_channel_id = 0; + int64_t listener_id = 0; + char listener_name[CBM_SZ_256] = {0}; + char listener_file[CBM_SZ_512] = {0}; + int step_rc = sqlite3_step(tq); + if (step_rc == SQLITE_ROW) { + tgt_channel_id = sqlite3_column_int64(tq, 0); + listener_id = sqlite3_column_int64(tq, SKIP_ONE); + const char *name = (const char *)sqlite3_column_text(tq, PAIR_LEN); + const char *file = (const char *)sqlite3_column_text(tq, CR_COL_3); + snprintf(listener_name, sizeof(listener_name), "%s", name ? name : ""); + snprintf(listener_file, sizeof(listener_file), "%s", file ? file : ""); + } + int finalize_rc = sqlite3_finalize(tq); + if ((step_rc != SQLITE_ROW && step_rc != SQLITE_DONE) || finalize_rc != SQLITE_OK) { + return CBM_STORE_ERR; + } + if (step_rc == SQLITE_DONE) { + return 0; } - sqlite3_bind_text(tq, SKIP_ONE, tgt_project, CBM_NOT_FOUND, SQLITE_STATIC); - sqlite3_bind_text(tq, PAIR_LEN, channel_name, CBM_NOT_FOUND, SQLITE_STATIC); - - bool matched = false; - if (sqlite3_step(tq) == SQLITE_ROW) { - int64_t tgt_channel_id = sqlite3_column_int64(tq, 0); - int64_t listener_id = sqlite3_column_int64(tq, SKIP_ONE); - const char *listener_name = (const char *)sqlite3_column_text(tq, PAIR_LEN); - const char *listener_file = (const char *)sqlite3_column_text(tq, CR_COL_3); - - /* Forward edge: emitter → local Channel */ - char fwd[CR_PROPS_BUF]; - build_cross_props(fwd, sizeof(fwd), tgt_project, listener_name ? listener_name : "", - listener_file ? listener_file : "", channel_name, "channel_name", - transport); - insert_cross_edge(src_store, src_project, emitter_id, channel_id, "CROSS_CHANNEL", fwd); - - /* Reverse edge: listener → target Channel */ - char caller_name[CBM_SZ_256] = {0}; - char caller_file[CBM_SZ_512] = {0}; - lookup_node_info(cbm_store_get_db(src_store), emitter_id, caller_name, sizeof(caller_name), - caller_file, sizeof(caller_file)); - - char rev[CR_PROPS_BUF]; - build_cross_props(rev, sizeof(rev), src_project, caller_name, caller_file, channel_name, - "channel_name", transport); - insert_cross_edge(tgt_store, tgt_project, listener_id, tgt_channel_id, "CROSS_CHANNEL", - rev); - matched = true; - } - sqlite3_finalize(tq); - return matched; + + char caller_name[CBM_SZ_256] = {0}; + char caller_file[CBM_SZ_512] = {0}; + if (!lookup_node_info(cbm_store_get_db(src_store), emitter_id, caller_name, sizeof(caller_name), + caller_file, sizeof(caller_file))) { + return CBM_STORE_ERR; + } + + /* Forward edge: emitter → local Channel */ + char fwd[CR_PROPS_BUF]; + build_cross_props(fwd, sizeof(fwd), tgt_project, listener_name, listener_file, channel_name, + "channel_name", transport); + if (!insert_cross_edge(src_store, src_project, emitter_id, channel_id, "CROSS_CHANNEL", fwd, + ctx)) { + return cr_cancel_requested(ctx) ? CBM_STORE_NOT_FOUND : CBM_STORE_ERR; + } + if (cr_cancel_requested(ctx)) { + return CBM_STORE_NOT_FOUND; + } + + /* Reverse edge: listener → target Channel */ + char rev[CR_PROPS_BUF]; + build_cross_props(rev, sizeof(rev), src_project, caller_name, caller_file, channel_name, + "channel_name", transport); + return insert_cross_edge(tgt_store, tgt_project, listener_id, tgt_channel_id, "CROSS_CHANNEL", + rev, ctx) + ? 1 + : (cr_cancel_requested(ctx) ? CBM_STORE_NOT_FOUND : CBM_STORE_ERR); } -static int match_channels(cbm_store_t *src_store, const char *src_project, cbm_store_t *tgt_store, - const char *tgt_project) { +static cr_match_result_t match_channels(cbm_store_t *src_store, const char *src_project, + cbm_store_t *tgt_store, const char *tgt_project, + cr_run_context_t *ctx) { struct sqlite3 *src_db = cbm_store_get_db(src_store); if (!src_db) { - return 0; + return cr_match_finish(ctx, 0, true); } sqlite3_stmt *s = NULL; @@ -579,14 +834,23 @@ static int match_channels(cbm_store_t *src_store, const char *src_project, cbm_s "SELECT DISTINCT n.id, n.name, n.qualified_name, n.properties, " "e.source_id FROM nodes n " "JOIN edges e ON e.target_id = n.id AND e.type = 'EMITS' " - "WHERE n.project = ?1 AND n.label = 'Channel'", + "WHERE n.project = ?1 AND n.label = 'Channel' " + "ORDER BY n.id, e.source_id", CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) { - return 0; + return cr_match_finish(ctx, 0, true); + } + if (sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC) != SQLITE_OK) { + sqlite3_finalize(s); + return cr_match_finish(ctx, 0, true); } - sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC); int count = 0; - while (sqlite3_step(s) == SQLITE_ROW && count < CR_MAX_EDGES) { + int scanned = 0; + int step_rc = SQLITE_DONE; + bool failed = false; + while (scanned < CR_MAX_EDGES && !cr_cancel_requested(ctx) && + (step_rc = sqlite3_step(s)) == SQLITE_ROW) { + scanned++; const char *channel_name = (const char *)sqlite3_column_text(s, SKIP_ONE); const char *channel_qn = (const char *)sqlite3_column_text(s, PAIR_LEN); if (!channel_name || !channel_qn) { @@ -596,66 +860,112 @@ static int match_channels(cbm_store_t *src_store, const char *src_project, cbm_s const char *channel_props = (const char *)sqlite3_column_text(s, CR_COL_3); int64_t emitter_id = sqlite3_column_int64(s, CR_COL_4); + char *channel_name_copy = cbm_strdup(channel_name); + if (!channel_name_copy) { + failed = true; + break; + } char transport[CBM_SZ_64] = {0}; json_str_prop(channel_props, "transport", transport, sizeof(transport)); - if (try_match_channel_listener(src_store, src_project, tgt_store, tgt_project, channel_name, - transport, emitter_id, channel_id)) { + int matched = + try_match_channel_listener(src_store, src_project, tgt_store, tgt_project, + channel_name_copy, transport, emitter_id, channel_id, ctx); + free(channel_name_copy); + if (matched == CBM_STORE_ERR) { + failed = true; + break; + } + if (matched == CBM_STORE_NOT_FOUND && cr_cancel_requested(ctx)) { + break; + } + if (matched > 0) { count++; } } - sqlite3_finalize(s); - return count; + if (!failed && !cr_cancel_requested(ctx) && scanned < CR_MAX_EDGES && step_rc != SQLITE_DONE) { + failed = true; + } + if (sqlite3_finalize(s) != SQLITE_OK) { + failed = true; + } + return cr_match_finish(ctx, count, failed); } /* ── Phase D: Generic route-type matcher (gRPC, GraphQL, tRPC) ──── */ -/* Look up a node's qualified_name by id. Returns true if found. */ -static bool lookup_node_qn(struct sqlite3 *db, int64_t node_id, char *out, size_t out_sz) { +/* Look up a node's qualified_name by id. Returns true if found and reports SQL + * failures separately from an ordinary miss. */ +static bool lookup_node_qn(struct sqlite3 *db, int64_t node_id, char *out, size_t out_sz, + bool *failed) { + *failed = false; out[0] = '\0'; + if (!db) { + *failed = true; + return false; + } sqlite3_stmt *st = NULL; if (sqlite3_prepare_v2(db, "SELECT qualified_name FROM nodes WHERE id = ?1", CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) { + *failed = true; + return false; + } + if (sqlite3_bind_int64(st, SKIP_ONE, node_id) != SQLITE_OK) { + sqlite3_finalize(st); + *failed = true; return false; } - sqlite3_bind_int64(st, SKIP_ONE, node_id); bool found = false; - if (sqlite3_step(st) == SQLITE_ROW) { + int step_rc = sqlite3_step(st); + if (step_rc == SQLITE_ROW) { const char *qn = (const char *)sqlite3_column_text(st, 0); if (qn) { snprintf(out, out_sz, "%s", qn); found = true; } + } else if (step_rc != SQLITE_DONE) { + *failed = true; + } + if (sqlite3_finalize(st) != SQLITE_OK) { + *failed = true; } - sqlite3_finalize(st); return found; } /* Match edges of a given type against Route nodes with a given QN prefix. * Reuses the same infrastructure as HTTP/async matching. */ -static int match_typed_routes(cbm_store_t *src_store, const char *src_project, - cbm_store_t *tgt_store, const char *tgt_project, - const char *edge_type, const char *svc_key, const char *method_key, - const char *cross_edge_type) { +static cr_match_result_t match_typed_routes(cbm_store_t *src_store, const char *src_project, + cbm_store_t *tgt_store, const char *tgt_project, + const char *edge_type, const char *svc_key, + const char *method_key, const char *cross_edge_type, + cr_run_context_t *ctx) { struct sqlite3 *src_db = cbm_store_get_db(src_store); if (!src_db) { - return 0; + return cr_match_finish(ctx, 0, true); } char sql[CBM_SZ_256]; snprintf(sql, sizeof(sql), "SELECT e.source_id, e.target_id, e.properties FROM edges e " - "WHERE e.project = ?1 AND e.type = '%s'", + "WHERE e.project = ?1 AND e.type = '%s' ORDER BY e.id", edge_type); sqlite3_stmt *s = NULL; if (sqlite3_prepare_v2(src_db, sql, CBM_NOT_FOUND, &s, NULL) != SQLITE_OK) { - return 0; + return cr_match_finish(ctx, 0, true); + } + if (sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC) != SQLITE_OK) { + sqlite3_finalize(s); + return cr_match_finish(ctx, 0, true); } - sqlite3_bind_text(s, SKIP_ONE, src_project, CBM_NOT_FOUND, SQLITE_STATIC); int count = 0; - while (sqlite3_step(s) == SQLITE_ROW && count < CR_MAX_EDGES) { + int scanned = 0; + int step_rc = SQLITE_DONE; + bool failed = false; + while (scanned < CR_MAX_EDGES && !cr_cancel_requested(ctx) && + (step_rc = sqlite3_step(s)) == SQLITE_ROW) { + scanned++; int64_t caller_id = sqlite3_column_int64(s, 0); int64_t route_id = sqlite3_column_int64(s, SKIP_ONE); const char *props = (const char *)sqlite3_column_text(s, PAIR_LEN); @@ -670,7 +980,12 @@ static int match_typed_routes(cbm_store_t *src_store, const char *src_project, /* Look up the Route QN from the target node (already points to the Route). */ char route_qn[CR_QN_BUF] = {0}; - if (!lookup_node_qn(src_db, route_id, route_qn, sizeof(route_qn))) { + bool query_failed = false; + if (!lookup_node_qn(src_db, route_id, route_qn, sizeof(route_qn), &query_failed)) { + if (query_failed) { + failed = true; + break; + } continue; } @@ -678,65 +993,128 @@ static int match_typed_routes(cbm_store_t *src_store, const char *src_project, char handler_file[CBM_SZ_512] = {0}; int64_t handler_id = find_route_handler(tgt_store, route_qn, handler_name, sizeof(handler_name), - handler_file, sizeof(handler_file)); + handler_file, sizeof(handler_file), &query_failed); + if (query_failed) { + failed = true; + break; + } if (handler_id == 0) { continue; } - emit_cross_route_bidirectional(src_store, src_project, src_db, caller_id, route_id, - tgt_store, tgt_project, handler_id, route_qn, handler_name, - handler_file, svc_val, svc_key, cross_edge_type); + if (cr_cancel_requested(ctx)) { + break; + } + + if (!emit_cross_route_bidirectional(src_store, src_project, src_db, caller_id, route_id, + tgt_store, tgt_project, handler_id, route_qn, + handler_name, handler_file, svc_val, svc_key, + cross_edge_type, ctx)) { + failed = !cr_cancel_requested(ctx); + break; + } count++; } - sqlite3_finalize(s); - return count; + if (!failed && !cr_cancel_requested(ctx) && scanned < CR_MAX_EDGES && step_rc != SQLITE_DONE) { + failed = true; + } + if (sqlite3_finalize(s) != SQLITE_OK) { + failed = true; + } + return cr_match_finish(ctx, count, failed); } /* ── Collect target projects ─────────────────────────────────────── */ +static void free_project_list(char **projects, int count); + /* When target_projects = ["*"], scan the cache directory for all .db files. */ -static int collect_all_projects(char ***out) { +static int collect_all_projects(char ***out, cr_run_context_t *ctx) { + *out = NULL; const char *dir = cr_cache_dir(); cbm_dir_t *d = cbm_opendir(dir); if (!d) { - *out = NULL; - return 0; + return -1; } int cap = CR_INIT_CAP; int count = 0; char **projects = malloc((size_t)cap * sizeof(char *)); + if (!projects) { + cbm_closedir(d); + return -1; + } + + bool failed = false; + int entries_scanned = 0; cbm_dirent_t *ent; while ((ent = cbm_readdir(d)) != NULL) { + if (cr_cancel_requested(ctx)) { + failed = true; + break; + } + entries_scanned++; + if (entries_scanned > CR_MAX_CACHE_ENTRIES) { + failed = true; + break; + } size_t len = strlen(ent->name); if (len < CR_COL_4 || strcmp(ent->name + len - CR_DB_EXT_LEN, ".db") != 0) { continue; } - if (strstr(ent->name, "_cross_repo") || strstr(ent->name, "_config")) { + /* Internal stores are exact filenames. Substring filtering would hide + * legitimate projects such as orders_config_service or api-wal. */ + if (strcmp(ent->name, "_cross_repo.db") == 0 || strcmp(ent->name, "_config.db") == 0) { + continue; + } + if (count >= CR_MAX_PROJECTS) { + failed = true; + break; + } + char project[CBM_DIRENT_NAME_MAX]; + size_t project_length = len - CR_DB_EXT_LEN; + if (project_length == 0 || project_length >= sizeof(project)) { continue; } - if (strstr(ent->name, "-wal") || strstr(ent->name, "-shm")) { + memcpy(project, ent->name, project_length); + project[project_length] = '\0'; + if (!cbm_validate_project_name(project) || !cr_project_exists(project)) { continue; } if (count >= cap) { cap *= PAIR_LEN; char **tmp = realloc(projects, (size_t)cap * sizeof(char *)); if (!tmp) { + failed = true; break; } projects = tmp; } - /* Strip .db extension */ - projects[count] = malloc(len - PAIR_LEN); - memcpy(projects[count], ent->name, len - CR_DB_EXT_LEN); - projects[count][len - CR_DB_EXT_LEN] = '\0'; + projects[count] = cbm_strdup(project); + if (!projects[count]) { + failed = true; + break; + } count++; } cbm_closedir(d); + if (failed) { + free_project_list(projects, count); + return cr_cancel_requested(ctx) ? CBM_STORE_NOT_FOUND : CBM_STORE_ERR; + } + qsort(projects, (size_t)count, sizeof(*projects), cr_project_compare); + int unique_count = 0; + for (int i = 0; i < count; i++) { + if (unique_count == 0 || strcmp(projects[i], projects[unique_count - 1]) != 0) { + projects[unique_count++] = projects[i]; + } else { + free(projects[i]); + } + } *out = projects; - return count; + return unique_count; } static void free_project_list(char **projects, int count) { @@ -746,55 +1124,147 @@ static void free_project_list(char **projects, int count) { free(projects); } +static int collect_named_projects(const char **targets, int target_count, char ***out, + cr_run_context_t *ctx) { + *out = NULL; + if (!targets || target_count <= 0 || target_count > CR_MAX_PROJECTS) { + return -1; + } + char **projects = calloc((size_t)target_count, sizeof(*projects)); + if (!projects) { + return -1; + } + int count = 0; + for (int i = 0; i < target_count; i++) { + if (cr_cancel_requested(ctx)) { + free_project_list(projects, count); + return CBM_STORE_NOT_FOUND; + } + if (!targets[i] || strcmp(targets[i], "*") == 0 || !cbm_validate_project_name(targets[i])) { + free_project_list(projects, count); + return -1; + } + projects[count] = cbm_strdup(targets[i]); + if (!projects[count]) { + free_project_list(projects, count); + return -1; + } + count++; + } + qsort(projects, (size_t)count, sizeof(*projects), cr_project_compare); + int unique_count = 0; + for (int i = 0; i < count; i++) { + if (unique_count == 0 || strcmp(projects[i], projects[unique_count - 1]) != 0) { + projects[unique_count++] = projects[i]; + } else { + free(projects[i]); + } + } + *out = projects; + return unique_count; +} + +static cr_run_status_t add_match_count(int *total, cr_match_result_t matched) { + *total += matched.count; + return matched.status; +} + /* ── Entry point ─────────────────────────────────────────────────── */ -cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **target_projects, - int target_count) { +cbm_cross_repo_result_t cbm_cross_repo_match_cancellable(const char *project, + const char **target_projects, + int target_count, + const atomic_int *cancelled) { cbm_cross_repo_result_t result = {0}; + cr_run_context_t run = {.cancelled = cancelled}; struct timespec t0; clock_gettime(CLOCK_MONOTONIC, &t0); - /* Open source project store (read-write) */ - char src_path[CR_PATH_BUF]; - cr_db_path(project, src_path, sizeof(src_path)); - cbm_store_t *src_store = cbm_store_open_path(src_path); - if (!src_store) { + if (!project || !cbm_validate_project_name(project) || !target_projects || target_count <= 0 || + (target_count == SKIP_ONE && !target_projects[0]) || !cr_project_exists(project)) { + result.failed = true; + return result; + } + if (cr_cancel_requested(&run)) { + result.cancelled = true; return result; } - - /* Clean existing CROSS_* edges for this project */ - delete_cross_edges(src_store, project); /* Resolve target projects */ char **resolved = NULL; int resolved_count = 0; - bool own_list = false; - if (target_count == SKIP_ONE && strcmp(target_projects[0], "*") == 0) { - resolved_count = collect_all_projects(&resolved); - own_list = true; + if (target_count == SKIP_ONE && target_projects[0] && strcmp(target_projects[0], "*") == 0) { + resolved_count = collect_all_projects(&resolved, &run); } else { - resolved = (char **)target_projects; - resolved_count = target_count; + resolved_count = collect_named_projects(target_projects, target_count, &resolved, &run); + } + if (resolved_count < 0) { + result.cancelled = cr_cancel_requested(&run); + result.failed = !result.cancelled; + return result; + } + for (int i = 0; i < resolved_count; i++) { + if (cr_cancel_requested(&run)) { + result.cancelled = true; + free_project_list(resolved, resolved_count); + return result; + } + if (strcmp(resolved[i], project) != 0 && !cr_project_exists(resolved[i])) { + result.failed = true; + free_project_list(resolved, resolved_count); + return result; + } + } + + /* Every input is known to exist before destructive source cleanup. The + * read-write open itself also omits CREATE so a race cannot create ghosts. */ + cbm_store_t *src_store = cr_open_existing_project(project); + if (!src_store) { + result.failed = true; + free_project_list(resolved, resolved_count); + return result; + } + + if (cr_cancel_requested(&run)) { + result.cancelled = true; + cbm_store_close(src_store); + free_project_list(resolved, resolved_count); + return result; + } + + /* Clean existing CROSS_* edges for this project */ + cr_run_status_t cleanup_status = delete_cross_edges(src_store, project, &run); + if (cleanup_status != CR_RUN_OK) { + result.cancelled = cleanup_status == CR_RUN_CANCELLED; + result.partial_results = result.cancelled && run.mutated; + result.failed = cleanup_status == CR_RUN_FAILED; + cbm_store_close(src_store); + free_project_list(resolved, resolved_count); + return result; } /* Match against each target */ for (int i = 0; i < resolved_count; i++) { + if (cr_cancel_requested(&run)) { + result.cancelled = true; + result.partial_results = run.mutated; + break; + } const char *tgt = resolved[i]; if (strcmp(tgt, project) == 0) { continue; /* skip self */ } - char tgt_path[CR_PATH_BUF]; - cr_db_path(tgt, tgt_path, sizeof(tgt_path)); - /* Open target store read-write (for bidirectional edge writes) */ - cbm_store_t *tgt_store = cbm_store_open_path(tgt_path); + cbm_store_t *tgt_store = cr_open_existing_project(tgt); if (!tgt_store) { - continue; + result.failed = true; + break; } - result.http_edges += match_http_routes(src_store, project, tgt_store, tgt); + cr_run_status_t match_status = add_match_count( + &result.http_edges, match_http_routes(src_store, project, tgt_store, tgt, &run)); /* Reverse direction: when this pass runs from the provider side, the * consumer's HTTP_CALLS live in tgt, not src — the forward pass above * finds nothing because the provider has no outbound calls. This also @@ -803,26 +1273,53 @@ cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **t * A caller edge lives in exactly one DB, so the two directions scan * disjoint edge sets and never double-count a pair; the store's * (source, target, type) upsert keeps re-recorded rows unique. (#523) */ - result.http_edges += match_http_routes(tgt_store, tgt, src_store, project); - result.async_edges += match_async_routes(src_store, project, tgt_store, tgt); - result.channel_edges += match_channels(src_store, project, tgt_store, tgt); - result.grpc_edges += match_typed_routes(src_store, project, tgt_store, tgt, "GRPC_CALLS", - "service", "method", "CROSS_GRPC_CALLS"); - result.graphql_edges += - match_typed_routes(src_store, project, tgt_store, tgt, "GRAPHQL_CALLS", "operation", - "operation", "CROSS_GRAPHQL_CALLS"); - result.trpc_edges += match_typed_routes(src_store, project, tgt_store, tgt, "TRPC_CALLS", - "procedure", "procedure", "CROSS_TRPC_CALLS"); - result.projects_scanned++; + if (match_status == CR_RUN_OK) { + match_status = add_match_count( + &result.http_edges, match_http_routes(tgt_store, tgt, src_store, project, &run)); + } + if (match_status == CR_RUN_OK) { + match_status = add_match_count( + &result.async_edges, match_async_routes(src_store, project, tgt_store, tgt, &run)); + } + if (match_status == CR_RUN_OK) { + match_status = add_match_count( + &result.channel_edges, match_channels(src_store, project, tgt_store, tgt, &run)); + } + if (match_status == CR_RUN_OK) { + match_status = + add_match_count(&result.grpc_edges, + match_typed_routes(src_store, project, tgt_store, tgt, "GRPC_CALLS", + "service", "method", "CROSS_GRPC_CALLS", &run)); + } + if (match_status == CR_RUN_OK) { + match_status = add_match_count( + &result.graphql_edges, + match_typed_routes(src_store, project, tgt_store, tgt, "GRAPHQL_CALLS", "operation", + "operation", "CROSS_GRAPHQL_CALLS", &run)); + } + if (match_status == CR_RUN_OK) { + match_status = add_match_count( + &result.trpc_edges, + match_typed_routes(src_store, project, tgt_store, tgt, "TRPC_CALLS", "procedure", + "procedure", "CROSS_TRPC_CALLS", &run)); + } cbm_store_close(tgt_store); + if (match_status == CR_RUN_FAILED) { + result.failed = true; + break; + } + if (match_status == CR_RUN_CANCELLED || cr_cancel_requested(&run)) { + result.cancelled = true; + result.partial_results = run.mutated; + break; + } + result.projects_scanned++; } cbm_store_close(src_store); - if (own_list) { - free_project_list(resolved, resolved_count); - } + free_project_list(resolved, resolved_count); struct timespec t1; clock_gettime(CLOCK_MONOTONIC, &t1); @@ -831,7 +1328,17 @@ cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **t int total = result.http_edges + result.async_edges + result.channel_edges + result.grpc_edges + result.graphql_edges + result.trpc_edges; - cbm_log_info("cross_repo.done", "project", project, "total", cr_itoa(total)); + if (result.cancelled) { + cbm_log_info("cross_repo.cancelled", "project", project, "partial_results", + result.partial_results ? "true" : "false"); + } else { + cbm_log_info("cross_repo.done", "project", project, "total", cr_itoa(total)); + } return result; } + +cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **target_projects, + int target_count) { + return cbm_cross_repo_match_cancellable(project, target_projects, target_count, NULL); +} diff --git a/src/pipeline/pass_cross_repo.h b/src/pipeline/pass_cross_repo.h index 5d2d4cfee..10ee0199a 100644 --- a/src/pipeline/pass_cross_repo.h +++ b/src/pipeline/pass_cross_repo.h @@ -7,6 +7,8 @@ #include "store/store.h" +#include + /* Result of a cross-repo matching run. */ typedef struct { int http_edges; /* CROSS_HTTP_CALLS edges created */ @@ -17,6 +19,9 @@ typedef struct { int trpc_edges; /* CROSS_TRPC_CALLS edges created */ int projects_scanned; double elapsed_ms; + bool failed; /* source/target validation, open, or allocation failed */ + bool cancelled; /* stopped at a bounded cancellation checkpoint */ + bool partial_results; /* committed writes before cancellation were retained */ } cbm_cross_repo_result_t; /* Run cross-repo matching for `project` against `target_projects`. @@ -29,4 +34,12 @@ typedef struct { cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **target_projects, int target_count); +/* Cancellation-aware form used by session-owned frontends. The caller-owned + * flag must outlive the call. Cancellation is not a multi-database rollback: + * already committed target writes remain and are reported as partial results. */ +cbm_cross_repo_result_t cbm_cross_repo_match_cancellable(const char *project, + const char **target_projects, + int target_count, + const atomic_int *cancelled); + #endif /* CBM_PASS_CROSS_REPO_H */ diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 9f363922a..5489402d3 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -12,7 +12,7 @@ */ #include "foundation/constants.h" -enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6, PL_WAL_BUF = 1040 }; +enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6 }; #define PL_NSEC_PER_SEC 1000000000LL #include "pipeline/pipeline.h" #include "pipeline/artifact.h" @@ -36,6 +36,7 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6, P #include "foundation/profile.h" #include "foundation/mem.h" +#include #include #include #include @@ -81,7 +82,8 @@ struct cbm_pipeline { cbm_git_context_t git_ctx; char *branch_qn; cbm_index_mode_t mode; - atomic_int cancelled; + atomic_int cancelled_storage; + atomic_int *cancelled; bool persistence; /* write .codebase-memory/graph.db.zst after indexing */ /* Indexing state (set during run) */ @@ -120,6 +122,13 @@ struct cbm_pipeline { /* ADR (project_summaries) captured before a full-reindex DB delete, so it * can be restored after the rebuild. NULL when no ADR existed. Issue #516. */ char *saved_adr; + + /* Deterministic test-only seam at the final publication boundary. Kept + * per pipeline so concurrent test/process activity cannot cross-trigger. */ + void (*before_publish_hook)(cbm_pipeline_t *, const char *, void *); + void *before_publish_hook_ctx; + int (*rename_hook)(const char *, const char *, void *); + void *rename_hook_ctx; }; /* ── Global pkgmap (one active pipeline at a time) ─────────────── */ @@ -183,7 +192,8 @@ cbm_pipeline_t *cbm_pipeline_new(const char *repo_path, const char *db_path, p->persistence = false; p->committed_nodes = -1; p->committed_edges = -1; - atomic_init(&p->cancelled, 0); + atomic_init(&p->cancelled_storage, 0); + p->cancelled = &p->cancelled_storage; return p; } @@ -254,8 +264,30 @@ void cbm_pipeline_free(cbm_pipeline_t *p) { } void cbm_pipeline_cancel(cbm_pipeline_t *p) { + if (p && p->cancelled) { + atomic_store(p->cancelled, 1); + } +} + +void cbm_pipeline_bind_cancel_flag(cbm_pipeline_t *p, atomic_int *cancelled) { + if (p && cancelled) { + p->cancelled = cancelled; + } +} + +void cbm_pipeline_set_before_publish_hook_for_tests( + cbm_pipeline_t *p, void (*hook)(cbm_pipeline_t *, const char *, void *), void *ctx) { + if (p) { + p->before_publish_hook = hook; + p->before_publish_hook_ctx = ctx; + } +} + +void cbm_pipeline_set_rename_hook_for_tests( + cbm_pipeline_t *p, int (*hook)(const char *, const char *, void *), void *ctx) { if (p) { - atomic_store(&p->cancelled, 1); + p->rename_hook = hook; + p->rename_hook_ctx = ctx; } } @@ -268,7 +300,7 @@ const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p) { } atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p) { - return p ? &p->cancelled : NULL; + return p ? p->cancelled : NULL; } int cbm_pipeline_get_mode(const cbm_pipeline_t *p) { @@ -373,20 +405,42 @@ static int effective_worker_count(bool initial) { /* Resolve the DB path for this pipeline. Caller must free(). */ static char *resolve_db_path(const cbm_pipeline_t *p) { - char *path = malloc(CBM_SZ_1K); - if (!path) { + if (!p) { return NULL; } if (p->db_path) { - snprintf(path, 1024, "%s", p->db_path); - } else { - snprintf(path, 1024, "%s/%s.db", cbm_resolve_cache_dir(), p->project_name); + return strdup(p->db_path); + } + + const char *cache_dir = cbm_resolve_cache_dir(); + cache_dir = cache_dir ? cache_dir : cbm_tmpdir(); + if (!cache_dir || !p->project_name) { + return NULL; + } + size_t cache_len = strlen(cache_dir); + size_t project_len = strlen(p->project_name); + if (project_len > SIZE_MAX - cache_len) { + return NULL; + } + size_t stem_len = cache_len + project_len; + if (stem_len > SIZE_MAX - sizeof("/.db")) { + return NULL; + } + size_t path_size = stem_len + sizeof("/.db"); + char *path = malloc(path_size); + if (!path) { + return NULL; + } + int n = snprintf(path, path_size, "%s/%s.db", cache_dir, p->project_name); + if (n < 0 || (size_t)n >= path_size) { + free(path); + return NULL; } return path; } static int check_cancel(const cbm_pipeline_t *p) { - return atomic_load(&p->cancelled) ? CBM_NOT_FOUND : 0; + return atomic_load(p->cancelled) ? CBM_NOT_FOUND : 0; } /* ── Hash table cleanup callback ─────────────────────────────────── */ @@ -1150,13 +1204,8 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file cbm_store_close(adr_store); } } - cbm_unlink(db_path); - char wal[PL_WAL_BUF]; - char shm[PL_WAL_BUF]; - snprintf(wal, sizeof(wal), "%s-wal", db_path); - snprintf(shm, sizeof(shm), "%s-shm", db_path); - cbm_unlink(wal); - cbm_unlink(shm); + (void)cbm_unlink(db_path); + (void)cbm_remove_db_sidecars(db_path); free(db_path); return CBM_NOT_FOUND; } @@ -1190,23 +1239,27 @@ static const char *pipeline_mode_name(cbm_index_mode_t mode) { static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *files, int file_count, struct timespec *t) { cbm_clock_gettime(CLOCK_MONOTONIC, t); - char db_path[CBM_SZ_1K]; - if (p->db_path) { - snprintf(db_path, sizeof(db_path), "%s", p->db_path); - } else { - const char *cdir = cbm_resolve_cache_dir(); - if (!cdir) { - cdir = cbm_tmpdir(); - } - snprintf(db_path, sizeof(db_path), "%s/%s.db", cdir, p->project_name); + char *db_path = resolve_db_path(p); + if (!db_path) { + return CBM_NOT_FOUND; + } + char *db_dir = strdup(db_path); + if (!db_dir) { + free(db_path); + return CBM_NOT_FOUND; } - char db_dir[CBM_SZ_1K]; - snprintf(db_dir, sizeof(db_dir), "%s", db_path); char *last_slash = strrchr(db_dir, '/'); +#ifdef _WIN32 + char *last_backslash = strrchr(db_dir, '\\'); + if (last_backslash && (!last_slash || last_backslash > last_slash)) { + last_slash = last_backslash; + } +#endif if (last_slash) { *last_slash = '\0'; cbm_mkdir_p(db_dir, CBM_DIR_PERMS); } + free(db_dir); /* Capture committed counts BEFORE the dump. cbm_gbuf_dump_to_sqlite calls * release_gbuf_indexes(), which frees node_by_qn (graph_buffer.c), after * which cbm_gbuf_node_count() returns 0. Reading these post-dump left @@ -1216,6 +1269,7 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil int rc = cbm_gbuf_dump_to_sqlite(p->gbuf, db_path); if (rc != 0) { cbm_log_error("pipeline.err", "phase", "dump"); + free(db_path); return rc; } cbm_log_info("pass.timing", "pass", "dump", "elapsed_ms", itoa_buf((int)elapsed_ms(*t))); @@ -1224,8 +1278,13 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil CBM_PROF_START(t_reopen); cbm_store_t *hash_store = cbm_store_open_path(db_path); CBM_PROF_END("persist", "1_reopen", t_reopen); - if (hash_store) { - bool hash_records_complete = true; + if (!hash_store) { + cbm_log_error("pipeline.err", "phase", "persist_open", "path", db_path); + free(db_path); + return CBM_NOT_FOUND; + } + bool hash_records_complete = true; + { CBM_PROF_START(t_delhash); if (cbm_store_delete_file_hashes(hash_store, p->project_name) != CBM_STORE_OK) { hash_records_complete = false; @@ -1376,19 +1435,7 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil } free(p->saved_adr); p->saved_adr = NULL; - - /* Export persistent artifact if enabled */ - if (p->persistence) { - CBM_PROF_START(t_art); - int arc = cbm_artifact_export(db_path, p->repo_path, p->project_name, CBM_ARTIFACT_BEST); - CBM_PROF_END("persist", "6_artifact_export", t_art); - if (arc != 0) { - const char *err = cbm_artifact_export_last_error(); - cbm_log_error("pipeline.err", "phase", "artifact_export", "err", err ? err : "unknown"); - /* A failed persistence export intentionally fails the run; this used to be ignored. */ - return arc; - } - } + free(db_path); return 0; } @@ -1505,10 +1552,11 @@ static int run_extraction_phase(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx, return rc; } -int cbm_pipeline_run(cbm_pipeline_t *p) { +static int cbm_pipeline_run_staged(cbm_pipeline_t *p, bool *was_incremental) { if (!p) { return CBM_NOT_FOUND; } + *was_incremental = false; CBM_PROF_START(t_pipeline_total); struct timespec t0; @@ -1562,9 +1610,12 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { /* Check for existing DB → try incremental or delete for reindex */ rc = try_incremental_or_delete_db(p, files, file_count); - if (rc == CBM_PIPELINE_ABORT_PRESERVE_DB || rc >= 0) { - cbm_discover_free(files, file_count); - return rc; + if (rc == CBM_PIPELINE_ABORT_PRESERVE_DB) { + goto cleanup; + } + if (rc >= 0) { + *was_incremental = true; + goto cleanup; } cbm_log_info("pipeline.route", "path", "full"); @@ -1583,7 +1634,7 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { .repo_path = p->repo_path, .gbuf = p->gbuf, .registry = p->registry, - .cancelled = &p->cancelled, + .cancelled = p->cancelled, .pipeline = p, /* so passes can record per-file skips (Track B) */ .mode = (int)p->mode, .path_aliases = path_aliases, @@ -1621,3 +1672,241 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { p->userconfig = NULL; return rc; } + +static void cleanup_staging_db(const char *path) { + if (!path) { + return; + } + (void)cbm_unlink(path); + (void)cbm_remove_db_sidecars(path); +} + +static bool ensure_db_parent(const char *path) { + if (!path) { + return false; + } + char *dir = strdup(path); + if (!dir) { + return false; + } + char *slash = strrchr(dir, '/'); +#ifdef _WIN32 + char *backslash = strrchr(dir, '\\'); + if (backslash && (!slash || backslash > slash)) { + slash = backslash; + } +#endif + if (!slash) { + free(dir); + return true; + } + *slash = '\0'; + bool ok = dir[0] == '\0' || cbm_mkdir_p(dir, CBM_DIR_PERMS); + free(dir); + return ok; +} + +static char *create_staging_path(const char *final_path) { + if (!final_path) { + return NULL; + } + static const char suffix[] = ".stage.XXXXXX"; + size_t final_len = strlen(final_path); + if (final_len > SIZE_MAX - sizeof(suffix)) { + return NULL; + } + size_t path_size = final_len + sizeof(suffix); +#ifdef _WIN32 + /* The Windows cbm_mkstemp compatibility contract may expand a /tmp/ + * prefix in-place and copies through a 4 KiB scratch path. Give it that + * full capacity, and reject longer inputs exactly rather than truncating. */ + if (path_size > CBM_SZ_4K) { + return NULL; + } + path_size = CBM_SZ_4K; +#endif + char *path = (char *)malloc(path_size); + if (!path) { + return NULL; + } + memcpy(path, final_path, final_len); + memcpy(path + final_len, suffix, sizeof(suffix)); + int fd = cbm_mkstemp(path); + if (fd < 0) { + free(path); + return NULL; + } +#ifdef _WIN32 + _close(fd); +#else + close(fd); +#endif + return path; +} + +/* A backup-failed destination may still have the only recoverable WAL or + * rollback journal. Publication may replace its main file only when no + * sidecar exists; otherwise fail without mutating the old generation. */ +static bool db_sidecars_absent(const char *db_path) { + if (!db_path || !db_path[0]) { + return false; + } + enum { SIDECAR_PATH_MAX = 4096 }; + char side[SIDECAR_PATH_MAX]; + if (strlen(db_path) > sizeof(side) - sizeof("-journal")) { + return false; + } + static const char *const suffixes[] = {"-wal", "-shm", "-journal"}; + for (size_t i = 0; i < sizeof(suffixes) / sizeof(suffixes[0]); i++) { + int n = snprintf(side, sizeof(side), "%s%s", db_path, suffixes[i]); + if (n <= 0 || (size_t)n >= sizeof(side)) { + return false; + } + struct stat side_st; + if (stat(side, &side_st) == 0 || errno != ENOENT) { + return false; + } + } + return true; +} + +static bool prepare_publish_destination(const char *final_path, bool final_existed, + bool backup_succeeded) { + struct stat current_st; + bool final_exists_now = stat(final_path, ¤t_st) == 0; + if (final_exists_now != final_existed) { + return false; + } + if (!final_exists_now) { + /* A crashed generation can leave sidecars without a main file. */ + return cbm_remove_db_sidecars(final_path) == 0; + } + if (!backup_succeeded) { + bool safe_to_replace = db_sidecars_absent(final_path); + if (!safe_to_replace) { + cbm_log_error("pipeline.err", "phase", "publish", "reason", + "backup_failed_sidecars_preserved", "path", final_path); + } + return safe_to_replace; + } + return cbm_store_prepare_path_for_replace(final_path) == CBM_STORE_OK && + cbm_remove_db_sidecars(final_path) == 0; +} + +static int seal_staging_db(const char *staging_path) { + cbm_store_t *store = cbm_store_open_path(staging_path); + if (!store) { + return CBM_NOT_FOUND; + } + int rc = cbm_store_check_integrity(store) && cbm_store_prepare_for_publish(store) == CBM_STORE_OK + ? 0 + : CBM_NOT_FOUND; + cbm_store_close(store); + if (rc == 0 && cbm_remove_db_sidecars(staging_path) != 0) { + rc = CBM_NOT_FOUND; + } + return rc; +} + +static int export_after_publish(cbm_pipeline_t *p, const char *final_path, bool was_incremental) { + if (p->persistence) { + CBM_PROF_START(t_art); + int rc = + cbm_artifact_export(final_path, p->repo_path, p->project_name, CBM_ARTIFACT_BEST); + CBM_PROF_END("persist", "6_artifact_export", t_art); + if (rc != 0) { + const char *err = cbm_artifact_export_last_error(); + cbm_log_error("pipeline.err", "phase", "artifact_export", "err", + err ? err : "unknown"); + } + return rc; + } + if (was_incremental && p->repo_path && cbm_artifact_exists(p->repo_path)) { + (void)cbm_artifact_export(final_path, p->repo_path, p->project_name, CBM_ARTIFACT_FAST); + } + return 0; +} + +int cbm_pipeline_run(cbm_pipeline_t *p) { + if (!p) { + return CBM_NOT_FOUND; + } + char *final_path = resolve_db_path(p); + if (!final_path || !ensure_db_parent(final_path)) { + free(final_path); + return CBM_NOT_FOUND; + } + struct stat final_st; + bool final_existed = stat(final_path, &final_st) == 0; + char *staging_path = create_staging_path(final_path); + if (!staging_path) { + free(final_path); + return CBM_NOT_FOUND; + } + + bool backup_succeeded = false; + if (final_existed) { + backup_succeeded = + cbm_store_backup_path(final_path, staging_path) == CBM_STORE_OK; + if (!backup_succeeded) { + cbm_log_warn("pipeline.stage", "action", "backup_failed_full_rebuild", "path", + final_path); + cleanup_staging_db(staging_path); + } + } + + char *configured_db_path = p->db_path; + p->db_path = strdup(staging_path); + if (!p->db_path) { + p->db_path = configured_db_path; + cleanup_staging_db(staging_path); + free(staging_path); + free(final_path); + return CBM_NOT_FOUND; + } + bool was_incremental = false; + int rc = cbm_pipeline_run_staged(p, &was_incremental); + free(p->db_path); + p->db_path = configured_db_path; + + if (rc != 0 || check_cancel(p) || seal_staging_db(staging_path) != 0) { + cleanup_staging_db(staging_path); + free(staging_path); + free(final_path); + return CBM_NOT_FOUND; + } + + if (p->before_publish_hook) { + p->before_publish_hook(p, staging_path, p->before_publish_hook_ctx); + } + if (check_cancel(p)) { + cleanup_staging_db(staging_path); + free(staging_path); + free(final_path); + return CBM_NOT_FOUND; + } + + /* A test hook may inspect the DB through SQLite and re-enable WAL mode; + * seal once more before installing the standalone main file. */ + if (seal_staging_db(staging_path) != 0) { + cleanup_staging_db(staging_path); + free(staging_path); + free(final_path); + return CBM_NOT_FOUND; + } + + if (!prepare_publish_destination(final_path, final_existed, backup_succeeded) || + (p->rename_hook ? p->rename_hook(staging_path, final_path, p->rename_hook_ctx) + : cbm_rename_replace(staging_path, final_path)) != 0) { + cbm_log_error("pipeline.err", "phase", "publish", "path", final_path); + cleanup_staging_db(staging_path); + free(staging_path); + free(final_path); + return CBM_NOT_FOUND; + } + + rc = export_after_publish(p, final_path, was_incremental); + free(staging_path); + free(final_path); + return rc; +} diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 67182329e..78cfd37fd 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -17,6 +17,7 @@ #include #include +#include #include "discover/discover.h" /* cbm_ignored_file_t (#963) */ @@ -61,6 +62,12 @@ int cbm_pipeline_run(cbm_pipeline_t *p); /* Request cancellation of a running pipeline (thread-safe). */ void cbm_pipeline_cancel(cbm_pipeline_t *p); +/* Bind cancellation to a caller-owned atomic flag. The flag must outlive the + * pipeline and should be initialized before binding. This lets a long-lived + * daemon request cancellation without retaining/dereferencing a pipeline + * pointer that its request thread may concurrently retire. */ +void cbm_pipeline_bind_cancel_flag(cbm_pipeline_t *p, atomic_int *cancelled); + /* Get the project name derived from repo_path. Returned string is * owned by the pipeline. Valid until cbm_pipeline_free(). */ const char *cbm_pipeline_project_name(const cbm_pipeline_t *p); diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 8b5766978..5e0378ea9 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -11,9 +11,8 @@ */ #include "foundation/constants.h" -enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24, INCR_WAL_BUF = 1040 }; +enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24 }; #include "pipeline/pipeline.h" -#include "pipeline/artifact.h" #include #include #include "pipeline/pipeline_internal.h" @@ -449,18 +448,12 @@ static void incr_free_edge_capture(cbm_edge_capture_t *cap) { /* Persist file hash rows for the current discovery and any mode-skipped * files preserved from the previous DB. * - * Partial-failure policy: an `upsert` failure on any single row is logged - * as a warning and the loop continues. We deliberately do NOT abort the - * whole reindex on a single bad row — partial preservation is better than - * total loss, and a transient failure on one file should not invalidate - * the entire incremental update. The trade-off is that a silently-failed - * row produces the same downstream effect as if the file were never - * indexed at all (forced re-parse on the next run for current-files, - * potential orphaned-node revival for mode_skipped). The warning surface - * is the only signal that something went wrong. */ -static bool persist_hashes(cbm_store_t *store, const char *project, cbm_file_info_t *files, - int file_count, const cbm_file_hash_t *mode_skipped, - int mode_skipped_count) { + * Every row is attempted so logs identify all failures, but any failed + * upsert rejects the staging generation. Atomic publication makes preserving + * the complete previous generation safer than installing partial metadata. */ +static int persist_hashes(cbm_store_t *store, const char *project, cbm_file_info_t *files, + int file_count, const cbm_file_hash_t *mode_skipped, + int mode_skipped_count) { int current_failed = 0; int ms_failed = 0; @@ -511,8 +504,9 @@ static bool persist_hashes(cbm_store_t *store, const char *project, cbm_file_inf if (current_failed > 0 || ms_failed > 0) { cbm_log_warn("incremental.persist_summary", "current_failed", itoa_buf(current_failed), "mode_skipped_failed", itoa_buf(ms_failed)); + return CBM_STORE_ERR; } - return current_failed == 0 && ms_failed == 0; + return CBM_STORE_OK; } /* ── Registry seed visitor ────────────────────────────────────────── */ @@ -643,68 +637,72 @@ static void run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_fil * Mode-skipped hash rows are preserved across the rebuild so subsequent * reindexes can correctly distinguish "never indexed" from "indexed but * not visited this pass". */ -static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project, - cbm_file_info_t *files, int file_count, - const cbm_file_hash_t *mode_skipped, int mode_skipped_count, - const char *repo_path, const cbm_coverage_row_t *cov, int cov_count, - const cbm_coverage_meta_t *meta_template) { +static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *project, + cbm_file_info_t *files, int file_count, + const cbm_file_hash_t *mode_skipped, int mode_skipped_count, + const cbm_coverage_row_t *cov, int cov_count, + const cbm_coverage_meta_t *meta_template) { struct timespec t; cbm_clock_gettime(CLOCK_MONOTONIC, &t); - cbm_unlink(db_path); - char wal[INCR_WAL_BUF]; - char shm[INCR_WAL_BUF]; - snprintf(wal, sizeof(wal), "%s-wal", db_path); - snprintf(shm, sizeof(shm), "%s-shm", db_path); - cbm_unlink(wal); - cbm_unlink(shm); + if ((cbm_unlink(db_path) != 0 && errno != ENOENT) || cbm_remove_db_sidecars(db_path) != 0) { + cbm_log_error("incremental.err", "msg", "clear_staging_failed", "path", db_path); + return CBM_STORE_ERR; + } int dump_rc = cbm_gbuf_dump_to_sqlite(gbuf, db_path); cbm_log_info("incremental.dump", "rc", itoa_buf(dump_rc), "elapsed_ms", itoa_buf((int)elapsed_ms(t))); + if (dump_rc != 0) { + return dump_rc; + } cbm_store_t *hash_store = cbm_store_open_path(db_path); - if (hash_store) { - bool hash_records_complete = persist_hashes(hash_store, project, files, file_count, - mode_skipped, mode_skipped_count); - - /* Coverage rows (#963): re-write the merged set into the rebuilt DB - * (AFTER hashes, so the deleted-file prune sees the live file set). */ - cbm_project_t project_info = {0}; - bool have_project_info = - cbm_store_get_project(hash_store, project, &project_info) == CBM_STORE_OK; - cbm_coverage_meta_t meta = meta_template ? *meta_template : (cbm_coverage_meta_t){0}; - meta.generation = have_project_info ? project_info.indexed_at : NULL; - meta.hash_records_complete = hash_records_complete; - if (cbm_store_coverage_replace_ex(hash_store, project, cov, cov_count, &meta) != - CBM_STORE_OK) { - cbm_log_error("incremental.err", "msg", "persist_coverage", "project", project); - } - if (have_project_info) { - cbm_project_free_fields(&project_info); - } - - /* FTS5 rebuild after incremental dump. The btree dump path bypasses - * any triggers that could have kept nodes_fts synchronized, so we - * rebuild from the nodes table here. See the full-dump path in - * pipeline.c for the matching logic. */ - cbm_store_exec(hash_store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');"); + if (!hash_store) { + cbm_log_error("incremental.err", "msg", "open_staging_after_dump", "path", db_path); + return CBM_STORE_ERR; + } + int rc = persist_hashes(hash_store, project, files, file_count, mode_skipped, + mode_skipped_count); + + /* Coverage rows (#963): re-write the merged set into the rebuilt DB + * (AFTER hashes, so the deleted-file prune sees the live file set). */ + cbm_project_t project_info = {0}; + bool have_project_info = + cbm_store_get_project(hash_store, project, &project_info) == CBM_STORE_OK; + cbm_coverage_meta_t meta = meta_template ? *meta_template : (cbm_coverage_meta_t){0}; + meta.generation = have_project_info ? project_info.indexed_at : NULL; + meta.hash_records_complete = rc == CBM_STORE_OK; + if (cbm_store_coverage_replace_ex(hash_store, project, cov, cov_count, &meta) != + CBM_STORE_OK) { + cbm_log_error("incremental.err", "msg", "persist_coverage", "project", project); + rc = CBM_STORE_ERR; + } + if (have_project_info) { + cbm_project_free_fields(&project_info); + } + + /* FTS5 rebuild after incremental dump. The btree dump path bypasses + * any triggers that could have kept nodes_fts synchronized, so we + * rebuild from the nodes table here. See the full-dump path in + * pipeline.c for the matching logic. */ + if (cbm_store_exec(hash_store, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');") != + CBM_STORE_OK) { + rc = CBM_STORE_ERR; + } + if (cbm_store_exec(hash_store, + "INSERT INTO nodes_fts(rowid, name, qualified_name, label, file_path) " + "SELECT id, cbm_camel_split(name), qualified_name, label, file_path " + "FROM nodes;") != CBM_STORE_OK) { if (cbm_store_exec(hash_store, "INSERT INTO nodes_fts(rowid, name, qualified_name, label, file_path) " - "SELECT id, cbm_camel_split(name), qualified_name, label, file_path " - "FROM nodes;") != CBM_STORE_OK) { - cbm_store_exec(hash_store, - "INSERT INTO nodes_fts(rowid, name, qualified_name, label, file_path) " - "SELECT id, name, qualified_name, label, file_path FROM nodes;"); + "SELECT id, name, qualified_name, label, file_path FROM nodes;") != + CBM_STORE_OK) { + rc = CBM_STORE_ERR; } - - cbm_store_close(hash_store); - } - - /* Auto-update artifact if one already exists (persistence was enabled previously) */ - if (repo_path && cbm_artifact_exists(repo_path)) { - cbm_artifact_export(db_path, repo_path, project, CBM_ARTIFACT_FAST); } + cbm_store_close(hash_store); + return rc; } /* ── Incremental pipeline entry point ────────────────────────────── */ @@ -990,10 +988,9 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil itoa_buf(edge_cap.count), "elapsed_ms", itoa_buf((int)elapsed_ms(t))); incr_free_edge_capture(&edge_cap); - /* Step 7: Dump to disk (preserves mode-skipped hash rows so the next + /* Step 7: Dump to staging (preserves mode-skipped hash rows so the next * reindex can correctly classify those files instead of seeing them - * as never-existed; also exports a fast-mode artifact when one is - * already present alongside the repo). */ + * as never-existed). Artifact refresh happens only after publication. */ /* Record committed counts before dump_and_persist (whose dump frees the * gbuf node index, zeroing the count) so the #334 plausibility gate also * covers incremental reindexes, not just full ones. */ @@ -1010,13 +1007,13 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .ignored_files_total = run_ignored_total, .coverage_version = 1, }; - dump_and_persist(existing, db_path, project, files, file_count, mode_skipped, - mode_skipped_count, cbm_pipeline_repo_path(p), cov, cov_n, &coverage_meta); + int persist_rc = dump_and_persist(existing, db_path, project, files, file_count, mode_skipped, + mode_skipped_count, cov, cov_n, &coverage_meta); free(cov); cbm_store_free_coverage(old_cov, old_cov_count); free_mode_skipped(mode_skipped, mode_skipped_count); cbm_gbuf_free(existing); cbm_log_info("incremental.done", "elapsed_ms", itoa_buf((int)elapsed_ms(t0))); - return 0; + return persist_rc; } diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index d2180a47a..3a7d453ed 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -631,6 +631,13 @@ atomic_int *cbm_pipeline_cancelled_ptr(cbm_pipeline_t *p); * which cannot see the opaque cbm_pipeline struct. Call before the dump. */ void cbm_pipeline_set_committed_counts(cbm_pipeline_t *p, int nodes, int edges); +/* Test seam: invoked after a complete staging DB is sealed and immediately + * before the cancellation check + atomic replace. Not part of the public API. */ +void cbm_pipeline_set_before_publish_hook_for_tests( + cbm_pipeline_t *p, void (*hook)(cbm_pipeline_t *, const char *, void *), void *ctx); +void cbm_pipeline_set_rename_hook_for_tests( + cbm_pipeline_t *p, int (*hook)(const char *, const char *, void *), void *ctx); + /* Parse a gRPC stub call "." into the canonical proto * service name + method. Returns true ONLY when a recognized gRPC stub/client * suffix is present (the stub-type signal that gates Route emission, #294). diff --git a/src/semantic/rotsq.c b/src/semantic/rotsq.c index 41ac0636a..3699fe718 100644 --- a/src/semantic/rotsq.c +++ b/src/semantic/rotsq.c @@ -3,6 +3,7 @@ */ #include "semantic/rotsq.h" +#include #include #define XXH_INLINE_ALL @@ -11,17 +12,32 @@ /* ── Deterministic ±1 diagonal (seeded, generated once) ─────────────── */ static float g_rsq_diag[CBM_RSQ_DIM]; -static int g_rsq_diag_ready = 0; +enum { + RSQ_DIAG_UNINITIALIZED = 0, + RSQ_DIAG_INITIALIZING, + RSQ_DIAG_READY, +}; +static atomic_int g_rsq_diag_state = ATOMIC_VAR_INIT(RSQ_DIAG_UNINITIALIZED); static void rsq_init_diag(void) { - if (g_rsq_diag_ready) { + if (atomic_load_explicit(&g_rsq_diag_state, memory_order_acquire) == RSQ_DIAG_READY) { return; } - for (int d = 0; d < CBM_RSQ_DIM; d++) { - uint64_t h = XXH3_64bits_withSeed(&d, sizeof(d), 0x5bd1e995u); - g_rsq_diag[d] = (h & 1u) ? 1.0F : -1.0F; + + int expected = RSQ_DIAG_UNINITIALIZED; + if (atomic_compare_exchange_strong_explicit(&g_rsq_diag_state, &expected, RSQ_DIAG_INITIALIZING, + memory_order_acq_rel, memory_order_acquire)) { + for (int d = 0; d < CBM_RSQ_DIM; d++) { + uint64_t h = XXH3_64bits_withSeed(&d, sizeof(d), 0x5bd1e995u); + g_rsq_diag[d] = (h & 1u) ? 1.0F : -1.0F; + } + atomic_store_explicit(&g_rsq_diag_state, RSQ_DIAG_READY, memory_order_release); + return; } - g_rsq_diag_ready = 1; + + /* Initialization is bounded and cannot fail. Acquire pairs with the + * initializer's release so readers observe the fully populated table. */ + while (atomic_load_explicit(&g_rsq_diag_state, memory_order_acquire) != RSQ_DIAG_READY) {} } /* ── Fast Walsh–Hadamard Transform (in place, unnormalized) ─────────── */ diff --git a/src/store/store.c b/src/store/store.c index 112d5ea71..2595adb25 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -635,19 +635,20 @@ static int store_authorizer(void *user_data, int action, const char *p3, const c } } -static cbm_store_t *store_open_internal(const char *path, bool in_memory) { +static cbm_store_t *store_open_internal(const char *path, bool in_memory, bool create) { cbm_store_t *s = calloc(CBM_ALLOC_ONE, sizeof(cbm_store_t)); if (!s) { return NULL; } - int flags = SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE; + int flags = SQLITE_OPEN_READWRITE | (create ? SQLITE_OPEN_CREATE : 0); if (in_memory) { flags |= SQLITE_OPEN_MEMORY; } int rc = sqlite3_open_v2(path, &s->db, flags, NULL); if (rc != SQLITE_OK) { + sqlite3_close(s->db); free(s); return NULL; } @@ -685,14 +686,21 @@ static cbm_store_t *store_open_internal(const char *path, bool in_memory) { } cbm_store_t *cbm_store_open_memory(void) { - return store_open_internal(":memory:", true); + return store_open_internal(":memory:", true, true); } cbm_store_t *cbm_store_open_path(const char *db_path) { if (!db_path) { return NULL; } - return store_open_internal(db_path, false); + return store_open_internal(db_path, false, true); +} + +cbm_store_t *cbm_store_open_path_existing(const char *db_path) { + if (!db_path) { + return NULL; + } + return store_open_internal(db_path, false, false); } const char *cbm_store_db_path(const cbm_store_t *s) { @@ -933,7 +941,7 @@ cbm_store_t *cbm_store_open(const char *project) { } char path[CBM_SZ_1K]; snprintf(path, sizeof(path), "%s/%s.db", cdir, project); - return store_open_internal(path, false); + return store_open_internal(path, false, true); } static void finalize_stmt(sqlite3_stmt **s) { @@ -1081,6 +1089,99 @@ int cbm_store_checkpoint(cbm_store_t *s) { return exec_sql(s, "PRAGMA optimize;"); } +static int prepare_sqlite_for_publish(sqlite3 *db) { + if (!db) { + return CBM_STORE_ERR; + } + int log_frames = -1; + int checkpointed_frames = -1; + int rc = sqlite3_wal_checkpoint_v2(db, NULL, SQLITE_CHECKPOINT_TRUNCATE, &log_frames, + &checkpointed_frames); + if (rc != SQLITE_OK || (log_frames >= 0 && checkpointed_frames != log_frames)) { + return CBM_STORE_ERR; + } + + sqlite3_stmt *stmt = NULL; + rc = sqlite3_prepare_v2(db, "PRAGMA journal_mode=DELETE;", CBM_NOT_FOUND, &stmt, NULL); + if (rc != SQLITE_OK) { + return CBM_STORE_ERR; + } + bool delete_mode = false; + if (sqlite3_step(stmt) == SQLITE_ROW) { + const char *mode = (const char *)sqlite3_column_text(stmt, 0); + delete_mode = mode && strcmp(mode, "delete") == 0; + } + sqlite3_finalize(stmt); + return delete_mode ? CBM_STORE_OK : CBM_STORE_ERR; +} + +int cbm_store_prepare_for_publish(cbm_store_t *s) { + if (!s || !s->db) { + return CBM_STORE_ERR; + } + return prepare_sqlite_for_publish(s->db); +} + +int cbm_store_prepare_path_for_replace(const char *path) { + if (!path) { + return CBM_STORE_ERR; + } + sqlite3 *db = NULL; + int rc = sqlite3_open_v2(path, &db, SQLITE_OPEN_READWRITE, NULL); + if (rc != SQLITE_OK) { + sqlite3_close(db); + return CBM_STORE_ERR; + } + sqlite3_busy_timeout(db, 10000); + int result = prepare_sqlite_for_publish(db); + if (sqlite3_close(db) != SQLITE_OK) { + result = CBM_STORE_ERR; + } + return result; +} + +int cbm_store_backup_path(const char *source_path, const char *staging_path) { + if (!source_path || !staging_path) { + return CBM_STORE_ERR; + } + if (cbm_remove_db_sidecars(staging_path) != 0) { + return CBM_STORE_ERR; + } + + sqlite3 *source = NULL; + sqlite3 *dest = NULL; + int rc = sqlite3_open_v2(source_path, &source, SQLITE_OPEN_READONLY, NULL); + if (rc != SQLITE_OK) { + sqlite3_close(source); + return CBM_STORE_ERR; + } + sqlite3_busy_timeout(source, 10000); + rc = sqlite3_open_v2(staging_path, &dest, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL); + if (rc != SQLITE_OK) { + sqlite3_close(dest); + sqlite3_close(source); + return CBM_STORE_ERR; + } + sqlite3_busy_timeout(dest, 10000); + + sqlite3_backup *backup = sqlite3_backup_init(dest, "main", source, "main"); + int result = CBM_STORE_ERR; + if (backup) { + int step_rc = sqlite3_backup_step(backup, CBM_NOT_FOUND); + int finish_rc = sqlite3_backup_finish(backup); + if (step_rc == SQLITE_DONE && finish_rc == SQLITE_OK) { + result = CBM_STORE_OK; + } + } + if (sqlite3_close(dest) != SQLITE_OK) { + result = CBM_STORE_ERR; + } + if (sqlite3_close(source) != SQLITE_OK) { + result = CBM_STORE_ERR; + } + return result; +} + /* ── Dump ───────────────────────────────────────────────────────── */ /* Dump entire in-memory database to a file via sqlite3_backup. diff --git a/src/store/store.h b/src/store/store.h index 963837f88..b69525935 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -100,6 +100,18 @@ int cbm_store_find_edges_by_url_path(cbm_store_t *s, const char *project, const /* Restore database from another store (backup API). */ int cbm_store_restore_from(cbm_store_t *dst, cbm_store_t *src); +/* Copy a transactionally-consistent snapshot, including committed WAL frames, + * from an existing DB into a same-directory staging path. */ +int cbm_store_backup_path(const char *source_path, const char *staging_path); + +/* Seal a staging DB into one self-contained main file before atomic publish. + * The store must have no concurrent users. */ +int cbm_store_prepare_for_publish(cbm_store_t *s); + +/* Checkpoint and detach sidecars from an existing destination immediately + * before replacement. Fails closed while another process prevents sealing. */ +int cbm_store_prepare_path_for_replace(const char *path); + /* ── Search ─────────────────────────────────────────────────────── */ typedef struct { @@ -201,6 +213,11 @@ cbm_store_t *cbm_store_open_memory(void); /* Open a file-backed database at the given path. Creates if needed. */ cbm_store_t *cbm_store_open_path(const char *db_path); +/* Open an existing file-backed database read-write without CREATE. Intended + * for coordinated mutations where a missing/typo path must never materialize + * a ghost database. Returns NULL when the file does not exist. */ +cbm_store_t *cbm_store_open_path_existing(const char *db_path); + /* Open an existing file-backed database for querying only. Opened READ-ONLY * (no SQLITE_OPEN_CREATE, no write pragmas) so queries never mutate the DB and * work on a read-only file / filesystem. Returns NULL if the file does not diff --git a/src/ui/config.c b/src/ui/config.c index 678c3fada..13b6cfe2a 100644 --- a/src/ui/config.c +++ b/src/ui/config.c @@ -14,10 +14,22 @@ #include +#include +#include #include #include #include +#ifdef _WIN32 +#include "foundation/win_utf8.h" + +#include +#else +#include +#include +#include +#endif + /* ── Path ────────────────────────────────────────────────────── */ void cbm_ui_config_path(char *buf, int bufsz) { @@ -30,43 +42,103 @@ void cbm_ui_config_path(char *buf, int bufsz) { /* ── Load ────────────────────────────────────────────────────── */ +static char *config_read_file(const char *path, size_t *length_out, + bool *opened_out) { + if (length_out) { + *length_out = 0; + } + if (opened_out) { + *opened_out = false; + } + if (!path || !length_out || !opened_out) { + return NULL; + } +#ifdef _WIN32 + wchar_t *wide_path = cbm_utf8_to_wide(path); + if (!wide_path) { + return NULL; + } + HANDLE file = CreateFileW(wide_path, GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + free(wide_path); + if (file == INVALID_HANDLE_VALUE) { + return NULL; + } + *opened_out = true; + LARGE_INTEGER size; + if (!GetFileSizeEx(file, &size) || size.QuadPart <= 0 || + size.QuadPart > 4096) { + (void)CloseHandle(file); + return NULL; + } + size_t length = (size_t)size.QuadPart; + char *buffer = malloc(length + 1U); + DWORD read_length = 0; + bool read_ok = buffer && + ReadFile(file, buffer, (DWORD)length, &read_length, NULL) && + read_length == (DWORD)length; + (void)CloseHandle(file); + if (!read_ok) { + free(buffer); + return NULL; + } +#else + FILE *file = cbm_fopen(path, "rb"); + if (!file) { + return NULL; + } + *opened_out = true; + if (fseek(file, 0, SEEK_END) != 0) { + (void)fclose(file); + return NULL; + } + long file_length = ftell(file); + if (file_length <= 0 || file_length > 4096 || + fseek(file, 0, SEEK_SET) != 0) { + (void)fclose(file); + return NULL; + } + size_t length = (size_t)file_length; + char *buffer = malloc(length + 1U); + bool read_ok = buffer && fread(buffer, 1, length, file) == length; + (void)fclose(file); + if (!read_ok) { + free(buffer); + return NULL; + } +#endif + buffer[length] = '\0'; + *length_out = length; + return buffer; +} + void cbm_ui_config_load(cbm_ui_config_t *cfg) { + if (!cfg) { + return; + } cfg->ui_enabled = CBM_UI_DEFAULT_ENABLED; cfg->ui_port = CBM_UI_DEFAULT_PORT; char path[CBM_SZ_1K]; cbm_ui_config_path(path, (int)sizeof(path)); - FILE *f = fopen(path, "rb"); - if (!f) { + size_t length = 0; + bool opened = false; + char *buffer = config_read_file(path, &length, &opened); + if (!opened) { /* No config file — auto-enable UI if binary has embedded assets */ if (CBM_EMBEDDED_FILE_COUNT > 0) { cfg->ui_enabled = true; } return; } - - fseek(f, 0, SEEK_END); - long len = ftell(f); - fseek(f, 0, SEEK_SET); - - if (len <= 0 || len > 4096) { - fclose(f); - return; /* empty or suspiciously large → defaults */ - } - - char *buf = malloc((size_t)len + SKIP_ONE); - if (!buf) { - fclose(f); + if (!buffer) { return; } - size_t nread = fread(buf, SKIP_ONE, (size_t)len, f); - fclose(f); - buf[nread] = '\0'; - - yyjson_doc *doc = yyjson_read(buf, nread, 0); - free(buf); + yyjson_doc *doc = yyjson_read(buffer, length, 0); + free(buffer); if (!doc) { cbm_log_warn("ui.config.corrupt", "path", path); return; /* corrupt JSON → defaults */ @@ -85,7 +157,10 @@ void cbm_ui_config_load(cbm_ui_config_t *cfg) { yyjson_val *v_port = yyjson_obj_get(root, "ui_port"); if (yyjson_is_int(v_port)) { - cfg->ui_port = (int)yyjson_get_int(v_port); + int64_t port = yyjson_get_int(v_port); + if (port > 0 && port <= 65535) { + cfg->ui_port = (int)port; + } } yyjson_doc_free(doc); @@ -93,45 +168,233 @@ void cbm_ui_config_load(cbm_ui_config_t *cfg) { /* ── Save ────────────────────────────────────────────────────── */ -void cbm_ui_config_save(const cbm_ui_config_t *cfg) { +static bool config_parent_directory(const char *path, char *directory, + size_t directory_size) { + int written = snprintf(directory, directory_size, "%s", path ? path : ""); + if (written <= 0 || (size_t)written >= directory_size) { + return false; + } + char *slash = strrchr(directory, '/'); + char *backslash = strrchr(directory, '\\'); + if (backslash && (!slash || backslash > slash)) { + slash = backslash; + } + if (!slash) { + return snprintf(directory, directory_size, ".") == 1; + } + if (slash == directory) { + slash[1] = '\0'; + } else { + *slash = '\0'; + } + return true; +} + +#ifdef _WIN32 +static volatile LONG g_config_temp_sequence = 0; + +static bool config_write_atomic(const char *path, const char *json, + size_t json_length) { + wchar_t *wide_path = cbm_utf8_to_wide(path); + if (!wide_path) { + return false; + } + size_t temporary_capacity = wcslen(wide_path) + 40U; + wchar_t *temporary = calloc(temporary_capacity, sizeof(*temporary)); + HANDLE file = INVALID_HANDLE_VALUE; + if (temporary) { + for (unsigned int attempt = 0; attempt < 128U; attempt++) { + ULONG sequence = (ULONG)InterlockedIncrement(&g_config_temp_sequence); + int written = swprintf(temporary, temporary_capacity, + L"%ls.tmp.%08lX.%08lX", wide_path, + (unsigned long)GetCurrentProcessId(), + (unsigned long)sequence); + if (written <= 0 || (size_t)written >= temporary_capacity) { + break; + } + file = CreateFileW(temporary, GENERIC_WRITE, 0, NULL, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | + FILE_FLAG_WRITE_THROUGH, + NULL); + if (file != INVALID_HANDLE_VALUE || + GetLastError() != ERROR_FILE_EXISTS) { + break; + } + } + } + bool ok = file != INVALID_HANDLE_VALUE; + size_t offset = 0; + while (ok && offset < json_length) { + size_t remaining = json_length - offset; + DWORD chunk = remaining > UINT32_MAX ? UINT32_MAX : (DWORD)remaining; + DWORD written = 0; + ok = WriteFile(file, json + offset, chunk, &written, NULL) != 0 && + written > 0; + offset += written; + } + if (ok) { + ok = FlushFileBuffers(file) != 0; + } + if (file != INVALID_HANDLE_VALUE && !CloseHandle(file)) { + ok = false; + } + if (ok) { + ok = MoveFileExW(temporary, wide_path, + MOVEFILE_REPLACE_EXISTING | + MOVEFILE_WRITE_THROUGH) != 0; + } + if (!ok && temporary) { + (void)DeleteFileW(temporary); + } + free(temporary); + free(wide_path); + return ok; +} +#else +static bool config_write_all(int descriptor, const char *json, + size_t json_length) { + size_t offset = 0; + while (offset < json_length) { + ssize_t written = write(descriptor, json + offset, + json_length - offset); + if (written < 0 && errno == EINTR) { + continue; + } + if (written <= 0) { + return false; + } + offset += (size_t)written; + } + return true; +} + +static bool config_sync_descriptor(int descriptor) { + int result; + do { + result = fsync(descriptor); + } while (result != 0 && errno == EINTR); + return result == 0; +} + +static bool config_sync_parent_directory(const char *path) { + char directory[CBM_SZ_1K]; + if (!config_parent_directory(path, directory, sizeof(directory))) { + return false; + } + int flags = O_RDONLY; +#ifdef O_DIRECTORY + flags |= O_DIRECTORY; +#endif + int descriptor = open(directory, flags); + if (descriptor < 0) { + return false; + } + bool synced = config_sync_descriptor(descriptor); + int sync_error = synced ? 0 : errno; + (void)close(descriptor); +#ifdef ENOTSUP + if (!synced && sync_error == ENOTSUP) { + synced = true; + } +#endif +#ifdef EOPNOTSUPP + if (!synced && sync_error == EOPNOTSUPP) { + synced = true; + } +#endif + if (!synced && sync_error == EINVAL) { + /* Some POSIX filesystems do not implement directory fsync. The temp + * file itself was already synced and rename publication is atomic. */ + synced = true; + } + return synced; +} + +static bool config_write_atomic(const char *path, const char *json, + size_t json_length) { + char temporary[CBM_SZ_2K]; + int written = snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path); + if (written <= 0 || (size_t)written >= sizeof(temporary)) { + return false; + } + int descriptor = cbm_mkstemp(temporary); + if (descriptor < 0) { + return false; + } + bool ok = fchmod(descriptor, 0600) == 0; + int descriptor_flags = fcntl(descriptor, F_GETFD); + ok = ok && descriptor_flags >= 0 && + fcntl(descriptor, F_SETFD, descriptor_flags | FD_CLOEXEC) == 0; + ok = ok && config_write_all(descriptor, json, json_length) && + config_sync_descriptor(descriptor); + if (close(descriptor) != 0) { + ok = false; + } + if (ok) { + ok = rename(temporary, path) == 0; + } + if (ok) { + ok = config_sync_parent_directory(path); + } + if (!ok) { + (void)cbm_unlink(temporary); + } + return ok; +} +#endif + +bool cbm_ui_config_save(const cbm_ui_config_t *cfg) { + if (!cfg || cfg->ui_port <= 0 || cfg->ui_port > 65535) { + cbm_log_error("ui.config.write_fail", "reason", "invalid_config"); + return false; + } char path[CBM_SZ_1K]; cbm_ui_config_path(path, (int)sizeof(path)); /* Ensure directory exists (recursive) */ char dir[CBM_SZ_1K]; - snprintf(dir, sizeof(dir), "%s", path); - char *slash = strrchr(dir, '/'); - if (slash) { - *slash = '\0'; - cbm_mkdir_p(dir, 0750); + bool directory_ready = config_parent_directory(path, dir, sizeof(dir)); + if (directory_ready && !cbm_is_dir(dir)) { + directory_ready = cbm_mkdir_p(dir, 0750) || cbm_is_dir(dir); + } + if (!directory_ready) { + cbm_log_error("ui.config.write_fail", "path", path, "reason", + "create_directory"); + return false; } yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); - yyjson_mut_val *root = yyjson_mut_obj(doc); - yyjson_mut_doc_set_root(doc, root); - - yyjson_mut_obj_add_bool(doc, root, "ui_enabled", cfg->ui_enabled); - yyjson_mut_obj_add_int(doc, root, "ui_port", cfg->ui_port); + yyjson_mut_val *root = doc ? yyjson_mut_obj(doc) : NULL; + bool serialized = doc && root; + if (serialized) { + yyjson_mut_doc_set_root(doc, root); + serialized = + yyjson_mut_obj_add_bool(doc, root, "ui_enabled", + cfg->ui_enabled) && + yyjson_mut_obj_add_int(doc, root, "ui_port", cfg->ui_port); + } size_t json_len = 0; - char *json = yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, &json_len); - yyjson_mut_doc_free(doc); + char *json = serialized + ? yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, &json_len) + : NULL; + if (doc) { + yyjson_mut_doc_free(doc); + } if (!json) { cbm_log_error("ui.config.write_fail", "reason", "serialize"); - return; + return false; } - FILE *f = fopen(path, "wb"); - if (!f) { - cbm_log_error("ui.config.write_fail", "path", path); - free(json); - return; - } - - fwrite(json, SKIP_ONE, json_len, f); - fclose(f); + bool saved = config_write_atomic(path, json, json_len); free(json); + if (!saved) { + cbm_log_error("ui.config.write_fail", "path", path, "reason", + "atomic_publish"); + return false; + } cbm_log_debug("ui.config.saved", "path", path); + return true; } diff --git a/src/ui/config.h b/src/ui/config.h index 1173cc45e..5e389e1de 100644 --- a/src/ui/config.h +++ b/src/ui/config.h @@ -21,8 +21,9 @@ typedef struct { /* Load config from disk. Missing/corrupt file → defaults. */ void cbm_ui_config_load(cbm_ui_config_t *cfg); -/* Save config to disk. Creates directory if needed. */ -void cbm_ui_config_save(const cbm_ui_config_t *cfg); +/* Atomically save one complete config generation. Creates the directory if + * needed and reports write/sync/replace failures. */ +bool cbm_ui_config_save(const cbm_ui_config_t *cfg); /* Get the config file path. Writes to buf (up to bufsz bytes). * Exposed for testing. */ diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 844896ce0..15c59a959 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -127,30 +127,32 @@ static void handle_ui_config(cbm_http_conn_t *c, const cbm_http_req_t *req) { /* ── Server state ─────────────────────────────────────────────── */ -struct cbm_http_server { - cbm_httpd_t *listener; - cbm_mcp_server_t *mcp; /* own MCP server instance (read-only) */ - struct cbm_watcher *watcher; /* external watcher ref (not owned) */ - atomic_int stop_flag; - int port; - bool listener_ok; -}; - -/* ── Forward declarations for process-kill PID validation ──────── */ - #define MAX_INDEX_JOBS 4 typedef struct { + cbm_http_server_t *server; char root_path[1024]; char project_name[256]; atomic_int status; /* 0=idle, 1=running, 2=done, 3=error */ char error_msg[256]; -#ifndef _WIN32 - pid_t child_pid; /* tracked for process-kill validation */ -#endif + cbm_thread_t thread; + bool thread_started; } index_job_t; -static index_job_t g_index_jobs[MAX_INDEX_JOBS]; +struct cbm_http_server { + cbm_httpd_t *listener; + cbm_mcp_server_t *mcp; /* own MCP server instance (read-only) */ + struct cbm_watcher *watcher; /* external watcher ref (not owned) */ + cbm_http_index_executor_fn index_executor; + void *index_executor_context; + cbm_http_project_mutation_begin_fn mutation_begin; + cbm_http_project_mutation_end_fn mutation_end; + void *mutation_context; + index_job_t index_jobs[MAX_INDEX_JOBS]; + atomic_int stop_flag; + int port; + bool listener_ok; +}; /* ── Serve embedded asset ─────────────────────────────────────── */ @@ -309,7 +311,7 @@ static void handle_repo_info(cbm_http_conn_t *c, const cbm_http_req_t *req) { cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}"); return; } - cbm_store_t *store = cbm_store_open_path(db_path); + cbm_store_t *store = cbm_store_open_path_query(db_path); if (!store) { cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}"); return; @@ -576,76 +578,6 @@ static void handle_processes(cbm_http_conn_t *c) { cbm_http_replyf(c, 200, g_cors_json, "%s", buf); } -/* POST /api/process-kill — kill a process by PID */ -static void handle_process_kill(cbm_http_conn_t *c, const cbm_http_req_t *req) { - if (req->body_len == 0 || req->body_len > 256) { - cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}"); - return; - } - - yyjson_doc *doc = yyjson_read(req->body, req->body_len, 0); - if (!doc) { - cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid json\"}"); - return; - } - yyjson_val *root = yyjson_doc_get_root(doc); - yyjson_val *v_pid = yyjson_obj_get(root, "pid"); - if (!v_pid || !yyjson_is_int(v_pid)) { - yyjson_doc_free(doc); - cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"missing pid\"}"); - return; - } - int target_pid = (int)yyjson_get_int(v_pid); - yyjson_doc_free(doc); - -#ifdef _WIN32 - if (target_pid == (int)_getpid()) { -#else - if (target_pid == (int)getpid()) { -#endif - cbm_http_replyf(c, 400, g_cors_json, - "{\"error\":\"cannot kill self (use the UI server's own shutdown)\"}"); - return; - } - -#ifndef _WIN32 - /* Only allow killing PIDs that were spawned by this server (indexing jobs) */ - { - bool pid_is_ours = false; - for (int i = 0; i < MAX_INDEX_JOBS; i++) { - if (atomic_load(&g_index_jobs[i].status) == 1 && - g_index_jobs[i].child_pid == target_pid) { - pid_is_ours = true; - break; - } - } - if (!pid_is_ours) { - cbm_http_replyf(c, 403, g_cors_json, - "{\"error\":\"can only kill server-spawned processes\"}"); - return; - } - } -#endif - -#ifdef _WIN32 - HANDLE hproc = OpenProcess(PROCESS_TERMINATE, FALSE, (DWORD)target_pid); - if (!hproc || !TerminateProcess(hproc, 1)) { - if (hproc) - CloseHandle(hproc); - cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"kill failed\"}"); - return; - } - CloseHandle(hproc); -#else - if (kill(target_pid, SIGTERM) != 0) { - cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"kill failed\"}"); - return; - } -#endif - - cbm_http_replyf(c, 200, g_cors_json, "{\"killed\":%d}", target_pid); -} - /* ── Directory browser ────────────────────────────────────────── */ #include @@ -775,7 +707,7 @@ static void handle_adr_get(cbm_http_conn_t *c, const cbm_http_req_t *req) { char db_path[1024]; db_path_for_project(name, db_path, sizeof(db_path)); - cbm_store_t *store = cbm_store_open_path(db_path); + cbm_store_t *store = cbm_store_open_path_query(db_path); if (!store) { cbm_http_replyf(c, 200, g_cors_json, "{\"has_adr\":false}"); return; @@ -824,7 +756,8 @@ static void handle_adr_get(cbm_http_conn_t *c, const cbm_http_req_t *req) { } /* POST /api/adr — save ADR content. Body: {"project":"...","content":"..."} */ -static void handle_adr_save(cbm_http_conn_t *c, const cbm_http_req_t *req) { +static void handle_adr_save(cbm_http_server_t *srv, cbm_http_conn_t *c, + const cbm_http_req_t *req) { if (req->body_len == 0 || req->body_len > 16384) { cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}"); return; @@ -848,18 +781,36 @@ static void handle_adr_save(cbm_http_conn_t *c, const cbm_http_req_t *req) { const char *proj = yyjson_get_str(v_proj); const char *content = yyjson_get_str(v_content); + if (srv->mutation_begin && + !srv->mutation_begin(srv->mutation_context, proj)) { + yyjson_doc_free(doc); + cbm_http_replyf(c, 423, g_cors_json, + "{\"error\":\"project is busy; retry after indexing\"}"); + return; + } + bool mutation_held = srv->mutation_begin != NULL; + char db_path[1024]; db_path_for_project(proj, db_path, sizeof(db_path)); cbm_store_t *store = cbm_store_open_path(db_path); - yyjson_doc_free(doc); if (!store) { + if (mutation_held) { + srv->mutation_end(srv->mutation_context, proj); + } + yyjson_doc_free(doc); cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}"); return; } int rc = cbm_store_adr_store(store, proj, content); cbm_store_close(store); + if (mutation_held) { + srv->mutation_end(srv->mutation_context, proj); + } + /* proj/content are owned by doc; keep it alive through both SQLite and + * the mutation-end callback. */ + yyjson_doc_free(doc); if (rc == CBM_STORE_OK) { cbm_http_replyf(c, 200, g_cors_json, "{\"saved\":true}"); @@ -976,182 +927,37 @@ void cbm_http_server_set_binary_path(const char *path) { } } -/* Index via subprocess — isolates crashes from the main process. */ +/* Execute through the daemon's shared job registry. The thread is retained in + * its slot and joined before reuse/free; no detached operation can outlive the + * daemon application that owns its callback context. */ static void *index_thread_fn(void *arg) { index_job_t *job = arg; cbm_log_info("ui.index.start", "path", job->root_path); - - /* Use stored binary path, or try to find it */ - const char *bin = g_binary_path; - char self_path[1024] = {0}; - if (!bin[0]) { - cbm_http_server_resolve_binary_path(NULL, self_path, sizeof(self_path)); - bin = self_path[0] ? self_path : "codebase-memory-mcp"; - } - - char log_file[256]; - - /* JSON-escape root_path and optional project name. */ - char escaped_path[2048]; - cbm_json_escape(escaped_path, (int)sizeof(escaped_path), job->root_path); - char escaped_name[512]; - cbm_json_escape(escaped_name, (int)sizeof(escaped_name), job->project_name); - char json_arg[4096]; - if (job->project_name[0]) { - snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\",\"name\":\"%s\"}", escaped_path, - escaped_name); - } else { - snprintf(json_arg, sizeof(json_arg), "{\"repo_path\":\"%s\"}", escaped_path); - } - -#ifdef _WIN32 - snprintf(log_file, sizeof(log_file), "%s\\cbm_index_%d.log", - getenv("TEMP") ? getenv("TEMP") : ".", (int)_getpid()); - - /* Build command line for CreateProcess through the shared MS-CRT quoter so the - * JSON arg's embedded quotes survive the child's argv re-parse — a naive - * `"%s"` wrap dropped them, corrupting {"repo_path":"…"} into {repo_path:…}. - * --index-worker: this http_server spawn is already the crash-isolation layer, - * so the child runs indexing in-process rather than spawning its own supervisor - * (avoids redundant process nesting). */ - char cmdline[2048]; - const char *const idx_argv[] = {bin, "cli", "--index-worker", "index_repository", - json_arg, NULL}; - if (!cbm_build_win_cmdline(cmdline, sizeof(cmdline), idx_argv)) { - snprintf(job->error_msg, sizeof(job->error_msg), "index command line too long"); - atomic_store(&job->status, 3); - return NULL; - } - /* Wide command line: CreateProcessA would re-mangle the UTF-8 repo path through the - * ANSI code page at the spawn boundary, so a non-ASCII repo path never reaches the - * worker intact (#423/#20). Convert and spawn via CreateProcessW. */ - wchar_t *wcmd = cbm_utf8_to_wide(cmdline); - if (!wcmd) { - snprintf(job->error_msg, sizeof(job->error_msg), "index command line conversion failed"); - atomic_store(&job->status, 3); - return NULL; - } - - cbm_log_info("ui.index.spawn", "bin", bin, "log", log_file); - - HANDLE hlog = CreateFileA(log_file, GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL, NULL); - STARTUPINFOW si_proc = {.cb = sizeof(si_proc)}; - if (hlog != INVALID_HANDLE_VALUE) { - si_proc.dwFlags = STARTF_USESTDHANDLES; - si_proc.hStdError = hlog; - si_proc.hStdOutput = hlog; - } - PROCESS_INFORMATION pi = {0}; - BOOL spawned = CreateProcessW(NULL, wcmd, NULL, NULL, TRUE, 0, NULL, NULL, &si_proc, &pi); - free(wcmd); - if (!spawned) { - snprintf(job->error_msg, sizeof(job->error_msg), "CreateProcess failed"); - atomic_store(&job->status, 3); - if (hlog != INVALID_HANDLE_VALUE) - CloseHandle(hlog); - return NULL; - } - if (hlog != INVALID_HANDLE_VALUE) - CloseHandle(hlog); - - /* Poll log file while child runs */ - long tail_pos = 0; - for (;;) { - DWORD wait = WaitForSingleObject(pi.hProcess, 500); - FILE *lf = fopen(log_file, "r"); - if (lf) { - fseek(lf, tail_pos, SEEK_SET); - char line[512]; - while (fgets(line, sizeof(line), lf)) { - size_t l = strlen(line); - if (l > 0 && line[l - 1] == '\n') - line[l - 1] = '\0'; - if (line[0]) - cbm_ui_log_append(line); - } - tail_pos = ftell(lf); - fclose(lf); - } - if (wait == WAIT_OBJECT_0) - break; - } - - DWORD win_exit = 1; - GetExitCodeProcess(pi.hProcess, &win_exit); - int exit_code = (int)win_exit; - CloseHandle(pi.hProcess); - CloseHandle(pi.hThread); - (void)DeleteFileA(log_file); -#else - snprintf(log_file, sizeof(log_file), "/tmp/cbm_index_%d.log", (int)getpid()); - - cbm_log_info("ui.index.fork", "bin", bin, "log", log_file); - - pid_t child_pid = fork(); - if (child_pid < 0) { - snprintf(job->error_msg, sizeof(job->error_msg), "fork failed"); - atomic_store(&job->status, 3); - return NULL; - } - job->child_pid = child_pid; - - if (child_pid == 0) { - FILE *lf = freopen(log_file, "w", stderr); - (void)lf; - freopen("/dev/null", "w", stdout); - execl(bin, bin, "cli", "--index-worker", "index_repository", json_arg, (char *)NULL); - _exit(127); - } - - long tail_pos = 0; - for (;;) { - int wstatus = 0; - pid_t wr = waitpid(child_pid, &wstatus, WNOHANG); - bool child_done = (wr == child_pid); - - FILE *lf = fopen(log_file, "r"); - if (lf) { - fseek(lf, tail_pos, SEEK_SET); - char line[512]; - while (fgets(line, sizeof(line), lf)) { - size_t l = strlen(line); - if (l > 0 && line[l - 1] == '\n') - line[l - 1] = '\0'; - if (line[0]) - cbm_ui_log_append(line); - } - tail_pos = ftell(lf); - fclose(lf); - } - - if (child_done) - break; - - struct timespec ts = {0, 500000000}; - cbm_nanosleep(&ts, NULL); - } - - int wstatus = 0; - waitpid(child_pid, &wstatus, 0); - int exit_code = WIFEXITED(wstatus) ? WEXITSTATUS(wstatus) : -1; - - (void)unlink(log_file); -#endif - - if (exit_code != 0) { - snprintf(job->error_msg, sizeof(job->error_msg), "indexing failed (exit code %d)", - exit_code); + cbm_http_server_t *server = job->server; + int result = server && server->index_executor + ? server->index_executor(server->index_executor_context, + job->root_path, job->project_name) + : -1; + if (result != 0) { + snprintf(job->error_msg, sizeof(job->error_msg), + "daemon index operation failed"); atomic_store(&job->status, 3); } else { atomic_store(&job->status, 2); } - cbm_log_info("ui.index.done", "path", job->root_path, "rc", exit_code == 0 ? "ok" : "err"); + cbm_log_info("ui.index.done", "path", job->root_path, "rc", + result == 0 ? "ok" : "err"); return NULL; } /* POST /api/index — body: {"root_path": "/abs/path", "project_name": "..."} */ -static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) { +static void handle_index_start(cbm_http_server_t *server, cbm_http_conn_t *c, + const cbm_http_req_t *req) { + if (!server || !server->index_executor) { + cbm_http_replyf(c, 503, g_cors_json, + "{\"error\":\"daemon index coordinator unavailable\"}"); + return; + } if (req->body_len == 0 || req->body_len > 4096) { cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}"); return; @@ -1183,7 +989,7 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) { /* Find free job slot */ int slot = -1; for (int i = 0; i < MAX_INDEX_JOBS; i++) { - int st = atomic_load(&g_index_jobs[i].status); + int st = atomic_load(&server->index_jobs[i].status); if (st == 0 || st == 2 || st == 3) { slot = i; break; @@ -1195,7 +1001,12 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) { return; } - index_job_t *job = &g_index_jobs[slot]; + index_job_t *job = &server->index_jobs[slot]; + if (job->thread_started) { + (void)cbm_thread_join(&job->thread); + job->thread_started = false; + } + job->server = server; snprintf(job->root_path, sizeof(job->root_path), "%s", rpath); snprintf(job->project_name, sizeof(job->project_name), "%s", project_name); job->error_msg[0] = '\0'; @@ -1203,25 +1014,24 @@ static void handle_index_start(cbm_http_conn_t *c, const cbm_http_req_t *req) { yyjson_doc_free(doc); /* Spawn background thread */ - cbm_thread_t tid; - if (cbm_thread_create(&tid, 0, index_thread_fn, job) != 0) { + if (cbm_thread_create(&job->thread, 0, index_thread_fn, job) != 0) { atomic_store(&job->status, 3); snprintf(job->error_msg, sizeof(job->error_msg), "thread creation failed"); cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"thread creation failed\"}"); return; } - cbm_thread_detach(&tid); /* Don't leak thread handle */ + job->thread_started = true; cbm_http_replyf(c, 202, g_cors_json, "{\"status\":\"indexing\",\"slot\":%d,\"path\":\"%s\"}", slot, job->root_path); } /* GET /api/index-status — returns status of all index jobs */ -static void handle_index_status(cbm_http_conn_t *c) { +static void handle_index_status(cbm_http_server_t *server, cbm_http_conn_t *c) { char buf[2048] = "["; int pos = 1; for (int i = 0; i < MAX_INDEX_JOBS; i++) { - int st = atomic_load(&g_index_jobs[i].status); + int st = atomic_load(&server->index_jobs[i].status); if (st == 0) continue; if (pos > 1) @@ -1229,7 +1039,8 @@ static void handle_index_status(cbm_http_conn_t *c) { const char *ss = st == 1 ? "indexing" : st == 2 ? "done" : "error"; http_appendf(buf, sizeof(buf), &pos, "{\"slot\":%d,\"status\":\"%s\",\"path\":\"%s\",\"error\":\"%s\"}", i, ss, - g_index_jobs[i].root_path, st == 3 ? g_index_jobs[i].error_msg : ""); + server->index_jobs[i].root_path, + st == 3 ? server->index_jobs[i].error_msg : ""); } buf[pos++] = ']'; buf[pos] = '\0'; @@ -1258,12 +1069,26 @@ static void handle_delete_project(cbm_http_server_t *srv, cbm_http_conn_t *c, return; } + if (srv->mutation_begin && + !srv->mutation_begin(srv->mutation_context, name)) { + cbm_http_replyf(c, 423, g_cors_json, + "{\"error\":\"project is busy; retry after indexing\"}"); + return; + } + bool mutation_held = srv->mutation_begin != NULL; + if (unlink(db_path) != 0) { if (errno == ENOENT) { unwatch_project(srv, name); + if (mutation_held) { + srv->mutation_end(srv->mutation_context, name); + } cbm_http_replyf(c, 404, g_cors_json, "{\"error\":\"project not found\"}"); return; } + if (mutation_held) { + srv->mutation_end(srv->mutation_context, name); + } cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"failed to delete\"}"); return; } @@ -1277,6 +1102,9 @@ static void handle_delete_project(cbm_http_server_t *srv, cbm_http_conn_t *c, unwatch_project(srv, name); cbm_log_info("ui.project.deleted", "name", name); + if (mutation_held) { + srv->mutation_end(srv->mutation_context, name); + } cbm_http_replyf(c, 200, g_cors_json, "{\"deleted\":true}"); } @@ -1296,7 +1124,7 @@ static void handle_project_health(cbm_http_conn_t *c, const cbm_http_req_t *req) return; } - cbm_store_t *store = cbm_store_open_path(db_path); + cbm_store_t *store = cbm_store_open_path_query(db_path); if (!store) { cbm_http_replyf(c, 200, g_cors_json, "{\"status\":\"corrupt\",\"reason\":\"cannot open\"}"); return; @@ -1470,7 +1298,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { return; } - cbm_store_t *store = cbm_store_open_path(db_path); + cbm_store_t *store = cbm_store_open_path_query(db_path); if (!store) { cbm_http_replyf(c, 500, g_cors_json, "{\"error\":\"cannot open store\"}"); return; @@ -1539,7 +1367,7 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) { continue; } - cbm_store_t *lp_store = cbm_store_open_path(lp_path); + cbm_store_t *lp_store = cbm_store_open_path_query(lp_path); if (!lp_store) { free(linked[li]); continue; @@ -1761,13 +1589,13 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, /* POST /api/index → start background indexing */ if (is_post && cbm_http_path_match(req->path, "/api/index")) { - handle_index_start(c, req); + handle_index_start(srv, c, req); return; } /* GET /api/index-status → check indexing progress */ if (is_get && cbm_http_path_match(req->path, "/api/index-status")) { - handle_index_status(c); + handle_index_status(srv, c); return; } @@ -1797,7 +1625,7 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, /* POST /api/adr → save ADR for project */ if (is_post && cbm_http_path_match(req->path, "/api/adr")) { - handle_adr_save(c, req); + handle_adr_save(srv, c, req); return; } @@ -1819,12 +1647,6 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, return; } - /* POST /api/process-kill → kill a process */ - if (is_post && cbm_http_path_match(req->path, "/api/process-kill")) { - handle_process_kill(c, req); - return; - } - /* GET / → index.html (no-cache so browser always gets latest) */ if (cbm_http_path_match(req->path, "/")) { const cbm_embedded_file_t *f = cbm_embedded_lookup("/index.html"); @@ -1848,6 +1670,15 @@ static void dispatch_request(cbm_http_server_t *srv, cbm_http_conn_t *c, /* ── Public API ───────────────────────────────────────────────── */ +static char *http_read_only_index_rejected(void *context, const char *repo_path, + const char *args_json) { + (void)context; + (void)repo_path; + (void)args_json; + return cbm_mcp_text_result( + "UI RPC indexing is disabled; use the coordinated /api/index route", true); +} + cbm_http_server_t *cbm_http_server_new(int port) { cbm_http_server_t *srv = calloc(1, sizeof(*srv)); if (!srv) @@ -1863,6 +1694,9 @@ cbm_http_server_t *cbm_http_server_new(int port) { free(srv); return NULL; } + cbm_mcp_server_set_background_tasks(srv->mcp, false); + cbm_mcp_server_set_index_executor(srv->mcp, + http_read_only_index_rejected, srv); /* Bind to localhost only (httpd refuses anything else by construction) */ srv->listener = cbm_httpd_listen(port); @@ -1891,6 +1725,12 @@ cbm_http_server_t *cbm_http_server_new(int port) { void cbm_http_server_free(cbm_http_server_t *srv) { if (!srv) return; + for (int i = 0; i < MAX_INDEX_JOBS; i++) { + if (srv->index_jobs[i].thread_started) { + (void)cbm_thread_join(&srv->index_jobs[i].thread); + srv->index_jobs[i].thread_started = false; + } + } cbm_httpd_close(srv->listener); cbm_mcp_server_free(srv->mcp); free(srv); @@ -1951,3 +1791,25 @@ void cbm_http_server_set_watcher(cbm_http_server_t *srv, struct cbm_watcher *wat srv->watcher = watcher; } } + +void cbm_http_server_set_index_executor(cbm_http_server_t *srv, + cbm_http_index_executor_fn executor, + void *context) { + if (srv) { + srv->index_executor = executor; + srv->index_executor_context = context; + } +} + +void cbm_http_server_set_project_mutation_guard( + cbm_http_server_t *srv, cbm_http_project_mutation_begin_fn begin, + cbm_http_project_mutation_end_fn end, void *context) { + if (!srv || ((begin == NULL) != (end == NULL))) { + return; + } + srv->mutation_begin = begin; + srv->mutation_end = end; + srv->mutation_context = begin ? context : NULL; + cbm_mcp_server_set_project_mutation_guard(srv->mcp, begin, end, + begin ? context : NULL); +} diff --git a/src/ui/http_server.h b/src/ui/http_server.h index 4aa98a615..6e2a2de26 100644 --- a/src/ui/http_server.h +++ b/src/ui/http_server.h @@ -16,6 +16,13 @@ typedef struct cbm_http_server cbm_http_server_t; struct cbm_watcher; +/* Daemon-owned index boundary. The callback blocks until the coordinated + * operation finishes; the HTTP server runs it on a tracked, joinable thread. */ +typedef int (*cbm_http_index_executor_fn)(void *context, const char *root_path, + const char *project_name); +typedef bool (*cbm_http_project_mutation_begin_fn)(void *context, const char *project); +typedef void (*cbm_http_project_mutation_end_fn)(void *context, const char *project); + /* Create an HTTP server on the given port. * Creates its own cbm_mcp_server_t with a separate read-only SQLite connection. * Returns NULL on failure (e.g. port in use). */ @@ -43,6 +50,18 @@ void cbm_http_server_set_recv_deadline_ms(cbm_http_server_t *srv, int ms); /* Set external watcher reference for UI project lifecycle actions. Not owned. */ void cbm_http_server_set_watcher(cbm_http_server_t *srv, struct cbm_watcher *watcher); +/* Route UI indexing through the daemon's shared operation registry. */ +void cbm_http_server_set_index_executor(cbm_http_server_t *srv, + cbm_http_index_executor_fn executor, + void *context); + +/* Route direct UI mutations and /rpc mutation tools through the daemon's + * per-project coordination gate. Direct UI calls are non-blocking: begin=false + * is returned to the browser as a retryable locked response. */ +void cbm_http_server_set_project_mutation_guard( + cbm_http_server_t *srv, cbm_http_project_mutation_begin_fn begin, + cbm_http_project_mutation_end_fn end, void *context); + /* Initialize the log ring buffer mutex. Must be called once before any threads. */ void cbm_ui_log_init(void); diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 5459503a7..11e2cf5a8 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -44,6 +44,10 @@ typedef struct { char *project_name; char *root_path; + /* poll_once snapshots retain state pointers after projects_lock is + * released. Unwatch/replacement tombstones a removed state so a later + * snapshot entry cannot admit new indexing work. */ + atomic_bool registered; char last_head[CBM_SZ_64]; /* git HEAD hash (committed baseline) */ bool is_git; /* false → skip polling */ bool baseline_done; /* true after first poll */ @@ -70,6 +74,13 @@ struct cbm_watcher { void *user_data; CBMHashTable *projects; /* name → project_state_t* */ cbm_mutex_t projects_lock; + /* Serializes callback replacement with the entire destructive prune + * transaction so a borrowed daemon context cannot be freed mid-callback. */ + cbm_mutex_t coordination_lock; + cbm_watcher_project_mutation_begin_fn mutation_begin; + cbm_watcher_project_mutation_end_fn mutation_end; + cbm_watcher_project_pruned_fn project_pruned; + void *mutation_context; atomic_int stopped; /* Deferred-free list: freed after the next poll_once. */ project_state_t **pending_free; @@ -331,6 +342,8 @@ static int git_file_count(const char *root_path) { /* ── Project state lifecycle ────────────────────────────────────── */ +static void state_free(project_state_t *s); + static project_state_t *state_new(const char *name, const char *root_path) { project_state_t *s = calloc(CBM_ALLOC_ONE, sizeof(*s)); if (!s) { @@ -338,6 +351,11 @@ static project_state_t *state_new(const char *name, const char *root_path) { } s->project_name = strdup(name); s->root_path = strdup(root_path); + if (!s->project_name || !s->root_path) { + state_free(s); + return NULL; + } + atomic_init(&s->registered, true); s->interval_ms = POLL_BASE_MS; return s; } @@ -432,25 +450,63 @@ static const char *itoa_buf(int v) { return buf; } -static void delete_cached_project_db(const char *project_name) { +static bool watcher_unlink_cached_file(const char *project_name, const char *path, + const char *artifact) { + if (cbm_unlink(path) == 0 || errno == ENOENT) { + return true; + } + int unlink_errno = errno; + char errno_text[CBM_SZ_32]; + snprintf(errno_text, sizeof(errno_text), "%d", unlink_errno); + cbm_log_warn("watcher.root_prune_delete_failed", "project", project_name, + "artifact", artifact, "path", path, "errno", errno_text); + return false; +} + +static bool delete_cached_project_db(const char *project_name) { if (!cbm_validate_project_name(project_name)) { - return; + return false; } const char *cache_dir = cbm_resolve_cache_dir(); if (!cache_dir) { - return; + return false; } - char path[CBM_SZ_1K]; - char wal[CBM_SZ_1K]; - char shm[CBM_SZ_1K]; - snprintf(path, sizeof(path), "%s/%s.db", cache_dir, project_name); - snprintf(wal, sizeof(wal), "%s-wal", path); - snprintf(shm, sizeof(shm), "%s-shm", path); - (void)cbm_unlink(path); - (void)cbm_unlink(wal); - (void)cbm_unlink(shm); + size_t cache_length = strlen(cache_dir); + size_t project_length = strlen(project_name); + if (cache_length > SIZE_MAX - project_length - sizeof("/.db")) { + return false; + } + size_t path_capacity = cache_length + project_length + sizeof("/.db"); + char *path = malloc(path_capacity); + char *sidecar = malloc(path_capacity + sizeof("-wal")); + if (!path || !sidecar) { + free(path); + free(sidecar); + return false; + } + int written = snprintf(path, path_capacity, "%s/%s.db", cache_dir, project_name); + bool formatted = written > 0 && (size_t)written < path_capacity; + bool removed = formatted && + watcher_unlink_cached_file(project_name, path, "database"); + /* If the main DB could not be removed, preserve WAL/SHM: together they + * may be the only recoverable committed generation. */ + if (removed) { + written = snprintf(sidecar, path_capacity + sizeof("-wal"), "%s-wal", path); + removed = written > 0 && + (size_t)written < path_capacity + sizeof("-wal") && + watcher_unlink_cached_file(project_name, sidecar, "wal"); + } + if (removed) { + written = snprintf(sidecar, path_capacity + sizeof("-wal"), "%s-shm", path); + removed = written > 0 && + (size_t)written < path_capacity + sizeof("-wal") && + watcher_unlink_cached_file(project_name, sidecar, "shm"); + } + free(path); + free(sidecar); + return removed; } /* Hash table foreach callback to free state entries */ @@ -476,6 +532,7 @@ cbm_watcher_t *cbm_watcher_new(cbm_store_t *store, cbm_index_fn index_fn, void * return NULL; } cbm_mutex_init(&w->projects_lock); + cbm_mutex_init(&w->coordination_lock); atomic_init(&w->stopped, 0); return w; } @@ -487,6 +544,7 @@ void cbm_watcher_free(cbm_watcher_t *w) { /* Safety net: ensure stopped is set before draining pending_free. * In production the caller should cbm_watcher_stop() + join first. */ atomic_store(&w->stopped, 1); + cbm_mutex_lock(&w->coordination_lock); cbm_mutex_lock(&w->projects_lock); cbm_ht_foreach(w->projects, free_state_entry, NULL); cbm_ht_free(w->projects); @@ -495,10 +553,28 @@ void cbm_watcher_free(cbm_watcher_t *w) { } free(w->pending_free); cbm_mutex_unlock(&w->projects_lock); + cbm_mutex_unlock(&w->coordination_lock); cbm_mutex_destroy(&w->projects_lock); + cbm_mutex_destroy(&w->coordination_lock); free(w); } +void cbm_watcher_set_project_mutation_guard( + cbm_watcher_t *w, cbm_watcher_project_mutation_begin_fn begin, + cbm_watcher_project_mutation_end_fn end, cbm_watcher_project_pruned_fn pruned, + void *context) { + if (!w) { + return; + } + cbm_mutex_lock(&w->coordination_lock); + bool valid = begin && end; + w->mutation_begin = valid ? begin : NULL; + w->mutation_end = valid ? end : NULL; + w->project_pruned = valid ? pruned : NULL; + w->mutation_context = valid ? context : NULL; + cbm_mutex_unlock(&w->coordination_lock); +} + /* ── Watch list management ──────────────────────────────────────── */ void cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *root_path) { @@ -513,20 +589,34 @@ void cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *r return; } - /* Remove old entry first (key points to state's project_name) */ - cbm_mutex_lock(&w->projects_lock); - project_state_t *old = cbm_ht_get(w->projects, project_name); - if (old) { - cbm_ht_delete(w->projects, project_name); - state_free(old); - } - project_state_t *s = state_new(project_name, root_path); if (!s) { - cbm_mutex_unlock(&w->projects_lock); cbm_log_warn("watcher.watch.oom", "project", project_name, "path", root_path); return; } + + cbm_mutex_lock(&w->projects_lock); + project_state_t *old = cbm_ht_get(w->projects, project_name); + if (old && strcmp(old->root_path, s->root_path) == 0) { + /* Multiple daemon sessions may subscribe to the same canonical + * project/root. Re-registering that identical watch must not discard + * its git baseline, pending dirty signature, or immediate-touch state. */ + cbm_mutex_unlock(&w->projects_lock); + state_free(s); + return; + } + if (old) { + /* A poll snapshot may still be using the old state, including while + * its index callback calls back into this function. Queue it before + * removing the table entry; on OOM, preserve the existing watch. */ + if (!defer_state_free(w, old)) { + cbm_mutex_unlock(&w->projects_lock); + state_free(s); + return; + } + atomic_store_explicit(&old->registered, false, memory_order_release); + cbm_ht_delete(w->projects, project_name); + } cbm_ht_set(w->projects, s->project_name, s); cbm_mutex_unlock(&w->projects_lock); cbm_log_info("watcher.watch", "project", project_name, "path", root_path); @@ -542,6 +632,7 @@ void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name) { if (s && defer_state_free(w, s)) { /* The entry leaves the table only once its state is safely on * the deferred-free list; on OOM the watch stays registered. */ + atomic_store_explicit(&s->registered, false, memory_order_release); cbm_ht_delete(w->projects, project_name); removed = true; } @@ -656,32 +747,72 @@ static void prune_missing_project(cbm_watcher_t *w, project_state_t *s) { return; } - char project_name[CBM_SZ_1K]; - snprintf(project_name, sizeof(project_name), "%s", s->project_name); + char *project_name = cbm_strdup(s->project_name); + char *root_path = cbm_strdup(s->root_path); + if (!project_name || !root_path) { + free(project_name); + free(root_path); + return; + } bool removed = false; + cbm_mutex_lock(&w->coordination_lock); + bool mutation_acquired = !w->mutation_begin || + w->mutation_begin(w->mutation_context, project_name); + if (!mutation_acquired) { + cbm_mutex_unlock(&w->coordination_lock); + free(project_name); + free(root_path); + return; + } cbm_mutex_lock(&w->projects_lock); project_state_t *current = cbm_ht_get(w->projects, project_name); /* Deferred free (same discipline as cbm_watcher_unwatch): this state * is referenced by the poll_once snapshot iterating us. On OOM the - * watch stays registered and pruning retries on the next cycle. */ - if (current == s && defer_state_free(w, s)) { - delete_cached_project_db(project_name); - cbm_ht_delete(w->projects, project_name); - removed = true; + * watch stays registered and pruning retries on the next cycle. Re-stat + * under the mutation lease so a root restored after the poll snapshot is + * never pruned. */ + int stat_errno = 0; + uint64_t now_ms = cbm_now_ms(); + bool still_eligible = current == s && strcmp(current->root_path, root_path) == 0 && + current->missing_root_count >= MISSING_ROOT_DELETE_AFTER && + current->first_missing_ms > 0 && + now_ms - current->first_missing_ms >= + (uint64_t)prune_grace_s() * CBM_MSEC_PER_SEC && + root_status(root_path, &stat_errno) == ROOT_MISSING; + if (still_eligible && defer_state_free(w, s)) { + if (delete_cached_project_db(project_name)) { + atomic_store_explicit(&s->registered, false, memory_order_release); + cbm_ht_delete(w->projects, project_name); + removed = true; + } else { + /* The state stays registered; undo the just-added deferred-free + * entry so the next poll can safely retry the failed deletion. */ + w->pending_free[--w->pending_free_count] = NULL; + } } cbm_mutex_unlock(&w->projects_lock); + if (removed && w->project_pruned) { + w->project_pruned(w->mutation_context, project_name); + } + if (w->mutation_begin) { + w->mutation_end(w->mutation_context, project_name); + } + cbm_mutex_unlock(&w->coordination_lock); + if (removed) { cbm_log_info("watcher.root_pruned", "project", project_name); } + free(project_name); + free(root_path); } static void poll_project(const char *key, void *val, void *ud) { (void)key; poll_ctx_t *ctx = ud; project_state_t *s = val; - if (!s) { + if (!s || !atomic_load_explicit(&s->registered, memory_order_acquire)) { return; } @@ -747,6 +878,12 @@ static void poll_project(const char *key, void *val, void *ud) { } /* Trigger reindex */ + if (!atomic_load_explicit(&s->registered, memory_order_acquire)) { + /* Git inspection can take long enough for the final owning daemon + * session to disconnect. Unwatch tombstones the snapshot state; do + * not admit an index callback after that ownership boundary. */ + return; + } cbm_log_info("watcher.changed", "project", s->project_name, "strategy", "git"); if (ctx->w->index_fn) { int rc = ctx->w->index_fn(s->project_name, s->root_path, ctx->w->user_data); diff --git a/src/watcher/watcher.h b/src/watcher/watcher.h index 096d4e4a2..9d59411f9 100644 --- a/src/watcher/watcher.h +++ b/src/watcher/watcher.h @@ -31,6 +31,15 @@ typedef struct cbm_watcher cbm_watcher_t; * root_path: absolute path to the repository root */ typedef int (*cbm_index_fn)(const char *project_name, const char *root_path, void *user_data); +/* Optional daemon coordination for destructive stale-root pruning. begin is + * non-blocking: a false result preserves the watch and retries on a later + * poll. A successful begin is paired with end. pruned is called after the + * physical watch and cached DB have been removed so the daemon can invalidate + * its logical subscriptions. All callbacks use the same borrowed context. */ +typedef bool (*cbm_watcher_project_mutation_begin_fn)(void *context, const char *project); +typedef void (*cbm_watcher_project_mutation_end_fn)(void *context, const char *project); +typedef void (*cbm_watcher_project_pruned_fn)(void *context, const char *project); + /* ── Lifecycle ──────────────────────────────────────────────────── */ /* Create a new watcher. store is used for project metadata lookups. @@ -42,12 +51,21 @@ cbm_watcher_t *cbm_watcher_new(cbm_store_t *store, cbm_index_fn index_fn, void * * Precondition: cbm_watcher_stop() + thread join must have completed. */ void cbm_watcher_free(cbm_watcher_t *w); +/* Install or clear daemon-owned stale-root coordination. Passing NULL for + * begin/end clears all callbacks. The setter waits for an in-flight prune + * callback to finish before returning. */ +void cbm_watcher_set_project_mutation_guard( + cbm_watcher_t *w, cbm_watcher_project_mutation_begin_fn begin, + cbm_watcher_project_mutation_end_fn end, cbm_watcher_project_pruned_fn pruned, + void *context); + /* ── Watch list management ──────────────────────────────────────── */ /* Add a project to the watch list. root_path is copied. */ void cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *root_path); -/* Remove a project from the watch list. */ +/* Remove a project from the watch list. Any not-yet-admitted callback in the + * current poll snapshot is invalidated before this function returns. */ void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name); /* Refresh a project's timestamp (resets adaptive backoff). */ diff --git a/tests/repro/repro_issue557.c b/tests/repro/repro_issue557.c index 9093d45ac..a312aeadf 100644 --- a/tests/repro/repro_issue557.c +++ b/tests/repro/repro_issue557.c @@ -74,6 +74,7 @@ */ #include +#include #include "test_framework.h" #include @@ -96,6 +97,97 @@ static int file_exists(const char *path) { return (stat(path, &st) == 0) ? 1 : 0; } +/* Unique quarantine generations use: + * + * .db.corrupt.<16-hex-token> + * + * Only the main DB generation proves recoverability. SQLite sidecars and + * reservation artifacts (for example, -wal, -shm, or .pending) must not make + * the primary survival assertion pass on their own. */ +static int is_hex_digit(char ch) { + return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || + (ch >= 'A' && ch <= 'F'); +} + +static int is_unique_corrupt_main_name(const char *name) { + char prefix[CBM_DIRENT_NAME_MAX]; + snprintf(prefix, sizeof(prefix), "%s.db.corrupt.", REPRO557_PROJECT); + size_t prefix_len = strlen(prefix); + if (strncmp(name, prefix, prefix_len) != 0) { + return 0; + } + + const char *token = name + prefix_len; + if (strlen(token) != 16) { + return 0; + } + for (size_t i = 0; i < 16; i++) { + if (!is_hex_digit(token[i])) { + return 0; + } + } + return 1; +} + +static int unique_corrupt_main_exists(const char *cache_dir) { + cbm_dir_t *dir = cbm_opendir(cache_dir); + if (!dir) { + return 0; + } + + int found = 0; + cbm_dirent_t *entry; + while ((entry = cbm_readdir(dir)) != NULL) { + if (!is_unique_corrupt_main_name(entry->name)) { + continue; + } + char path[800]; + snprintf(path, sizeof(path), "%s/%s", cache_dir, entry->name); + if (file_exists(path)) { + found = 1; + break; + } + } + cbm_closedir(dir); + return found; +} + +static int is_backup_artifact_name(const char *name) { + char corrupt_prefix[CBM_DIRENT_NAME_MAX]; + char bak_prefix[CBM_DIRENT_NAME_MAX]; + snprintf(corrupt_prefix, sizeof(corrupt_prefix), "%s.db.corrupt", REPRO557_PROJECT); + snprintf(bak_prefix, sizeof(bak_prefix), "%s.db.bak", REPRO557_PROJECT); + + size_t corrupt_len = strlen(corrupt_prefix); + if (strncmp(name, corrupt_prefix, corrupt_len) == 0 && + (name[corrupt_len] == '\0' || name[corrupt_len] == '.' || + name[corrupt_len] == '-')) { + return 1; + } + + size_t bak_len = strlen(bak_prefix); + return strncmp(name, bak_prefix, bak_len) == 0 && + (name[bak_len] == '\0' || name[bak_len] == '.' || name[bak_len] == '-'); +} + +static void cleanup_backup_artifacts(const char *cache_dir) { + cbm_dir_t *dir = cbm_opendir(cache_dir); + if (!dir) { + return; + } + + cbm_dirent_t *entry; + while ((entry = cbm_readdir(dir)) != NULL) { + if (!is_backup_artifact_name(entry->name)) { + continue; + } + char path[800]; + snprintf(path, sizeof(path), "%s/%s", cache_dir, entry->name); + cbm_unlink(path); + } + cbm_closedir(dir); +} + /* ── Test ───────────────────────────────────────────────────────────── * * repro_issue557_corrupt_db_not_silently_deleted @@ -230,9 +322,10 @@ TEST(repro_issue557_corrupt_db_not_silently_deleted) { * (a) the original DB still exists at db_path (zero deletion), or * (b) a backup file exists at a conventional backup path. * - * Two conventional backup suffixes from the suggested fix in #557: - * ".corrupt" -- timestamped or plain rename - * ".bak" -- simpler alternative + * Accepted backup forms from the suggested fix in #557: + * ".corrupt.<16-hex-token>" -- unique quarantine generation + * ".corrupt" -- legacy plain rename + * ".bak" -- simpler alternative * * WHY RED on buggy code: * cbm_unlink(path) at mcp.c:803 removes the file. @@ -245,18 +338,18 @@ TEST(repro_issue557_corrupt_db_not_silently_deleted) { char backup_corrupt[720], backup_bak[720]; snprintf(backup_corrupt, sizeof(backup_corrupt), "%s.corrupt", db_path); snprintf(backup_bak, sizeof(backup_bak), "%s.bak", db_path); - int backup_exists = file_exists(backup_corrupt) || file_exists(backup_bak); + int backup_exists = unique_corrupt_main_exists(tmp_cache) || + file_exists(backup_corrupt) || file_exists(backup_bak); /* Clean up temp dir (best effort -- before the assertion so the dir * is removed even when the assertion fails and longjmp unwinds). */ unlink(db_path); - unlink(backup_corrupt); - unlink(backup_bak); char wal[730], shm[730]; snprintf(wal, sizeof(wal), "%s-wal", db_path); snprintf(shm, sizeof(shm), "%s-shm", db_path); unlink(wal); unlink(shm); + cleanup_backup_artifacts(tmp_cache); rmdir(tmp_cache); #if defined(_WIN32) @@ -269,7 +362,7 @@ TEST(repro_issue557_corrupt_db_not_silently_deleted) { * THE KEY ASSERTION -- must be RED on unpatched code: * * db_still_exists -- 1 if the DB was preserved in-place (zero-delete fix) - * backup_exists -- 1 if a .corrupt or .bak rename was made (quarantine fix) + * backup_exists -- 1 if a main .corrupt generation or .bak rename exists * * On buggy code: both are 0 because cbm_unlink() ran with no backup. * On fixed code: at least one is 1. diff --git a/tests/test_activation_transaction.c b/tests/test_activation_transaction.c new file mode 100644 index 000000000..4ccc433b1 --- /dev/null +++ b/tests/test_activation_transaction.c @@ -0,0 +1,1028 @@ +/* Transactional install/update/uninstall binary activation contract. */ +#include "test_framework.h" +#include "test_helpers.h" + +#include "cli/activation_transaction.h" +#include "foundation/compat_fs.h" +#include "foundation/platform.h" + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include +#endif + +#ifdef __APPLE__ +#include +#include +#include +#endif + +#ifndef _WIN32 +#include +#endif + +enum { ACTIVATION_TEST_PATH_CAP = 1024, ACTIVATION_TEST_CONTENT_CAP = 256 }; + +static bool activation_test_fixture(char out[ACTIVATION_TEST_PATH_CAP]) { + int written = snprintf(out, ACTIVATION_TEST_PATH_CAP, + "%s/cbm-activation-transaction-XXXXXX", cbm_tmpdir()); + return written > 0 && written < ACTIVATION_TEST_PATH_CAP && + cbm_mkdtemp(out) != NULL; +} + +static bool activation_test_path(char out[ACTIVATION_TEST_PATH_CAP], + const char *directory, const char *name) { + int written = snprintf(out, ACTIVATION_TEST_PATH_CAP, "%s/%s", directory, name); + return written > 0 && written < ACTIVATION_TEST_PATH_CAP; +} + +static bool activation_test_read(const char *path, + char out[ACTIVATION_TEST_CONTENT_CAP]) { + FILE *file = cbm_fopen(path, "rb"); + if (!file) { + return false; + } + size_t used = fread(out, 1, ACTIVATION_TEST_CONTENT_CAP - 1, file); + bool complete = !ferror(file) && feof(file); + out[used] = '\0'; + return fclose(file) == 0 && complete; +} + +static bool activation_test_write(const char *path, const char *contents) { + FILE *file = cbm_fopen(path, "wb"); + if (!file) { + return false; + } + size_t length = strlen(contents); + bool ok = fwrite(contents, 1, length, file) == length; + ok = fclose(file) == 0 && ok; +#ifndef _WIN32 + ok = chmod(path, 0700) == 0 && ok; +#endif + return ok; +} + +static bool activation_test_exists(const char *path) { + struct stat status; + return path && stat(path, &status) == 0; +} + +typedef struct { + bool expect_absent; + const char *expected_contents; +} activation_test_validation_t; + +static bool activation_test_validate(const char *target_path, void *opaque) { + const activation_test_validation_t *validation = opaque; + if (validation->expect_absent) { + return !activation_test_exists(target_path); + } + char contents[ACTIVATION_TEST_CONTENT_CAP]; + return activation_test_read(target_path, contents) && + strcmp(contents, validation->expected_contents) == 0; +} + +static bool activation_test_reject(const char *target_path, void *opaque) { + (void)target_path; + (void)opaque; + return false; +} + +#ifdef _WIN32 +#ifndef ACCESS_ALLOWED_CALLBACK_ACE_TYPE +#define ACCESS_ALLOWED_CALLBACK_ACE_TYPE (0x9) +#endif + +typedef struct { + ACE_HEADER header; + ACCESS_MASK mask; + DWORD sid_start; +} activation_test_callback_allow_ace_t; + +static wchar_t *activation_test_windows_utf8_to_wide(const char *value) { + int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, + NULL, 0); + wchar_t *wide = needed > 0 ? calloc((size_t)needed, sizeof(*wide)) : NULL; + if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, + wide, needed) <= 0) { + free(wide); + return NULL; + } + return wide; +} + +static bool activation_test_windows_current_user_sid(void **information_out, + PSID *sid_out) { + *information_out = NULL; + *sid_out = NULL; + HANDLE token = NULL; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { + return false; + } + DWORD needed = 0; + (void)GetTokenInformation(token, TokenUser, NULL, 0, &needed); + void *information = needed > 0 ? calloc(1, needed) : NULL; + bool ok = information && + GetTokenInformation(token, TokenUser, information, needed, + &needed) != 0; + (void)CloseHandle(token); + if (!ok) { + free(information); + return false; + } + PSID sid = ((TOKEN_USER *)information)->User.Sid; + if (!sid || !IsValidSid(sid)) { + free(information); + return false; + } + *information_out = information; + *sid_out = sid; + return true; +} + +static bool activation_test_windows_set_directory_acl( + const char *path, bool include_untrusted_callback) { + void *user_information = NULL; + PSID user_sid = NULL; + if (!activation_test_windows_current_user_sid(&user_information, + &user_sid)) { + return false; + } + unsigned char world_sid_storage[SECURITY_MAX_SID_SIZE]; + DWORD world_sid_size = sizeof(world_sid_storage); + PSID world_sid = world_sid_storage; + bool ok = CreateWellKnownSid(WinWorldSid, NULL, world_sid, + &world_sid_size) != 0; + DWORD world_sid_length = ok ? GetLengthSid(world_sid) : 0U; + DWORD user_ace_size = (DWORD)(sizeof(ACCESS_ALLOWED_ACE) - sizeof(DWORD)) + + GetLengthSid(user_sid); + DWORD callback_ace_size = + (DWORD)(sizeof(activation_test_callback_allow_ace_t) - sizeof(DWORD)) + + world_sid_length; + DWORD acl_size = (DWORD)sizeof(ACL) + user_ace_size + + (include_untrusted_callback ? callback_ace_size : 0U); + PACL acl = ok ? calloc(1, acl_size) : NULL; + ok = acl && InitializeAcl(acl, acl_size, ACL_REVISION) != 0 && + AddAccessAllowedAceEx(acl, ACL_REVISION, 0, GENERIC_ALL, + user_sid) != 0; + unsigned char *callback_storage = + include_untrusted_callback ? calloc(1, callback_ace_size) : NULL; + if (ok && include_untrusted_callback) { + activation_test_callback_allow_ace_t *callback = + (activation_test_callback_allow_ace_t *)callback_storage; + ok = callback && callback_ace_size <= UINT16_MAX; + if (ok) { + callback->header.AceType = ACCESS_ALLOWED_CALLBACK_ACE_TYPE; + callback->header.AceFlags = 0; + callback->header.AceSize = (WORD)callback_ace_size; + callback->mask = FILE_ADD_FILE | FILE_DELETE_CHILD; + ok = CopySid(world_sid_length, &callback->sid_start, world_sid) != + 0 && + AddAce(acl, ACL_REVISION, MAXDWORD, callback, + callback_ace_size) != 0; + } + } + wchar_t *wide_path = ok ? activation_test_windows_utf8_to_wide(path) : NULL; + HANDLE directory = wide_path + ? CreateFileW(wide_path, READ_CONTROL | WRITE_DAC, + FILE_SHARE_READ | FILE_SHARE_WRITE | + FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | + FILE_FLAG_OPEN_REPARSE_POINT, + NULL) + : INVALID_HANDLE_VALUE; + ok = directory != INVALID_HANDLE_VALUE && + SetSecurityInfo(directory, SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | + PROTECTED_DACL_SECURITY_INFORMATION, + NULL, NULL, acl, NULL) == ERROR_SUCCESS; + if (directory != INVALID_HANDLE_VALUE) { + (void)CloseHandle(directory); + } + free(wide_path); + free(callback_storage); + free(acl); + free(user_information); + return ok; +} + +static bool activation_test_windows_directory_owned_by_current_user( + const char *path) { + void *user_information = NULL; + PSID user_sid = NULL; + wchar_t *wide_path = activation_test_windows_utf8_to_wide(path); + HANDLE directory = wide_path + ? CreateFileW(wide_path, READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | + FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | + FILE_FLAG_OPEN_REPARSE_POINT, + NULL) + : INVALID_HANDLE_VALUE; + PSID owner = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + bool ok = directory != INVALID_HANDLE_VALUE && + activation_test_windows_current_user_sid(&user_information, + &user_sid) && + GetSecurityInfo(directory, SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, &owner, NULL, NULL, + NULL, &descriptor) == ERROR_SUCCESS && + owner && IsValidSid(owner) && EqualSid(owner, user_sid); + if (descriptor) { + (void)LocalFree(descriptor); + } + if (directory != INVALID_HANDLE_VALUE) { + (void)CloseHandle(directory); + } + free(wide_path); + free(user_information); + return ok; +} +#endif + +#ifndef _WIN32 +typedef struct { + const char *contents; + bool created; +} activation_test_competing_target_t; + +static void activation_test_create_competing_target(const char *target_path, + void *opaque) { + activation_test_competing_target_t *competing = opaque; + competing->created = activation_test_write(target_path, + competing->contents); +} +#endif + +#ifdef __APPLE__ +typedef enum { + ACTIVATION_TEST_ACL_OK = 0, + ACTIVATION_TEST_ACL_UNSUPPORTED = 1, + ACTIVATION_TEST_ACL_ERROR = 2, +} activation_test_acl_status_t; + +static activation_test_acl_status_t activation_test_install_mutating_acl( + const char *path) { + acl_t acl = acl_init(1); + if (!acl) { + return ACTIVATION_TEST_ACL_ERROR; + } + acl_entry_t entry = NULL; + acl_permset_t permissions = NULL; + gid_t foreign_group = getegid() == (gid_t)0 ? (gid_t)1 : (gid_t)0; + uuid_t foreign_group_uuid; + bool valid = acl_create_entry(&acl, &entry) == 0 && entry && + acl_set_tag_type(entry, ACL_EXTENDED_ALLOW) == 0 && + mbr_gid_to_uuid(foreign_group, foreign_group_uuid) == 0 && + acl_set_qualifier(entry, foreign_group_uuid) == 0 && + acl_get_permset(entry, &permissions) == 0 && permissions && + acl_clear_perms(permissions) == 0 && + acl_add_perm(permissions, ACL_ADD_FILE) == 0 && + acl_add_perm(permissions, ACL_ADD_SUBDIRECTORY) == 0 && + acl_add_perm(permissions, ACL_DELETE_CHILD) == 0 && + acl_set_permset(entry, permissions) == 0 && + acl_valid(acl) == 0; + activation_test_acl_status_t status = ACTIVATION_TEST_ACL_ERROR; + if (valid) { + errno = 0; + if (acl_set_file(path, ACL_TYPE_EXTENDED, acl) == 0) { + status = ACTIVATION_TEST_ACL_OK; + } else if (errno == ENOTSUP || errno == EOPNOTSUPP) { + status = ACTIVATION_TEST_ACL_UNSUPPORTED; + } + } + if (acl_free(acl) != 0) { + status = ACTIVATION_TEST_ACL_ERROR; + } + return status; +} + +static bool activation_test_clear_extended_acl(const char *path) { + acl_t empty = acl_init(0); + if (!empty) { + return false; + } + bool cleared = acl_set_file(path, ACL_TYPE_EXTENDED, empty) == 0; + return acl_free(empty) == 0 && cleared; +} + +static bool activation_test_clear_extended_acl_if_exists(const char *path) { + return !path || !path[0] || !activation_test_exists(path) || + activation_test_clear_extended_acl(path); +} + +static bool activation_test_acl_metadata_acceptable(const char *path, + bool expect_directory) { + struct stat status; + if (lstat(path, &status) != 0 || status.st_uid != geteuid() || + (status.st_mode & 0777) != 0700) { + return false; + } + return expect_directory ? S_ISDIR(status.st_mode) + : S_ISREG(status.st_mode) && status.st_nlink == 1; +} +#endif + +TEST(activation_transaction_stages_same_directory_private_executable_and_aborts) { + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_NOT_NULL(transaction); + const char *staged = cbm_activation_transaction_staged_path(transaction); + ASSERT_NOT_NULL(staged); + ASSERT_TRUE(strncmp(staged, directory, strlen(directory)) == 0); + ASSERT_EQ(staged[strlen(directory)], '/'); + ASSERT_FALSE(activation_test_exists(target)); + + char contents[ACTIVATION_TEST_CONTENT_CAP]; + ASSERT_TRUE(activation_test_read(staged, contents)); + ASSERT_STR_EQ(contents, "candidate"); +#ifndef _WIN32 + struct stat status; + ASSERT_EQ(lstat(staged, &status), 0); + ASSERT_TRUE(S_ISREG(status.st_mode)); + ASSERT_EQ(status.st_uid, geteuid()); + ASSERT_EQ(status.st_mode & 0777, 0700); +#endif + + char staged_copy[ACTIVATION_TEST_PATH_CAP]; + (void)snprintf(staged_copy, sizeof(staged_copy), "%s", staged); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_NULL(transaction); + ASSERT_FALSE(activation_test_exists(staged_copy)); + ASSERT_FALSE(activation_test_exists(target)); + ASSERT_EQ(th_rmtree(directory), 0); + PASS(); +} + +TEST(activation_transaction_commit_keeps_backup_until_finalize) { + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + ASSERT_TRUE(activation_test_write(target, "old")); + + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + target, "new", strlen("new"), &transaction), + CBM_ACTIVATION_TRANSACTION_OK); + const char *backup = cbm_activation_transaction_backup_path(transaction); + ASSERT_NOT_NULL(backup); + char backup_copy[ACTIVATION_TEST_PATH_CAP]; + (void)snprintf(backup_copy, sizeof(backup_copy), "%s", backup); + + activation_test_validation_t validation = { + .expect_absent = false, + .expected_contents = "new", + }; + ASSERT_EQ(cbm_activation_transaction_commit(transaction, + activation_test_validate, + &validation), + CBM_ACTIVATION_TRANSACTION_OK); + char contents[ACTIVATION_TEST_CONTENT_CAP]; + ASSERT_TRUE(activation_test_read(target, contents)); + ASSERT_STR_EQ(contents, "new"); + ASSERT_TRUE(activation_test_read(backup_copy, contents)); + ASSERT_STR_EQ(contents, "old"); + + ASSERT_EQ(cbm_activation_transaction_finalize(transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_FALSE(activation_test_exists(backup_copy)); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(th_rmtree(directory), 0); + PASS(); +} + +TEST(activation_transaction_validation_failure_restores_previous_target) { + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + ASSERT_TRUE(activation_test_write(target, "old")); + + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + target, "bad", strlen("bad"), &transaction), + CBM_ACTIVATION_TRANSACTION_OK); + const char *backup = cbm_activation_transaction_backup_path(transaction); + ASSERT_NOT_NULL(backup); + char backup_copy[ACTIVATION_TEST_PATH_CAP]; + (void)snprintf(backup_copy, sizeof(backup_copy), "%s", backup); + ASSERT_EQ(cbm_activation_transaction_commit(transaction, + activation_test_reject, NULL), + CBM_ACTIVATION_TRANSACTION_VALIDATION_FAILED); + + char contents[ACTIVATION_TEST_CONTENT_CAP]; + ASSERT_TRUE(activation_test_read(target, contents)); + ASSERT_STR_EQ(contents, "old"); + ASSERT_FALSE(activation_test_exists(backup_copy)); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(th_rmtree(directory), 0); + PASS(); +} + +TEST(activation_transaction_explicit_rollback_restores_previous_target) { + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + ASSERT_TRUE(activation_test_write(target, "old")); + + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + target, "new", strlen("new"), &transaction), + CBM_ACTIVATION_TRANSACTION_OK); + activation_test_validation_t validation = { + .expect_absent = false, + .expected_contents = "new", + }; + ASSERT_EQ(cbm_activation_transaction_commit(transaction, + activation_test_validate, + &validation), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_rollback(transaction), + CBM_ACTIVATION_TRANSACTION_OK); + char contents[ACTIVATION_TEST_CONTENT_CAP]; + ASSERT_TRUE(activation_test_read(target, contents)); + ASSERT_STR_EQ(contents, "old"); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(th_rmtree(directory), 0); + PASS(); +} + +TEST(activation_transaction_stage_file_installs_new_target) { + char directory[ACTIVATION_TEST_PATH_CAP]; + char source[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(source, directory, "downloaded-cbm")); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + ASSERT_TRUE(activation_test_write(source, "downloaded")); + + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_file(target, source, + &transaction), + CBM_ACTIVATION_TRANSACTION_OK); + activation_test_validation_t validation = { + .expect_absent = false, + .expected_contents = "downloaded", + }; + ASSERT_EQ(cbm_activation_transaction_commit(transaction, + activation_test_validate, + &validation), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_finalize(transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), + CBM_ACTIVATION_TRANSACTION_OK); + char contents[ACTIVATION_TEST_CONTENT_CAP]; + ASSERT_TRUE(activation_test_read(target, contents)); + ASSERT_STR_EQ(contents, "downloaded"); + ASSERT_EQ(th_rmtree(directory), 0); + PASS(); +} + +TEST(activation_transaction_removal_can_rollback_or_finalize) { + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + ASSERT_TRUE(activation_test_write(target, "old")); + activation_test_validation_t absent = { + .expect_absent = true, + .expected_contents = NULL, + }; + + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_removal(target, &transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_commit(transaction, + activation_test_validate, + &absent), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_FALSE(activation_test_exists(target)); + ASSERT_EQ(cbm_activation_transaction_rollback(transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_TRUE(activation_test_exists(target)); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), + CBM_ACTIVATION_TRANSACTION_OK); + + ASSERT_EQ(cbm_activation_transaction_stage_removal(target, &transaction), + CBM_ACTIVATION_TRANSACTION_OK); + const char *backup = cbm_activation_transaction_backup_path(transaction); + ASSERT_NOT_NULL(backup); + char backup_copy[ACTIVATION_TEST_PATH_CAP]; + (void)snprintf(backup_copy, sizeof(backup_copy), "%s", backup); + ASSERT_EQ(cbm_activation_transaction_commit(transaction, + activation_test_validate, + &absent), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_finalize(transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_FALSE(activation_test_exists(target)); + ASSERT_FALSE(activation_test_exists(backup_copy)); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(th_rmtree(directory), 0); + PASS(); +} + +TEST(activation_transaction_rejects_cross_account_writable_target_directory) { +#ifndef _WIN32 + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + ASSERT_EQ(chmod(directory, 0777), 0); + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction), + CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_NULL(transaction); + ASSERT_EQ(chmod(directory, 0700), 0); + ASSERT_EQ(th_rmtree(directory), 0); +#endif + PASS(); +} + +TEST(activation_transaction_rejects_windows_callback_allow_directory_ace) { +#ifdef _WIN32 + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + ASSERT_TRUE(activation_test_windows_set_directory_acl(directory, true)); + ASSERT_TRUE( + activation_test_windows_directory_owned_by_current_user(directory)); + + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t stage_status = + cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction); + bool transaction_was_created = transaction != NULL; + cbm_activation_transaction_status_t close_status = + transaction ? cbm_activation_transaction_close(&transaction) + : CBM_ACTIVATION_TRANSACTION_OK; + bool acl_restored = + activation_test_windows_set_directory_acl(directory, false); + int cleanup_status = th_rmtree(directory); + + ASSERT_EQ(stage_status, CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_FALSE(transaction_was_created); + ASSERT_EQ(close_status, CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_NULL(transaction); + ASSERT_TRUE(acl_restored); + ASSERT_EQ(cleanup_status, 0); +#endif + PASS(); +} + +TEST(activation_transaction_rejects_symlink_candidate_target_and_parent) { +#ifndef _WIN32 + char directory[ACTIVATION_TEST_PATH_CAP]; + char candidate[ACTIVATION_TEST_PATH_CAP]; + char candidate_link[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + char target_link[ACTIVATION_TEST_PATH_CAP]; + char real_parent[ACTIVATION_TEST_PATH_CAP]; + char real_nested[ACTIVATION_TEST_PATH_CAP]; + char real_nested_candidate[ACTIVATION_TEST_PATH_CAP]; + char parent_link[ACTIVATION_TEST_PATH_CAP]; + char linked_parent_target[ACTIVATION_TEST_PATH_CAP]; + char linked_nested_target[ACTIVATION_TEST_PATH_CAP]; + char linked_nested_candidate[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(candidate, directory, "candidate")); + ASSERT_TRUE(activation_test_path(candidate_link, directory, + "candidate-link")); + ASSERT_TRUE(activation_test_path(target, directory, "target")); + ASSERT_TRUE(activation_test_path(target_link, directory, "target-link")); + ASSERT_TRUE(activation_test_path(real_parent, directory, "real-parent")); + ASSERT_TRUE(activation_test_path(real_nested, real_parent, "nested")); + ASSERT_TRUE(activation_test_path(real_nested_candidate, real_nested, + "candidate")); + ASSERT_TRUE(activation_test_path(parent_link, directory, "parent-link")); + ASSERT_TRUE(activation_test_path(linked_parent_target, parent_link, "cbm")); + ASSERT_TRUE(activation_test_path(linked_nested_target, parent_link, + "nested/cbm")); + ASSERT_TRUE(activation_test_path(linked_nested_candidate, parent_link, + "nested/candidate")); + ASSERT_TRUE(activation_test_write(candidate, "candidate")); + ASSERT_EQ(symlink(candidate, candidate_link), 0); + ASSERT_EQ(symlink(candidate, target_link), 0); + ASSERT_TRUE(cbm_mkdir_p(real_parent, 0700)); + ASSERT_TRUE(cbm_mkdir_p(real_nested, 0700)); + ASSERT_TRUE(activation_test_write(real_nested_candidate, "candidate")); + ASSERT_EQ(symlink(real_parent, parent_link), 0); + + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_file(target, candidate_link, + &transaction), + CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_NULL(transaction); + ASSERT_EQ(cbm_activation_transaction_stage_file( + target, linked_nested_candidate, &transaction), + CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_NULL(transaction); + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + target_link, "replacement", strlen("replacement"), + &transaction), + CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_NULL(transaction); + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + linked_parent_target, "replacement", strlen("replacement"), + &transaction), + CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_NULL(transaction); + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + linked_nested_target, "replacement", strlen("replacement"), + &transaction), + CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_NULL(transaction); + + char contents[ACTIVATION_TEST_CONTENT_CAP]; + ASSERT_TRUE(activation_test_read(candidate, contents)); + ASSERT_STR_EQ(contents, "candidate"); + ASSERT_EQ(unlink(candidate_link), 0); + ASSERT_EQ(unlink(target_link), 0); + ASSERT_EQ(unlink(parent_link), 0); + ASSERT_EQ(th_rmtree(directory), 0); +#endif + PASS(); +} + +TEST(activation_transaction_fails_closed_if_target_directory_is_replaced) { + char root[ACTIVATION_TEST_PATH_CAP]; + char active[ACTIVATION_TEST_PATH_CAP]; + char moved[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(root)); + ASSERT_TRUE(activation_test_path(active, root, "active")); + ASSERT_TRUE(activation_test_path(moved, root, "moved")); + ASSERT_TRUE(cbm_mkdir_p(active, 0700)); + ASSERT_TRUE(activation_test_path(target, active, "cbm")); + + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(rename(active, moved), 0); + ASSERT_TRUE(cbm_mkdir_p(active, 0700)); + ASSERT_EQ(cbm_activation_transaction_commit(transaction, NULL, NULL), + CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_FALSE(activation_test_exists(target)); + ASSERT_EQ(cbm_rmdir(active), 0); + ASSERT_EQ(rename(moved, active), 0); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_NULL(transaction); + ASSERT_EQ(th_rmtree(root), 0); + PASS(); +} + +TEST(activation_transaction_does_not_replace_target_created_at_publish_boundary) { +#ifndef _WIN32 + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction), + CBM_ACTIVATION_TRANSACTION_OK); + const char *staged = cbm_activation_transaction_staged_path(transaction); + ASSERT_NOT_NULL(staged); + char staged_copy[ACTIVATION_TEST_PATH_CAP]; + (void)snprintf(staged_copy, sizeof(staged_copy), "%s", staged); + + activation_test_competing_target_t competing = { + .contents = "external", + .created = false, + }; + cbm_activation_transaction_set_before_absent_publish_for_test( + activation_test_create_competing_target, &competing); + cbm_activation_transaction_status_t commit_status = + cbm_activation_transaction_commit(transaction, NULL, NULL); + cbm_activation_transaction_set_before_absent_publish_for_test(NULL, NULL); + + char before_close[ACTIVATION_TEST_CONTENT_CAP]; + bool target_survived_commit = activation_test_read(target, before_close); + bool stage_survived_commit = activation_test_exists(staged_copy); + cbm_activation_transaction_status_t close_status = + cbm_activation_transaction_close(&transaction); + bool stage_survived_close = activation_test_exists(staged_copy); + char after_close[ACTIVATION_TEST_CONTENT_CAP]; + bool target_survived_close = activation_test_read(target, after_close); + + if (activation_test_exists(target)) { + ASSERT_EQ(cbm_unlink(target), 0); + } + ASSERT_EQ(th_rmtree(directory), 0); + + ASSERT_TRUE(competing.created); + ASSERT_EQ(commit_status, CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_TRUE(target_survived_commit); + ASSERT_STR_EQ(before_close, "external"); + ASSERT_TRUE(stage_survived_commit); + ASSERT_EQ(close_status, CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_NULL(transaction); + ASSERT_FALSE(stage_survived_close); + ASSERT_TRUE(target_survived_close); + ASSERT_STR_EQ(after_close, "external"); +#endif + PASS(); +} + +TEST(activation_transaction_rejects_macos_mutating_extended_acl) { +#ifdef __APPLE__ + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + ASSERT_EQ(chmod(directory, 0700), 0); + + activation_test_acl_status_t acl_status = + activation_test_install_mutating_acl(directory); + if (acl_status == ACTIVATION_TEST_ACL_UNSUPPORTED) { + ASSERT_EQ(th_rmtree(directory), 0); + SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); + } + ASSERT_EQ(acl_status, ACTIVATION_TEST_ACL_OK); + + struct stat status; + ASSERT_EQ(stat(directory, &status), 0); + ASSERT_EQ(status.st_mode & 0777, 0700); + + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t stage_status = + cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction); + bool transaction_was_created = transaction != NULL; + cbm_activation_transaction_status_t close_status = + transaction ? cbm_activation_transaction_close(&transaction) + : CBM_ACTIVATION_TRANSACTION_OK; + ASSERT_EQ(th_rmtree(directory), 0); + + ASSERT_EQ(stage_status, CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_FALSE(transaction_was_created); + ASSERT_EQ(close_status, CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_NULL(transaction); +#endif + PASS(); +} + +TEST(activation_transaction_rejects_macos_existing_target_mutating_acl) { +#ifdef __APPLE__ + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + ASSERT_TRUE(activation_test_write(target, "old")); + + activation_test_acl_status_t acl_status = + activation_test_install_mutating_acl(target); + if (acl_status == ACTIVATION_TEST_ACL_UNSUPPORTED) { + ASSERT_EQ(th_rmtree(directory), 0); + SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); + } + ASSERT_EQ(acl_status, ACTIVATION_TEST_ACL_OK); + bool metadata_acceptable = + activation_test_acl_metadata_acceptable(target, false); + + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t stage_status = + cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction); + char staged[ACTIVATION_TEST_PATH_CAP] = {0}; + char backup[ACTIVATION_TEST_PATH_CAP] = {0}; + if (transaction) { + const char *staged_path = + cbm_activation_transaction_staged_path(transaction); + const char *backup_path = + cbm_activation_transaction_backup_path(transaction); + if (staged_path) { + (void)snprintf(staged, sizeof(staged), "%s", staged_path); + } + if (backup_path) { + (void)snprintf(backup, sizeof(backup), "%s", backup_path); + } + } + cbm_activation_transaction_status_t commit_status = + CBM_ACTIVATION_TRANSACTION_INVALID_STATE; + if (stage_status == CBM_ACTIVATION_TRANSACTION_OK && transaction) { + commit_status = + cbm_activation_transaction_commit(transaction, NULL, NULL); + } + char observed[ACTIVATION_TEST_CONTENT_CAP]; + bool target_unchanged = activation_test_read(target, observed) && + strcmp(observed, "old") == 0; + bool acl_cleared = activation_test_clear_extended_acl_if_exists(target) && + activation_test_clear_extended_acl_if_exists(staged) && + activation_test_clear_extended_acl_if_exists(backup); + cbm_activation_transaction_status_t close_status = + transaction ? cbm_activation_transaction_close(&transaction) + : CBM_ACTIVATION_TRANSACTION_OK; + char restored[ACTIVATION_TEST_CONTENT_CAP]; + bool target_restored = activation_test_read(target, restored) && + strcmp(restored, "old") == 0; + int cleanup_status = th_rmtree(directory); + + ASSERT_TRUE(metadata_acceptable); + ASSERT_TRUE(stage_status == CBM_ACTIVATION_TRANSACTION_IO || + (stage_status == CBM_ACTIVATION_TRANSACTION_OK && + commit_status == CBM_ACTIVATION_TRANSACTION_IO)); + ASSERT_TRUE(target_unchanged); + ASSERT_TRUE(acl_cleared); + ASSERT_EQ(close_status, CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_NULL(transaction); + ASSERT_TRUE(target_restored); + ASSERT_EQ(cleanup_status, 0); +#endif + PASS(); +} + +TEST(activation_transaction_revalidates_macos_directory_acl_before_commit) { +#ifdef __APPLE__ + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + ASSERT_TRUE(activation_test_write(target, "old")); + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction), + CBM_ACTIVATION_TRANSACTION_OK); + + activation_test_acl_status_t acl_status = + activation_test_install_mutating_acl(directory); + if (acl_status == ACTIVATION_TEST_ACL_UNSUPPORTED) { + ASSERT_EQ(cbm_activation_transaction_close(&transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(th_rmtree(directory), 0); + SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); + } + ASSERT_EQ(acl_status, ACTIVATION_TEST_ACL_OK); + bool metadata_acceptable = + activation_test_acl_metadata_acceptable(directory, true); + cbm_activation_transaction_status_t commit_status = + cbm_activation_transaction_commit(transaction, NULL, NULL); + char observed[ACTIVATION_TEST_CONTENT_CAP]; + bool target_unchanged = activation_test_read(target, observed) && + strcmp(observed, "old") == 0; + bool acl_cleared = activation_test_clear_extended_acl(directory); + cbm_activation_transaction_status_t close_status = + cbm_activation_transaction_close(&transaction); + int cleanup_status = th_rmtree(directory); + + ASSERT_TRUE(metadata_acceptable); + ASSERT_EQ(commit_status, CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_TRUE(target_unchanged); + ASSERT_TRUE(acl_cleared); + ASSERT_EQ(close_status, CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_NULL(transaction); + ASSERT_EQ(cleanup_status, 0); +#endif + PASS(); +} + +TEST(activation_transaction_revalidates_macos_staged_file_acl_before_commit) { +#ifdef __APPLE__ + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + ASSERT_TRUE(activation_test_write(target, "old")); + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction), + CBM_ACTIVATION_TRANSACTION_OK); + const char *staged_path = + cbm_activation_transaction_staged_path(transaction); + ASSERT_NOT_NULL(staged_path); + char staged[ACTIVATION_TEST_PATH_CAP]; + (void)snprintf(staged, sizeof(staged), "%s", staged_path); + + activation_test_acl_status_t acl_status = + activation_test_install_mutating_acl(staged); + if (acl_status == ACTIVATION_TEST_ACL_UNSUPPORTED) { + ASSERT_EQ(cbm_activation_transaction_close(&transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(th_rmtree(directory), 0); + SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); + } + ASSERT_EQ(acl_status, ACTIVATION_TEST_ACL_OK); + bool metadata_acceptable = + activation_test_acl_metadata_acceptable(staged, false); + cbm_activation_transaction_status_t commit_status = + cbm_activation_transaction_commit(transaction, NULL, NULL); + char observed[ACTIVATION_TEST_CONTENT_CAP]; + bool target_unchanged = activation_test_read(target, observed) && + strcmp(observed, "old") == 0; + bool acl_cleared = activation_test_clear_extended_acl_if_exists(staged) && + activation_test_clear_extended_acl_if_exists(target); + cbm_activation_transaction_status_t close_status = + cbm_activation_transaction_close(&transaction); + int cleanup_status = th_rmtree(directory); + + ASSERT_TRUE(metadata_acceptable); + ASSERT_EQ(commit_status, CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_TRUE(target_unchanged); + ASSERT_TRUE(acl_cleared); + ASSERT_EQ(close_status, CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_NULL(transaction); + ASSERT_EQ(cleanup_status, 0); +#endif + PASS(); +} + +TEST(activation_transaction_revalidates_macos_target_file_acl_before_commit) { +#ifdef __APPLE__ + char directory[ACTIVATION_TEST_PATH_CAP]; + char target[ACTIVATION_TEST_PATH_CAP]; + ASSERT_TRUE(activation_test_fixture(directory)); + ASSERT_TRUE(activation_test_path(target, directory, "cbm")); + ASSERT_TRUE(activation_test_write(target, "old")); + cbm_activation_transaction_t *transaction = NULL; + ASSERT_EQ(cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction), + CBM_ACTIVATION_TRANSACTION_OK); + const char *backup_path = + cbm_activation_transaction_backup_path(transaction); + ASSERT_NOT_NULL(backup_path); + char backup[ACTIVATION_TEST_PATH_CAP]; + (void)snprintf(backup, sizeof(backup), "%s", backup_path); + + activation_test_acl_status_t acl_status = + activation_test_install_mutating_acl(target); + if (acl_status == ACTIVATION_TEST_ACL_UNSUPPORTED) { + ASSERT_EQ(cbm_activation_transaction_close(&transaction), + CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(th_rmtree(directory), 0); + SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); + } + ASSERT_EQ(acl_status, ACTIVATION_TEST_ACL_OK); + bool metadata_acceptable = + activation_test_acl_metadata_acceptable(target, false); + cbm_activation_transaction_status_t commit_status = + cbm_activation_transaction_commit(transaction, NULL, NULL); + char observed[ACTIVATION_TEST_CONTENT_CAP]; + bool target_unchanged = activation_test_read(target, observed) && + strcmp(observed, "old") == 0; + bool acl_cleared = activation_test_clear_extended_acl_if_exists(target) && + activation_test_clear_extended_acl_if_exists(backup); + cbm_activation_transaction_status_t close_status = + cbm_activation_transaction_close(&transaction); + int cleanup_status = th_rmtree(directory); + + ASSERT_TRUE(metadata_acceptable); + ASSERT_EQ(commit_status, CBM_ACTIVATION_TRANSACTION_IO); + ASSERT_TRUE(target_unchanged); + ASSERT_TRUE(acl_cleared); + ASSERT_EQ(close_status, CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_NULL(transaction); + ASSERT_EQ(cleanup_status, 0); +#endif + PASS(); +} + +SUITE(activation_transaction) { + RUN_TEST(activation_transaction_stages_same_directory_private_executable_and_aborts); + RUN_TEST(activation_transaction_commit_keeps_backup_until_finalize); + RUN_TEST(activation_transaction_validation_failure_restores_previous_target); + RUN_TEST(activation_transaction_explicit_rollback_restores_previous_target); + RUN_TEST(activation_transaction_stage_file_installs_new_target); + RUN_TEST(activation_transaction_removal_can_rollback_or_finalize); + RUN_TEST(activation_transaction_rejects_cross_account_writable_target_directory); + RUN_TEST(activation_transaction_rejects_windows_callback_allow_directory_ace); + RUN_TEST(activation_transaction_rejects_symlink_candidate_target_and_parent); + RUN_TEST(activation_transaction_fails_closed_if_target_directory_is_replaced); + RUN_TEST(activation_transaction_does_not_replace_target_created_at_publish_boundary); + RUN_TEST(activation_transaction_rejects_macos_mutating_extended_acl); + RUN_TEST(activation_transaction_rejects_macos_existing_target_mutating_acl); + RUN_TEST(activation_transaction_revalidates_macos_directory_acl_before_commit); + RUN_TEST(activation_transaction_revalidates_macos_staged_file_acl_before_commit); + RUN_TEST(activation_transaction_revalidates_macos_target_file_acl_before_commit); +} diff --git a/tests/test_cli.c b/tests/test_cli.c index bb8a94b6f..d6e187454 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10,12 +10,19 @@ * Total: 47 Go tests → 47 C tests */ #include "../src/foundation/compat.h" +#include "../src/foundation/compat_thread.h" #include "test_framework.h" #include "test_helpers.h" #include +#include +#include +#include +#include +#include #include #include #include +#include #include #include #include @@ -26,8 +33,128 @@ #include #endif #include +#include #include +/* Internal prompt seam used to restore process-global state after command + * tests that exercise --yes. */ +void cbm_set_auto_answer_for_test(int value); +int cbm_cli_sha256_file(const char *path, char *out, size_t out_size); +int cbm_cli_checksum_manifest_digest(const char *manifest_path, + const char *archive_name, char *out, + size_t out_size); +void cbm_cli_set_activation_cleanup_failure_for_test(bool enabled); +int cbm_cli_activation_abort_cleanup_probe_for_test(void); + +TEST(cli_progress_visibility_policy) { + ASSERT_TRUE(cbm_cli_progress_enabled(true, false)); + ASSERT_TRUE(cbm_cli_progress_enabled(false, true)); + ASSERT_FALSE(cbm_cli_progress_enabled(false, false)); + PASS(); +} + +TEST(cli_raw_mcp_result_preserves_tool_error_status) { + ASSERT_TRUE(cbm_cli_mcp_result_is_error( + "{\"content\":[],\"isError\":true}")); + ASSERT_FALSE(cbm_cli_mcp_result_is_error( + "{\"content\":[],\"isError\":false}")); + ASSERT_FALSE(cbm_cli_mcp_result_is_error( + "{\"content\":[{\"text\":\"\\\"isError\\\":true\"}]}")); + ASSERT_FALSE(cbm_cli_mcp_result_is_error("{\"isError\":\"true\"}")); + ASSERT_FALSE(cbm_cli_mcp_result_is_error("not-json")); + ASSERT_FALSE(cbm_cli_mcp_result_is_error(NULL)); + PASS(); +} + +TEST(cli_progress_sink_accepts_worker_json_logs) { + FILE *out = tmpfile(); + ASSERT_NOT_NULL(out); + + cbm_progress_sink_init(out); + cbm_progress_sink_fn( + "{\"level\":\"info\",\"event\":\"pipeline.discover\",\"files\":\"3\"}"); + cbm_progress_sink_fn( + "{\"level\":\"info\",\"event\":\"pass.start\",\"pass\":\"structure\"}"); + cbm_progress_sink_fini(); + + ASSERT_EQ(fseek(out, 0, SEEK_SET), 0); + char rendered[512] = {0}; + size_t rendered_size = fread(rendered, 1, sizeof(rendered) - 1, out); + (void)fclose(out); + + ASSERT_TRUE(rendered_size > 0); + ASSERT_NOT_NULL(strstr(rendered, "Discovering files (3 found)")); + ASSERT_NOT_NULL(strstr(rendered, "[1/9] Building file structure")); + PASS(); +} + +enum { CLI_PROGRESS_RACE_THREADS = 4, CLI_PROGRESS_RACE_ROUNDS = 32 }; + +typedef struct { + atomic_bool *start; + int worker_id; +} cli_progress_race_arg_t; + +static void *cli_progress_race_worker(void *opaque) { + cli_progress_race_arg_t *arg = opaque; + char counts[128]; + char progress[128]; + (void)snprintf(counts, sizeof(counts), + "level=info msg=gbuf.dump nodes=%d edges=%d", + 1000 + arg->worker_id, 2000 + arg->worker_id); + (void)snprintf(progress, sizeof(progress), + "level=info msg=parallel.extract.progress done=%d total=%d", + arg->worker_id + 1, CLI_PROGRESS_RACE_THREADS); + + while (!atomic_load_explicit(arg->start, memory_order_acquire)) { + cbm_usleep(100); + } + for (int round = 0; round < CLI_PROGRESS_RACE_ROUNDS; round++) { + cbm_progress_sink_fn(counts); + cbm_progress_sink_fn(progress); + } + return NULL; +} + +/* A normal run checks that concurrent callback delivery remains usable. More + * importantly, this is the focused TSan guard for progress-sink state: all + * worker threads write the node/edge summary and carriage-line state after the + * same release, so an unsynchronized implementation reports a data race. */ +TEST(cli_progress_sink_serializes_concurrent_callbacks) { + FILE *out = tmpfile(); + ASSERT_NOT_NULL(out); + cbm_progress_sink_init(out); + + atomic_bool start = ATOMIC_VAR_INIT(false); + cbm_thread_t threads[CLI_PROGRESS_RACE_THREADS]; + cli_progress_race_arg_t args[CLI_PROGRESS_RACE_THREADS]; + int created = 0; + for (; created < CLI_PROGRESS_RACE_THREADS; created++) { + args[created].start = &start; + args[created].worker_id = created; + if (cbm_thread_create(&threads[created], 0, cli_progress_race_worker, + &args[created]) != 0) { + break; + } + } + atomic_store_explicit(&start, true, memory_order_release); + for (int i = 0; i < created; i++) { + ASSERT_EQ(cbm_thread_join(&threads[i]), 0); + } + ASSERT_EQ(created, CLI_PROGRESS_RACE_THREADS); + + cbm_progress_sink_fn("level=info msg=pipeline.done elapsed_ms=1"); + cbm_progress_sink_fini(); + ASSERT_EQ(fseek(out, 0, SEEK_SET), 0); + char rendered[16384] = {0}; + size_t rendered_size = fread(rendered, 1, sizeof(rendered) - 1, out); + (void)fclose(out); + + ASSERT_TRUE(rendered_size > 0); + ASSERT_NOT_NULL(strstr(rendered, "Done: ")); + PASS(); +} + /* Helper: create a file with content */ static int write_test_file(const char *path, const char *content) { FILE *f = cbm_fopen(path, "wb"); @@ -245,6 +372,117 @@ static void test_rmdir_r(const char *path) { th_rmtree(path); } +/* Mandatory-daemon activation guard fixture. The production path uses the + * stable per-account endpoint directly; these callbacks make every race + * ordering deterministic without exposing a CLI flag or environment bypass. */ +typedef struct { + int mutation_count; + int mutation_result; + const char *guarded_path_a; + const char *guarded_text_a; + const char *guarded_path_b; + const char *guarded_text_b; + bool guarded_files_visible_before_unlock; + int mutation_reserve_result; + int mutation_reserve_count; + int quiesce_count; + int mutation_lease_release_count; + bool participants_active; + bool mutation_lease_held; + bool mutation_saw_lease; + const char *release_marker; + char diagnostic[512]; +} cli_activation_fake_t; + +static int cli_activation_fake_reserve_mutation( + void *opaque, cbm_cli_activation_lock_t *lease_out) { + cli_activation_fake_t *fake = opaque; + fake->mutation_reserve_count++; + if (fake->mutation_reserve_result != 1) { + *lease_out = NULL; + return fake->mutation_reserve_result; + } + if (fake->participants_active) { + fake->quiesce_count++; + fake->participants_active = false; + } + fake->mutation_lease_held = true; + *lease_out = fake; + return 1; +} + +static void cli_activation_fake_release_mutation( + void *opaque, cbm_cli_activation_lock_t lease) { + cli_activation_fake_t *fake = opaque; + if (lease == fake && fake->mutation_lease_held) { + bool path_a_visible = true; + bool path_b_visible = true; + if (fake->guarded_path_a) { + const char *data = read_test_file(fake->guarded_path_a); + path_a_visible = data && + (!fake->guarded_text_a || + strstr(data, fake->guarded_text_a)); + } + if (fake->guarded_path_b) { + const char *data = read_test_file(fake->guarded_path_b); + path_b_visible = data && + (!fake->guarded_text_b || + strstr(data, fake->guarded_text_b)); + } + fake->guarded_files_visible_before_unlock |= + path_a_visible && path_b_visible; + fake->mutation_lease_held = false; + fake->mutation_lease_release_count++; + if (fake->release_marker) { + (void)write_test_file(fake->release_marker, "released\n"); + } + } +} + +static void cli_activation_fake_diagnostic(void *opaque, const char *message) { + cli_activation_fake_t *fake = opaque; + snprintf(fake->diagnostic, sizeof(fake->diagnostic), "%s", message ? message : ""); +} + +static int cli_activation_fake_mutation(void *opaque) { + cli_activation_fake_t *fake = opaque; + fake->mutation_count++; + fake->mutation_saw_lease = fake->mutation_lease_held; + return fake->mutation_result; +} + +static cbm_cli_activation_ops_t cli_activation_fake_ops(cli_activation_fake_t *fake) { + cbm_cli_activation_ops_t ops = { + .context = fake, + .reserve_for_mutation = cli_activation_fake_reserve_mutation, + .mutation_lease_release = cli_activation_fake_release_mutation, + .visible_diagnostic = cli_activation_fake_diagnostic, + }; + return ops; +} + +static void cli_activation_save_env(char **home_out, char **cache_out) { + const char *home = getenv("HOME"); + const char *cache = getenv("CBM_CACHE_DIR"); + *home_out = home ? strdup(home) : NULL; + *cache_out = cache ? strdup(cache) : NULL; +} + +static void cli_activation_restore_env(char *home, char *cache) { + if (home) { + cbm_setenv("HOME", home, 1); + } else { + cbm_unsetenv("HOME"); + } + if (cache) { + cbm_setenv("CBM_CACHE_DIR", cache, 1); + } else { + cbm_unsetenv("CBM_CACHE_DIR"); + } + free(home); + free(cache); +} + /* Helper: create tar.gz with a single file */ static unsigned char *create_test_targz(const char *filename, const unsigned char *content, int content_len, int *out_len) { @@ -316,6 +554,1176 @@ static unsigned char *create_test_targz(const char *filename, const unsigned cha return gz; } +/* ═══════════════════════════════════════════════════════════════════ + * Mandatory daemon activation safety + * ═══════════════════════════════════════════════════════════════════ */ + +TEST(cli_activation_quiesces_active_cohort_before_mutation) { + cli_activation_fake_t fake = { + .participants_active = true, + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = { + .context = &fake, + .reserve_for_mutation = cli_activation_fake_reserve_mutation, + .mutation_lease_release = cli_activation_fake_release_mutation, + .visible_diagnostic = cli_activation_fake_diagnostic, + }; + + ASSERT_EQ(cbm_cli_activation_guard_with_ops( + &ops, cli_activation_fake_mutation, &fake), + 0); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.quiesce_count, 1); + ASSERT_FALSE(fake.participants_active); + ASSERT_EQ(fake.mutation_count, 1); + ASSERT_TRUE(fake.mutation_saw_lease); + ASSERT_EQ(fake.mutation_lease_release_count, 1); + ASSERT_FALSE(fake.mutation_lease_held); + PASS(); +} + +TEST(cli_activation_refuses_when_cohort_does_not_drain) { + cli_activation_fake_t fake = { + .participants_active = true, + .mutation_reserve_result = 0, + }; + cbm_cli_activation_ops_t ops = { + .context = &fake, + .reserve_for_mutation = cli_activation_fake_reserve_mutation, + .mutation_lease_release = cli_activation_fake_release_mutation, + .visible_diagnostic = cli_activation_fake_diagnostic, + }; + + ASSERT_EQ(cbm_cli_activation_guard_with_ops(&ops, cli_activation_fake_mutation, &fake), 1); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_TRUE(fake.participants_active); + ASSERT_EQ(fake.mutation_count, 0); + ASSERT_EQ(fake.mutation_lease_release_count, 0); + ASSERT_FALSE(fake.mutation_lease_held); + ASSERT_TRUE(fake.diagnostic[0] != '\0'); + PASS(); +} + +TEST(cli_activation_refuses_unsafe_cohort_reservation) { + cli_activation_fake_t fake = { + .mutation_reserve_result = -1, + }; + cbm_cli_activation_ops_t ops = { + .context = &fake, + .reserve_for_mutation = cli_activation_fake_reserve_mutation, + .mutation_lease_release = cli_activation_fake_release_mutation, + .visible_diagnostic = cli_activation_fake_diagnostic, + }; + + ASSERT_EQ(cbm_cli_activation_guard_with_ops(&ops, cli_activation_fake_mutation, &fake), 1); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.mutation_count, 0); + ASSERT_EQ(fake.mutation_lease_release_count, 0); + ASSERT_FALSE(fake.mutation_lease_held); + ASSERT_TRUE(fake.diagnostic[0] != '\0'); + PASS(); +} + +TEST(cli_activation_releases_maintenance_lease_after_success) { + cli_activation_fake_t fake = { + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = { + .context = &fake, + .reserve_for_mutation = cli_activation_fake_reserve_mutation, + .mutation_lease_release = cli_activation_fake_release_mutation, + .visible_diagnostic = cli_activation_fake_diagnostic, + }; + + ASSERT_EQ(cbm_cli_activation_guard_with_ops(&ops, cli_activation_fake_mutation, &fake), 0); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.mutation_count, 1); + ASSERT_TRUE(fake.mutation_saw_lease); + ASSERT_EQ(fake.mutation_lease_release_count, 1); + ASSERT_FALSE(fake.mutation_lease_held); + PASS(); +} + +TEST(cli_activation_releases_maintenance_lease_when_mutation_fails) { + cli_activation_fake_t fake = { + .mutation_reserve_result = 1, + .mutation_result = 7, + }; + cbm_cli_activation_ops_t ops = { + .context = &fake, + .reserve_for_mutation = cli_activation_fake_reserve_mutation, + .mutation_lease_release = cli_activation_fake_release_mutation, + .visible_diagnostic = cli_activation_fake_diagnostic, + }; + + ASSERT_EQ(cbm_cli_activation_guard_with_ops(&ops, cli_activation_fake_mutation, &fake), 7); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.mutation_count, 1); + ASSERT_TRUE(fake.mutation_saw_lease); + ASSERT_EQ(fake.mutation_lease_release_count, 1); + ASSERT_FALSE(fake.mutation_lease_held); + PASS(); +} + +#ifndef _WIN32 +static int cli_activation_abort_cleanup_probe_mutation(void *opaque) { + (void)opaque; + return cbm_cli_activation_abort_cleanup_probe_for_test(); +} + +/* A transaction whose recovery cannot be completed must not return through + * the ordinary activation guard: doing so would release the maintenance lease + * and allow another daemon generation to observe an uncertain executable. */ +TEST(cli_activation_cleanup_failure_fail_stops_before_lease_release) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), + "/tmp/cli-activation-cleanup-fail-stop-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char release_marker[512]; + snprintf(release_marker, sizeof(release_marker), "%s/released", tmpdir); + + pid_t child = fork(); + if (child < 0) { + test_rmdir_r(tmpdir); + FAIL("fork failed"); + } + if (child == 0) { + cli_activation_fake_t fake = { + .mutation_reserve_result = 1, + .release_marker = release_marker, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_cleanup_failure_for_test(true); + int rc = cbm_cli_activation_guard_with_ops( + &ops, cli_activation_abort_cleanup_probe_mutation, NULL); + cbm_cli_set_activation_cleanup_failure_for_test(false); + _Exit(rc == 0 ? EXIT_SUCCESS : 2); + } + + int status = 0; + bool waited = waitpid(child, &status, 0) == child; + struct stat marker_status; + bool lease_release_skipped = stat(release_marker, &marker_status) != 0; + test_rmdir_r(tmpdir); + + ASSERT_TRUE(waited); + ASSERT_TRUE(WIFEXITED(status)); + ASSERT_NEQ(WEXITSTATUS(status), 0); + ASSERT_TRUE(lease_release_skipped); + PASS(); +} + +/* Model the real bootstrap inversion: an admitted participant can already + * retain lifetime SH and startup when activation publishes maintenance EX. + * Keep startup longer than activation's two-second control deadline. A + * quiesce callback that waits for startup fails before the participant can + * drain; a callback that directly attempts OP8 and lets lifetime EX prove the + * drain succeeds, then acquires startup for the final re-probe and mutation. */ +TEST(cli_activation_quiesce_does_not_wait_on_bootstrap_startup) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-activation-order-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char runtime_parent[512]; + snprintf(runtime_parent, sizeof(runtime_parent), "%s/runtime", tmpdir); + if (test_mkdirp(runtime_parent) != 0) { + test_rmdir_r(tmpdir); + FAIL("runtime parent setup failed"); + } + int ready_pipe[2] = {-1, -1}; + if (pipe(ready_pipe) != 0) { + test_rmdir_r(tmpdir); + FAIL("pipe failed"); + } + pid_t child = fork(); + if (child == 0) { + close(ready_pipe[0]); + cbm_daemon_ipc_endpoint_t *endpoint = + cbm_daemon_bootstrap_endpoint_new(runtime_parent); + cbm_version_cohort_manager_t *manager = + endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL; + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + cbm_daemon_build_identity_t identity = { + .semantic_version = "cli-activation-test", + .build_fingerprint = fingerprint, + .protocol_abi = CBM_DAEMON_RUNTIME_WIRE_ABI, + .store_abi = 1, + .feature_abi = 1, + }; + cbm_version_cohort_lease_t *lease = NULL; + cbm_daemon_conflict_t conflict; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + bool setup = endpoint && manager && + cbm_daemon_runtime_process_build_fingerprint( + (uint64_t)getpid(), fingerprint) && + cbm_version_cohort_acquire( + manager, &identity, cbm_now_ms() + 2000U, &lease, + &conflict) == CBM_VERSION_COHORT_OK && + cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &startup) == 1 && startup; + char ready = setup ? 'R' : 'E'; + (void)write(ready_pipe[1], &ready, 1); + bool saw_maintenance = false; + uint64_t maintenance_deadline = cbm_now_ms() + 30000U; + while (setup && cbm_now_ms() < maintenance_deadline) { + cbm_version_cohort_maintenance_presence_t presence = + cbm_version_cohort_maintenance_presence(manager); + if (presence == CBM_VERSION_COHORT_MAINTENANCE_REQUESTED) { + saw_maintenance = true; + break; + } + if (presence == CBM_VERSION_COHORT_MAINTENANCE_UNSAFE || + presence == CBM_VERSION_COHORT_MAINTENANCE_IO) { + break; + } + cbm_usleep(1000); + } + uint64_t bootstrap_stall_deadline = cbm_now_ms() + 3000U; + while (saw_maintenance && cbm_now_ms() < bootstrap_stall_deadline) { + cbm_usleep(1000); + } + if (startup) { + (void)cbm_daemon_ipc_startup_lock_release(&startup); + } + while (lease && + cbm_version_cohort_lease_release(&lease) != + CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + while (manager && + cbm_version_cohort_manager_free(&manager) != + CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + cbm_daemon_ipc_endpoint_free(endpoint); + close(ready_pipe[1]); + _exit(setup && saw_maintenance ? 0 : 1); + } + close(ready_pipe[1]); + char ready = 0; + bool child_ready = child > 0 && read(ready_pipe[0], &ready, 1) == 1 && + ready == 'R'; + close(ready_pipe[0]); + + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + const char *shell = getenv("SHELL"); + char *old_shell = shell ? strdup(shell) : NULL; + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("SHELL", "/bin/zsh", 1); + char cache_dir[512]; + char install_dir[512]; + char target_path[640]; + char activation_log[640]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + snprintf(install_dir, sizeof(install_dir), "%s/custom/bin", tmpdir); + snprintf(target_path, sizeof(target_path), + "%s/codebase-memory-mcp", install_dir); + snprintf(activation_log, sizeof(activation_log), + "%s/logs/activation-events.ndjson", cache_dir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + cbm_cli_set_activation_runtime_parent_for_test(runtime_parent); + char dir_arg[640]; + snprintf(dir_arg, sizeof(dir_arg), "--dir=%s", install_dir); + char *install_argv[] = {"--force", "--skip-config", "--yes", dir_arg}; + int install_rc = child_ready ? cbm_cmd_install(4, install_argv) : -1; + cbm_cli_set_activation_runtime_parent_for_test(NULL); + cbm_set_auto_answer_for_test(0); + + int child_status = 0; + bool child_ok = child > 0 && waitpid(child, &child_status, 0) == child && + WIFEXITED(child_status) && WEXITSTATUS(child_status) == 0; + struct stat target_status; + struct stat log_status; + bool target_exists = stat(target_path, &target_status) == 0; + bool log_private = stat(activation_log, &log_status) == 0 && + (log_status.st_mode & 0077) == 0; + const char *events = read_test_file(activation_log); + const char *requested = events ? strstr(events, "\"phase\":\"requested\"") : NULL; + const char *stopped = events ? strstr(events, "\"phase\":\"daemon_stopped\"") : NULL; + const char *completed = events ? strstr(events, "\"phase\":\"completed\"") : NULL; + bool event_order = requested && stopped && completed && + requested < stopped && stopped < completed && + strstr(events, "\"restart_required\":true") != NULL; + + if (old_shell) { + cbm_setenv("SHELL", old_shell, 1); + } else { + cbm_unsetenv("SHELL"); + } + free(old_shell); + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_TRUE(child_ready); + ASSERT_EQ(install_rc, 0); + ASSERT_TRUE(child_ok); + ASSERT_TRUE(target_exists); + ASSERT_TRUE(log_private); + ASSERT_TRUE(event_order); + PASS(); +} +#endif + +TEST(cli_install_force_quiesces_active_cohort_before_replacing_binary) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-install-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + cbm_setenv("HOME", tmpdir, 1); + char cache_dir[512]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + + char bin_dir[512]; + char bin_target[640]; + snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", tmpdir); + test_mkdirp(bin_dir); +#ifdef _WIN32 + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp.exe", bin_dir); +#else + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp", bin_dir); +#endif + write_test_file(bin_target, "old binary must survive"); + + cli_activation_fake_t fake = { + .participants_active = true, + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + char *argv[] = {"--force"}; + int rc = cbm_cmd_install(1, argv); + cbm_cli_set_activation_ops_for_test(NULL); + + const char *installed = read_test_file(bin_target); + bool preserved = installed && strcmp(installed, "old binary must survive") == 0; + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + ASSERT_EQ(rc, 0); + ASSERT_FALSE(preserved); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.quiesce_count, 1); + ASSERT_EQ(fake.mutation_lease_release_count, 1); + PASS(); +} + +TEST(cli_install_dir_and_skip_config_stage_first_install_safely) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-install-dir-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + const char *shell = getenv("SHELL"); + char *old_shell = shell ? strdup(shell) : NULL; + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("SHELL", "/bin/zsh", 1); + char cache_dir[512]; + char install_dir[512]; + char target_path[640]; + char codex_dir[512]; + char codex_config[640]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + snprintf(install_dir, sizeof(install_dir), "%s/custom/new/bin", tmpdir); + snprintf(target_path, sizeof(target_path), +#ifdef _WIN32 + "%s/codebase-memory-mcp.exe", install_dir); +#else + "%s/codebase-memory-mcp", install_dir); +#endif + snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir); + snprintf(codex_config, sizeof(codex_config), "%s/config.toml", codex_dir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + test_mkdirp(codex_dir); + + cli_activation_fake_t fake = { + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + char *argv[] = {"--force", "--skip-config", "--yes", "--dir", + install_dir}; + int rc = cbm_cmd_install(5, argv); + char equals_arg[640]; + snprintf(equals_arg, sizeof(equals_arg), "--dir=%s", install_dir); + char *dry_argv[] = {"--force", "--skip-config", "--dry-run", + equals_arg}; + int dry_rc = cbm_cmd_install(4, dry_argv); + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + + struct stat target_status; + struct stat config_status; + bool target_exists = stat(target_path, &target_status) == 0; + bool config_absent = stat(codex_config, &config_status) != 0; + if (old_shell) { + cbm_setenv("SHELL", old_shell, 1); + } else { + cbm_unsetenv("SHELL"); + } + free(old_shell); + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_EQ(rc, 0); + ASSERT_EQ(dry_rc, 0); + ASSERT_TRUE(target_exists); + ASSERT_TRUE(config_absent); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.mutation_lease_release_count, 1); + PASS(); +} + +TEST(cli_activation_commands_reject_malformed_and_unknown_flags) { + cli_activation_fake_t fake = { + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + char *missing_dir[] = {"--dir"}; + char *empty_dir[] = {"--dir="}; + char *bad_install[] = {"--skip-config=value"}; + char *bad_update[] = {"--not-an-update-option"}; + char *bad_uninstall[] = {"--not-an-uninstall-option"}; + int missing_rc = cbm_cmd_install(1, missing_dir); + int empty_rc = cbm_cmd_install(1, empty_dir); + int install_rc = cbm_cmd_install(1, bad_install); + int update_rc = cbm_cmd_update(1, bad_update); + int uninstall_rc = cbm_cmd_uninstall(1, bad_uninstall); + cbm_cli_set_activation_ops_for_test(NULL); + + ASSERT_EQ(missing_rc, 1); + ASSERT_EQ(empty_rc, 1); + ASSERT_EQ(install_rc, 1); + ASSERT_EQ(update_rc, 1); + ASSERT_EQ(uninstall_rc, 1); + ASSERT_EQ(fake.mutation_reserve_count, 0); + PASS(); +} + +TEST(cli_install_reset_deletion_waits_for_final_activation_guard) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-install-reset-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + cbm_setenv("HOME", tmpdir, 1); + char cache_dir[512]; + char index_path[640]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + test_mkdirp(cache_dir); + snprintf(index_path, sizeof(index_path), "%s/project.db", cache_dir); + write_test_file(index_path, "index must survive final-guard refusal"); + + char bin_dir[512]; + char bin_target[640]; + snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", tmpdir); + test_mkdirp(bin_dir); +#ifdef _WIN32 + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp.exe", bin_dir); +#else + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp", bin_dir); +#endif + write_test_file(bin_target, "old binary must survive"); + + /* The prompt and candidate staging complete first. If the coordinated + * cohort still cannot drain, neither binary publication nor index reset + * may run. */ + cli_activation_fake_t fake = { + .participants_active = true, + .mutation_reserve_result = 0, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + char *argv[] = {"--force", "--reset-indexes", "--yes"}; + int rc = cbm_cmd_install(3, argv); + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + + const char *index = read_test_file(index_path); + bool index_preserved = + index && strcmp(index, "index must survive final-guard refusal") == 0; + const char *installed = read_test_file(bin_target); + bool binary_preserved = installed && strcmp(installed, "old binary must survive") == 0; + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_EQ(rc, 1); + ASSERT_TRUE(index_preserved); + ASSERT_TRUE(binary_preserved); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.mutation_lease_release_count, 0); + ASSERT_TRUE(fake.diagnostic[0] != '\0'); + PASS(); +} + +TEST(cli_install_config_only_waits_for_cohort_drain) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-install-config-race-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + const char *raw_shell = getenv("SHELL"); + char *old_shell = raw_shell ? strdup(raw_shell) : NULL; + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("SHELL", "/bin/zsh", 1); + char cache_dir[512]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + + char codex_dir[512]; + char codex_config[640]; + char shell_rc[512]; + char bin_dir[512]; + char bin_target[640]; + snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir); + snprintf(codex_config, sizeof(codex_config), "%s/config.toml", codex_dir); + snprintf(shell_rc, sizeof(shell_rc), "%s/.zshrc", tmpdir); + snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", tmpdir); + test_mkdirp(codex_dir); + test_mkdirp(bin_dir); + write_test_file(shell_rc, "# path must survive final-guard refusal\n"); +#ifdef _WIN32 + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp.exe", bin_dir); +#else + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp", bin_dir); +#endif + write_test_file(bin_target, "existing binary selected by the user"); + + /* No binary replacement and no index reset are requested. Config and PATH + * writes still require the same maintenance lease. */ + cli_activation_fake_t fake = { + .participants_active = true, + .mutation_reserve_result = 0, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + cbm_set_auto_answer_for_test(-1); + int rc = cbm_cmd_install(0, NULL); + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + + const char *shell_data = read_test_file(shell_rc); + bool path_preserved = shell_data && strstr(shell_data, "export PATH") == NULL; + struct stat config_status; + bool config_absent = stat(codex_config, &config_status) != 0; + if (old_shell) { + cbm_setenv("SHELL", old_shell, 1); + } else { + cbm_unsetenv("SHELL"); + } + free(old_shell); + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_EQ(rc, 1); + ASSERT_TRUE(path_preserved); + ASSERT_TRUE(config_absent); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.mutation_lease_release_count, 0); + ASSERT_TRUE(fake.diagnostic[0] != '\0'); + PASS(); +} + +TEST(cli_install_config_and_path_finish_before_guard_release) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-install-window-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + const char *raw_shell = getenv("SHELL"); + char *old_shell = raw_shell ? strdup(raw_shell) : NULL; + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("SHELL", "/bin/zsh", 1); + char cache_dir[512]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + + char codex_dir[512]; + char codex_config[640]; + char shell_rc[512]; + char bin_dir[512]; + char bin_target[640]; + snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir); + snprintf(codex_config, sizeof(codex_config), "%s/config.toml", codex_dir); + snprintf(shell_rc, sizeof(shell_rc), "%s/.zshrc", tmpdir); + snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", tmpdir); + test_mkdirp(codex_dir); + test_mkdirp(bin_dir); + write_test_file(shell_rc, "# existing shell config\n"); +#ifdef _WIN32 + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp.exe", bin_dir); +#else + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp", bin_dir); +#endif + write_test_file(bin_target, "existing binary selected by the user"); + + cli_activation_fake_t fake = { + .mutation_reserve_result = 1, + .guarded_path_a = codex_config, + .guarded_text_a = "codebase-memory-mcp", + .guarded_path_b = shell_rc, + .guarded_text_b = "export PATH", + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + cbm_set_auto_answer_for_test(-1); + int rc = cbm_cmd_install(0, NULL); + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + + if (old_shell) { + cbm_setenv("SHELL", old_shell, 1); + } else { + cbm_unsetenv("SHELL"); + } + free(old_shell); + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_EQ(rc, 0); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.mutation_lease_release_count, 1); + ASSERT_TRUE(fake.guarded_files_visible_before_unlock); + PASS(); +} + +/* Regression: config installation is not atomic across every supported agent. + * Once the verified executable has been published, rolling only that binary + * back on one agent-config refusal leaves any successful config writes pointing + * at the wrong (or, for a fresh install, missing) executable. */ +TEST(cli_install_config_failure_keeps_published_binary) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), + "/tmp/cli-install-partial-config-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + char *old_path = save_test_env("PATH"); + char *old_shell = save_test_env("SHELL"); + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("PATH", tmpdir, 1); + cbm_setenv("SHELL", "/bin/zsh", 1); + + char cache_dir[512]; + char openclaw_dir[512]; + char openclaw_config[640]; + char bin_dir[512]; + char bin_target[640]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + snprintf(openclaw_dir, sizeof(openclaw_dir), "%s/.openclaw", tmpdir); + snprintf(openclaw_config, sizeof(openclaw_config), "%s/openclaw.json", + openclaw_dir); + snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", tmpdir); +#ifdef _WIN32 + snprintf(bin_target, sizeof(bin_target), + "%s/codebase-memory-mcp.exe", bin_dir); +#else + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp", + bin_dir); +#endif + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + test_mkdirp(openclaw_dir); + test_mkdirp(bin_dir); + const char *malformed = "{ invalid config\n"; + write_test_file(openclaw_config, malformed); + + cli_activation_fake_t fake = { + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + char *argv[] = {"--force", "--yes"}; + int rc = cbm_cmd_install(2, argv); + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + + struct stat binary_status; + bool binary_published = stat(bin_target, &binary_status) == 0; + char *after = read_test_file_alloc(openclaw_config); + bool malformed_preserved = after && strcmp(after, malformed) == 0; + free(after); + restore_test_env("PATH", old_path); + restore_test_env("SHELL", old_shell); + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_EQ(rc, 1); + ASSERT_TRUE(binary_published); + ASSERT_TRUE(malformed_preserved); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.mutation_lease_release_count, 1); + ASSERT_NOT_NULL(strstr(fake.diagnostic, "executable was kept")); + PASS(); +} + +TEST(cli_update_download_failure_does_not_quiesce_sessions) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-update-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + const char *download = getenv("CBM_DOWNLOAD_URL"); + char *old_download = download ? strdup(download) : NULL; + cbm_setenv("HOME", tmpdir, 1); + cbm_setenv("CBM_DOWNLOAD_URL", "file:///cbm-update-does-not-exist", 1); + char cache_dir[512]; + char index_path[640]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + test_mkdirp(cache_dir); + snprintf(index_path, sizeof(index_path), "%s/project.db", cache_dir); + write_test_file(index_path, "index must survive"); + + cli_activation_fake_t fake = { + .participants_active = true, + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + char *argv[] = {"--force", "--standard", "--yes"}; + int rc = cbm_cmd_update(3, argv); + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + + const char *index = read_test_file(index_path); + bool preserved = index && strcmp(index, "index must survive") == 0; + if (old_download) { + cbm_setenv("CBM_DOWNLOAD_URL", old_download, 1); + } else { + cbm_unsetenv("CBM_DOWNLOAD_URL"); + } + free(old_download); + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + ASSERT_EQ(rc, 1); + ASSERT_TRUE(preserved); + ASSERT_EQ(fake.mutation_reserve_count, 0); + PASS(); +} + +TEST(cli_update_already_current_does_not_quiesce_sessions) { +#ifdef _WIN32 + /* The deterministic fake-curl fixture below is POSIX-only. The guarded + * ordering it exercises is platform-independent. */ + PASS(); +#else + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-update-current-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + const char *raw_path = getenv("PATH"); + const char *raw_download = getenv("CBM_DOWNLOAD_URL"); + char *old_path = raw_path ? strdup(raw_path) : NULL; + char *old_download = raw_download ? strdup(raw_download) : NULL; + cbm_setenv("HOME", tmpdir, 1); + cbm_unsetenv("CBM_DOWNLOAD_URL"); + + char bin_dir[512]; + char fake_curl[640]; + snprintf(bin_dir, sizeof(bin_dir), "%s/bin", tmpdir); + snprintf(fake_curl, sizeof(fake_curl), "%s/curl", bin_dir); + test_mkdirp(bin_dir); + write_test_file( + fake_curl, + "#!/bin/sh\n" + "printf '%s\\n' 'HTTP/1.1 302 Found' " + "'location: https://github.com/DeusData/codebase-memory-mcp/releases/tag/v0.0.0'\n"); + bool fixture_ready = chmod(fake_curl, 0700) == 0; + cbm_setenv("PATH", bin_dir, 1); + + cli_activation_fake_t fake = { + .participants_active = true, + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + char *argv[] = {"--standard"}; + int rc = fixture_ready ? cbm_cmd_update(1, argv) : -1; + cbm_cli_set_activation_ops_for_test(NULL); + + if (old_path) { + cbm_setenv("PATH", old_path, 1); + } else { + cbm_unsetenv("PATH"); + } + if (old_download) { + cbm_setenv("CBM_DOWNLOAD_URL", old_download, 1); + } else { + cbm_unsetenv("CBM_DOWNLOAD_URL"); + } + free(old_path); + free(old_download); + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_TRUE(fixture_ready); + ASSERT_EQ(rc, 0); + ASSERT_EQ(fake.mutation_reserve_count, 0); + PASS(); +#endif +} + +TEST(cli_update_agent_configs_finish_before_guard_release) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-update-window-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + const char *download_env = getenv("CBM_DOWNLOAD_URL"); + char *old_download = download_env ? strdup(download_env) : NULL; + cbm_setenv("HOME", tmpdir, 1); + char cache_dir[512]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + + char codex_dir[512]; + char codex_config[640]; + char release_dir[512]; + char archive_path[768]; + char checksum_path[640]; + snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir); + snprintf(codex_config, sizeof(codex_config), "%s/config.toml", codex_dir); + snprintf(release_dir, sizeof(release_dir), "%s/release", tmpdir); + test_mkdirp(codex_dir); + test_mkdirp(release_dir); + +#if defined(__APPLE__) +#if defined(__aarch64__) || defined(__arm64__) + const char *asset_name = "codebase-memory-mcp-darwin-arm64.tar.gz"; +#else + const char *asset_name = "codebase-memory-mcp-darwin-amd64.tar.gz"; +#endif +#elif defined(_WIN32) +#if defined(_M_ARM64) + const char *asset_name = "codebase-memory-mcp-windows-arm64.zip"; +#else + const char *asset_name = "codebase-memory-mcp-windows-amd64.zip"; +#endif +#else +#if defined(__aarch64__) + const char *asset_name = "codebase-memory-mcp-linux-arm64-portable.tar.gz"; +#else + const char *asset_name = "codebase-memory-mcp-linux-amd64-portable.tar.gz"; +#endif +#endif + +#ifdef _WIN32 + /* The existing ZIP fixture helper is intentionally exercised elsewhere; + * this activation-window regression uses the tar update path. */ + cli_activation_restore_env(old_home, old_cache); + if (old_download) { + cbm_setenv("CBM_DOWNLOAD_URL", old_download, 1); + } else { + cbm_unsetenv("CBM_DOWNLOAD_URL"); + } + free(old_download); + test_rmdir_r(tmpdir); + PASS(); +#else + const char *native_fixture = +#ifdef __APPLE__ + "/usr/bin/true"; +#else + "/bin/true"; +#endif + FILE *native_file = fopen(native_fixture, "rb"); + ASSERT_NOT_NULL(native_file); + ASSERT_EQ(fseek(native_file, 0, SEEK_END), 0); + long native_size = ftell(native_file); + ASSERT_GT(native_size, 0); + ASSERT_LTE(native_size, INT_MAX); + ASSERT_EQ(fseek(native_file, 0, SEEK_SET), 0); + unsigned char *replacement = malloc((size_t)native_size); + ASSERT_NOT_NULL(replacement); + ASSERT_EQ(fread(replacement, 1, (size_t)native_size, native_file), + (size_t)native_size); + ASSERT_EQ(fclose(native_file), 0); + int archive_len = 0; + unsigned char *archive = + create_test_targz("codebase-memory-mcp", replacement, + (int)native_size, &archive_len); + free(replacement); + ASSERT_NOT_NULL(archive); + snprintf(archive_path, sizeof(archive_path), "%s/%s", release_dir, asset_name); + FILE *archive_file = fopen(archive_path, "wb"); + ASSERT_NOT_NULL(archive_file); + ASSERT_EQ(fwrite(archive, 1, (size_t)archive_len, archive_file), + (size_t)archive_len); + ASSERT_EQ(fclose(archive_file), 0); + free(archive); + + char digest[65]; + ASSERT_EQ(cbm_cli_sha256_file(archive_path, digest, sizeof(digest)), 0); + snprintf(checksum_path, sizeof(checksum_path), "%s/checksums.txt", release_dir); + FILE *checksum_file = fopen(checksum_path, "w"); + ASSERT_NOT_NULL(checksum_file); + ASSERT_TRUE(fprintf(checksum_file, "%s %s\n", digest, asset_name) > 0); + ASSERT_EQ(fclose(checksum_file), 0); + + char download_url[640]; + snprintf(download_url, sizeof(download_url), "file://%s", release_dir); + cbm_setenv("CBM_DOWNLOAD_URL", download_url, 1); + + cli_activation_fake_t fake = { + .mutation_reserve_result = 1, + .guarded_path_a = codex_config, + .guarded_text_a = "codebase-memory-mcp", + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + char *argv[] = {"--force", "--standard"}; + int rc = cbm_cmd_update(2, argv); + cbm_cli_set_activation_ops_for_test(NULL); + + /* Re-run against a known old target while one independently detected agent + * refuses its config. The new executable must remain published because + * earlier agent configs may already have been refreshed for it. */ + char openclaw_dir[512]; + char openclaw_config[640]; + char bin_target[640]; + snprintf(openclaw_dir, sizeof(openclaw_dir), "%s/.openclaw", tmpdir); + snprintf(openclaw_config, sizeof(openclaw_config), "%s/openclaw.json", + openclaw_dir); + snprintf(bin_target, sizeof(bin_target), + "%s/.local/bin/codebase-memory-mcp", tmpdir); + test_mkdirp(openclaw_dir); + write_test_file(openclaw_config, "{ invalid config\n"); + static const char old_binary[] = "old binary before partial update"; + write_test_file(bin_target, old_binary); + + cli_activation_fake_t config_failure = { + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t failure_ops = + cli_activation_fake_ops(&config_failure); + cbm_cli_set_activation_ops_for_test(&failure_ops); + int config_failure_rc = cbm_cmd_update(2, argv); + cbm_cli_set_activation_ops_for_test(NULL); + struct stat updated_status; + bool replacement_kept = stat(bin_target, &updated_status) == 0 && + updated_status.st_size != + (off_t)(sizeof(old_binary) - 1U); + + if (old_download) { + cbm_setenv("CBM_DOWNLOAD_URL", old_download, 1); + } else { + cbm_unsetenv("CBM_DOWNLOAD_URL"); + } + free(old_download); + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_EQ(rc, 0); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.mutation_lease_release_count, 1); + ASSERT_TRUE(fake.guarded_files_visible_before_unlock); + ASSERT_EQ(config_failure_rc, 1); + ASSERT_TRUE(replacement_kept); + ASSERT_EQ(config_failure.mutation_reserve_count, 1); + ASSERT_EQ(config_failure.mutation_lease_release_count, 1); + ASSERT_NOT_NULL( + strstr(config_failure.diagnostic, "executable was kept")); + PASS(); +#endif +} + +TEST(cli_uninstall_quiesces_active_cohort_before_removing_binary_and_index) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-uninstall-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + cbm_setenv("HOME", tmpdir, 1); + + char cache_dir[512]; + char index_path[640]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + test_mkdirp(cache_dir); + snprintf(index_path, sizeof(index_path), "%s/project.db", cache_dir); + write_test_file(index_path, "index must survive active-daemon refusal"); + + char bin_dir[512]; + char bin_target[640]; + snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", tmpdir); + test_mkdirp(bin_dir); +#ifdef _WIN32 + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp.exe", bin_dir); +#else + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp", bin_dir); +#endif + write_test_file(bin_target, "binary must survive active-daemon refusal"); + + cli_activation_fake_t fake = { + .participants_active = true, + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + char *argv[] = {"--yes"}; + int rc = cbm_cmd_uninstall(1, argv); + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + + const char *index = read_test_file(index_path); + bool index_preserved = + index && strcmp(index, "index must survive active-daemon refusal") == 0; + const char *installed = read_test_file(bin_target); + bool binary_preserved = + installed && strcmp(installed, "binary must survive active-daemon refusal") == 0; + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_EQ(rc, 0); + ASSERT_FALSE(index_preserved); + ASSERT_FALSE(binary_preserved); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.quiesce_count, 1); + ASSERT_EQ(fake.mutation_lease_release_count, 1); + PASS(); +} + +TEST(cli_uninstall_preserves_binary_and_index_when_cohort_does_not_drain) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-uninstall-race-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + cbm_setenv("HOME", tmpdir, 1); + + char cache_dir[512]; + char index_path[640]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + test_mkdirp(cache_dir); + snprintf(index_path, sizeof(index_path), "%s/project.db", cache_dir); + write_test_file(index_path, "index must survive uninstall race"); + + char bin_dir[512]; + char bin_target[640]; + snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", tmpdir); + test_mkdirp(bin_dir); +#ifdef _WIN32 + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp.exe", bin_dir); +#else + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp", bin_dir); +#endif + write_test_file(bin_target, "binary must survive uninstall race"); + + /* Prompts and removal staging happen first. A failed cohort drain must + * preserve both the executable and optional index cleanup. */ + cli_activation_fake_t fake = { + .participants_active = true, + .mutation_reserve_result = 0, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + char *argv[] = {"--yes"}; + int rc = cbm_cmd_uninstall(1, argv); + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + + const char *index = read_test_file(index_path); + bool index_preserved = + index && strcmp(index, "index must survive uninstall race") == 0; + const char *installed = read_test_file(bin_target); + bool binary_preserved = + installed && strcmp(installed, "binary must survive uninstall race") == 0; + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + + ASSERT_EQ(rc, 1); + ASSERT_TRUE(index_preserved); + ASSERT_TRUE(binary_preserved); + ASSERT_EQ(fake.mutation_reserve_count, 1); + ASSERT_EQ(fake.mutation_lease_release_count, 0); + ASSERT_TRUE(fake.diagnostic[0] != '\0'); + PASS(); +} + +TEST(cli_activation_guard_is_bypassed_for_dry_run_and_plan) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-stateless-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + cbm_setenv("HOME", tmpdir, 1); + char cache_dir[512]; + snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); + cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + + cli_activation_fake_t fake = { + .participants_active = true, + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + char *install_dry[] = {"--force", "--dry-run"}; + char *install_plan[] = {"--force", "--plan"}; + char *update_dry[] = {"--force", "--dry-run", "--standard"}; + char *uninstall_dry[] = {"--dry-run", "--yes"}; + int install_dry_rc = cbm_cmd_install(2, install_dry); + int install_plan_rc = cbm_cmd_install(2, install_plan); + int update_dry_rc = cbm_cmd_update(3, update_dry); + int uninstall_dry_rc = cbm_cmd_uninstall(2, uninstall_dry); + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); + + cli_activation_restore_env(old_home, old_cache); + test_rmdir_r(tmpdir); + ASSERT_EQ(install_dry_rc, 0); + ASSERT_EQ(install_plan_rc, 0); + ASSERT_EQ(update_dry_rc, 0); + ASSERT_EQ(uninstall_dry_rc, 0); + ASSERT_EQ(fake.mutation_reserve_count, 0); + PASS(); +} + /* ═══════════════════════════════════════════════════════════════════ * Version comparison tests (port of selfupdate_test.go) * ═══════════════════════════════════════════════════════════════════ */ @@ -2133,21 +3541,45 @@ TEST(cli_agent_uninstall_reports_safe_editor_refusal) { const char *malformed = "{ invalid config\n"; write_test_file(config_path, malformed); + char bin_dir[512]; + char bin_path[640]; + snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", tmpdir); + test_mkdirp(bin_dir); +#ifdef _WIN32 + snprintf(bin_path, sizeof(bin_path), "%s/codebase-memory-mcp.exe", + bin_dir); +#else + snprintf(bin_path, sizeof(bin_path), "%s/codebase-memory-mcp", bin_dir); +#endif + write_test_file(bin_path, "installed binary must remain live\n"); + char *saved_home = save_test_env("HOME"); char *saved_path = save_test_env("PATH"); cbm_setenv("HOME", tmpdir, 1); cbm_setenv("PATH", tmpdir, 1); - char *argv[] = {"uninstall", "--yes"}; - int rc = cbm_cmd_uninstall(2, argv); + cli_activation_fake_t fake = { + .mutation_reserve_result = 1, + }; + cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); + cbm_cli_set_activation_ops_for_test(&ops); + char *argv[] = {"--yes"}; + int rc = cbm_cmd_uninstall(1, argv); + cbm_cli_set_activation_ops_for_test(NULL); + cbm_set_auto_answer_for_test(0); char *after = read_test_file_alloc(config_path); bool preserved = after && strcmp(after, malformed) == 0; + struct stat binary_status; + bool binary_preserved = stat(bin_path, &binary_status) == 0; free(after); restore_test_env("HOME", saved_home); restore_test_env("PATH", saved_path); test_rmdir_r(tmpdir); - if (rc == 0 || !preserved) - FAIL("agent uninstall must return failure when a safe editor refuses a config"); + if (rc == 0 || !preserved || !binary_preserved || + fake.mutation_reserve_count != 1 || + fake.mutation_lease_release_count != 1 || + !strstr(fake.diagnostic, "executable was kept")) + FAIL("agent uninstall refusal must fail before removing the live binary"); PASS(); } @@ -9311,6 +10743,103 @@ TEST(cli_config_get_set) { PASS(); } +typedef struct { + cbm_config_t *config; + const char *key; + const char *expected; + atomic_int *phase; + bool first_reader; + bool completed_handoff; + bool value_preserved; + uintptr_t storage_address; +} cli_config_read_thread_t; + +static void *cli_config_read_with_handoff(void *opaque) { + cli_config_read_thread_t *read = opaque; + if (read->first_reader) { + const char *value = cbm_config_get(read->config, read->key, NULL); + read->storage_address = (uintptr_t)value; + atomic_store_explicit(read->phase, 1, memory_order_release); + for (int spins = 0; spins < 5000 && + atomic_load_explicit(read->phase, memory_order_acquire) < 2; + spins++) { + cbm_usleep(1000); + } + read->completed_handoff = + atomic_load_explicit(read->phase, memory_order_acquire) == 2; + read->value_preserved = + read->completed_handoff && value && strcmp(value, read->expected) == 0; + return NULL; + } + + for (int spins = 0; spins < 5000 && + atomic_load_explicit(read->phase, memory_order_acquire) < 1; + spins++) { + cbm_usleep(1000); + } + read->completed_handoff = + atomic_load_explicit(read->phase, memory_order_acquire) == 1; + const char *value = cbm_config_get(read->config, read->key, NULL); + read->storage_address = (uintptr_t)value; + read->value_preserved = + read->completed_handoff && value && strcmp(value, read->expected) == 0; + atomic_store_explicit(read->phase, 2, memory_order_release); + return NULL; +} + +/* One daemon-owned config store is shared across concurrent session threads. + * A later read in one thread must not overwrite a borrowed result that another + * live thread is still consuming. */ +TEST(cli_config_get_result_storage_is_per_thread) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-cfg-thread-XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("cbm_mkdtemp failed"); + } + + cbm_config_t *cfg = cbm_config_open(tmpdir); + ASSERT_NOT_NULL(cfg); + ASSERT_EQ(cbm_config_set(cfg, "first", "alpha"), 0); + ASSERT_EQ(cbm_config_set(cfg, "second", "beta"), 0); + + atomic_int phase; + atomic_init(&phase, 0); + cli_config_read_thread_t reads[2] = { + {.config = cfg, + .key = "first", + .expected = "alpha", + .phase = &phase, + .first_reader = true}, + {.config = cfg, .key = "second", .expected = "beta", .phase = &phase}, + }; + cbm_thread_t threads[2]; + bool started0 = + cbm_thread_create(&threads[0], 0, cli_config_read_with_handoff, &reads[0]) == 0; + bool started1 = + cbm_thread_create(&threads[1], 0, cli_config_read_with_handoff, &reads[1]) == 0; + if (started0) { + (void)cbm_thread_join(&threads[0]); + } + if (started1) { + (void)cbm_thread_join(&threads[1]); + } + + bool separate_storage = reads[0].storage_address != 0 && + reads[1].storage_address != 0 && + reads[0].storage_address != reads[1].storage_address; + cbm_config_close(cfg); + test_rmdir_r(tmpdir); + + ASSERT_TRUE(started0); + ASSERT_TRUE(started1); + ASSERT_TRUE(reads[0].completed_handoff); + ASSERT_TRUE(reads[1].completed_handoff); + ASSERT_TRUE(separate_storage); + ASSERT_TRUE(reads[0].value_preserved); + ASSERT_TRUE(reads[1].value_preserved); + PASS(); +} + TEST(cli_config_get_bool) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-cfg-XXXXXX"); @@ -9599,8 +11128,6 @@ TEST(cli_print_tool_help_issue680) { * algorithm, from a bad macro rename inside the shell string) makes every * digest fail, and the caller then falls through and installs unverified. * Guard the digest itself against a known vector. */ -extern int cbm_cli_sha256_file(const char *path, char *out, size_t out_size); - /* Hash `content` (len bytes) via a temp file and compare to expected hex. * Returns 1 on match, 0 otherwise. */ static int sha256_vector_ok(const void *content, size_t len, const char *expected) { @@ -9625,6 +11152,119 @@ static int sha256_vector_ok(const void *content, size_t len, const char *expecte return rc == 0 && strcmp(digest, expected) == 0; } +static bool cli_checksum_manifest_path(char *path, size_t path_size) { + int written = snprintf(path, path_size, "%s/cbm-checksum-XXXXXX", + cbm_tmpdir()); + if (written <= 0 || (size_t)written >= path_size) { + return false; + } + int descriptor = cbm_mkstemp(path); + if (descriptor < 0) { + return false; + } + FILE *file = fdopen(descriptor, "wb"); + if (!file) { +#ifdef _WIN32 + (void)_close(descriptor); +#else + (void)close(descriptor); +#endif + (void)cbm_unlink(path); + return false; + } + return fclose(file) == 0; +} + +TEST(cli_checksum_manifest_requires_exact_filename_and_accepts_star) { + static const char lower_digest[] = + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + static const char upper_digest[] = + "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"; + static const char other_digest[] = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + const char *artifact = "codebase-memory-mcp-linux-amd64-portable.tar.gz"; + char path[512]; + ASSERT_TRUE(cli_checksum_manifest_path(path, sizeof(path))); + char manifest[1024]; + ASSERT_TRUE(snprintf(manifest, sizeof(manifest), + "%s prefix-%s\n%s *%s\n%s %s\n", + other_digest, artifact, upper_digest, artifact, + lower_digest, artifact) > 0); + ASSERT_EQ(write_test_file(path, manifest), 0); + + char parsed[65] = {0}; + int status = cbm_cli_checksum_manifest_digest( + path, artifact, parsed, sizeof(parsed)); + (void)cbm_unlink(path); + + ASSERT_EQ(status, 0); + ASSERT_STR_EQ(parsed, lower_digest); + PASS(); +} + +TEST(cli_checksum_manifest_rejects_invalid_missing_and_conflicting_digest) { + static const char digest_a[] = + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + static const char digest_b[] = + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + static const char invalid_digest[] = + "za7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + const char *artifact = "codebase-memory-mcp-darwin-arm64.tar.gz"; + char path[512]; + ASSERT_TRUE(cli_checksum_manifest_path(path, sizeof(path))); + char manifest[1024]; + char parsed[65] = {0}; + + ASSERT_TRUE(snprintf(manifest, sizeof(manifest), "%s prefix-%s\n", + digest_a, artifact) > 0); + ASSERT_EQ(write_test_file(path, manifest), 0); + ASSERT_NEQ(cbm_cli_checksum_manifest_digest( + path, artifact, parsed, sizeof(parsed)), + 0); + + ASSERT_TRUE(snprintf(manifest, sizeof(manifest), "%s %s\n", + invalid_digest, artifact) > 0); + ASSERT_EQ(write_test_file(path, manifest), 0); + ASSERT_NEQ(cbm_cli_checksum_manifest_digest( + path, artifact, parsed, sizeof(parsed)), + 0); + + ASSERT_TRUE(snprintf(manifest, sizeof(manifest), "%s %s\n%s *%s\n", + digest_a, artifact, digest_b, artifact) > 0); + ASSERT_EQ(write_test_file(path, manifest), 0); + ASSERT_NEQ(cbm_cli_checksum_manifest_digest( + path, artifact, parsed, sizeof(parsed)), + 0); + (void)cbm_unlink(path); + PASS(); +} + +TEST(cli_checksum_manifest_rejects_oversized_input) { + static const char digest[] = + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + const char *artifact = "codebase-memory-mcp-windows-amd64.zip"; + char path[512]; + ASSERT_TRUE(cli_checksum_manifest_path(path, sizeof(path))); + FILE *manifest = fopen(path, "wb"); + ASSERT_NOT_NULL(manifest); + ASSERT_TRUE(fprintf(manifest, "%s %s\n", digest, artifact) > 0); + unsigned char padding[1024]; + memset(padding, 'x', sizeof(padding)); + for (int i = 0; i < 65; i++) { + ASSERT_EQ(fwrite(padding, 1, sizeof(padding), manifest), + sizeof(padding)); + } + ASSERT_EQ(fclose(manifest), 0); + + char parsed[65] = {0}; + int status = cbm_cli_checksum_manifest_digest( + path, artifact, parsed, sizeof(parsed)); + (void)cbm_unlink(path); + + ASSERT_NEQ(status, 0); + PASS(); +} + /* NIST FIPS 180-4 SHA-256 test vectors: empty input, a single block ("abc"), * and a 56-byte input that forces the length padding into a second block. */ TEST(cli_sha256_file_matches_known_vector) { @@ -9643,7 +11283,38 @@ TEST(cli_sha256_file_matches_known_vector) { * ═══════════════════════════════════════════════════════════════════ */ SUITE(cli) { + RUN_TEST(cli_progress_visibility_policy); + RUN_TEST(cli_raw_mcp_result_preserves_tool_error_status); + RUN_TEST(cli_progress_sink_accepts_worker_json_logs); + RUN_TEST(cli_progress_sink_serializes_concurrent_callbacks); RUN_TEST(cli_sha256_file_matches_known_vector); + RUN_TEST(cli_checksum_manifest_requires_exact_filename_and_accepts_star); + RUN_TEST(cli_checksum_manifest_rejects_invalid_missing_and_conflicting_digest); + RUN_TEST(cli_checksum_manifest_rejects_oversized_input); + /* Mandatory daemon activation safety */ + RUN_TEST(cli_activation_quiesces_active_cohort_before_mutation); + RUN_TEST(cli_activation_refuses_when_cohort_does_not_drain); + RUN_TEST(cli_activation_refuses_unsafe_cohort_reservation); + RUN_TEST(cli_activation_releases_maintenance_lease_after_success); + RUN_TEST(cli_activation_releases_maintenance_lease_when_mutation_fails); +#ifndef _WIN32 + RUN_TEST(cli_activation_cleanup_failure_fail_stops_before_lease_release); + RUN_TEST(cli_activation_quiesce_does_not_wait_on_bootstrap_startup); +#endif + RUN_TEST(cli_install_force_quiesces_active_cohort_before_replacing_binary); + RUN_TEST(cli_install_dir_and_skip_config_stage_first_install_safely); + RUN_TEST(cli_activation_commands_reject_malformed_and_unknown_flags); + RUN_TEST(cli_install_reset_deletion_waits_for_final_activation_guard); + RUN_TEST(cli_install_config_only_waits_for_cohort_drain); + RUN_TEST(cli_install_config_and_path_finish_before_guard_release); + RUN_TEST(cli_install_config_failure_keeps_published_binary); + RUN_TEST(cli_update_download_failure_does_not_quiesce_sessions); + RUN_TEST(cli_update_already_current_does_not_quiesce_sessions); + RUN_TEST(cli_update_agent_configs_finish_before_guard_release); + RUN_TEST(cli_uninstall_quiesces_active_cohort_before_removing_binary_and_index); + RUN_TEST(cli_uninstall_preserves_binary_and_index_when_cohort_does_not_drain); + RUN_TEST(cli_activation_guard_is_bypassed_for_dry_run_and_plan); + /* Version (2 tests — selfupdate_test.go) */ RUN_TEST(cli_compare_versions); RUN_TEST(cli_version_get_set); @@ -9915,9 +11586,10 @@ SUITE(cli) { /* Skill directive descriptions (1 test — group E) */ RUN_TEST(cli_skill_descriptions_directive); - /* Config store (6 tests — group F) */ + /* Config store (7 tests — group F) */ RUN_TEST(cli_config_open_close); RUN_TEST(cli_config_get_set); + RUN_TEST(cli_config_get_result_storage_is_per_thread); RUN_TEST(cli_config_get_bool); RUN_TEST(cli_config_get_int); RUN_TEST(cli_config_delete); diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c new file mode 100644 index 000000000..1150c2a73 --- /dev/null +++ b/tests/test_cross_repo.c @@ -0,0 +1,548 @@ +/* + * test_cross_repo.c — Input, work-bound, and write-failure guards for the + * cross-repository matching pass. + */ +#include "test_framework.h" +#include "test_helpers.h" + +#include "foundation/compat.h" +#include "foundation/compat_thread.h" +#include "pipeline/pass_cross_repo.h" + +#include +#include +#include +#include +#include +#if !defined(_WIN32) +#include +#endif + +typedef struct { + char cache[256]; + char *saved_cache; +} cross_repo_fixture_t; + +static bool cross_repo_fixture_begin(cross_repo_fixture_t *fixture) { + memset(fixture, 0, sizeof(*fixture)); + const char *saved = getenv("CBM_CACHE_DIR"); + if (saved) { + fixture->saved_cache = strdup(saved); + if (!fixture->saved_cache) { + return false; + } + } + snprintf(fixture->cache, sizeof(fixture->cache), "/tmp/cbm-cross-hardening-XXXXXX"); + return cbm_mkdtemp(fixture->cache) != NULL && + cbm_setenv("CBM_CACHE_DIR", fixture->cache, 1) == 0; +} + +static void cross_repo_fixture_end(cross_repo_fixture_t *fixture) { + if (fixture->saved_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", fixture->saved_cache, 1); + } else { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + if (fixture->cache[0]) { + th_rmtree(fixture->cache); + } + free(fixture->saved_cache); + memset(fixture, 0, sizeof(*fixture)); +} + +static bool cross_repo_project_path(const cross_repo_fixture_t *fixture, const char *project, + char *out, size_t out_size) { + int written = snprintf(out, out_size, "%s/%s.db", fixture->cache, project); + return written > 0 && (size_t)written < out_size; +} + +static bool cross_repo_create_project(const cross_repo_fixture_t *fixture, const char *project) { + char path[512]; + if (!cross_repo_project_path(fixture, project, path, sizeof(path))) { + return false; + } + cbm_store_t *store = cbm_store_open_path(path); + if (!store) { + return false; + } + bool ok = cbm_store_upsert_project(store, project, fixture->cache) == CBM_STORE_OK; + cbm_store_close(store); + return ok; +} + +/* Seed one HTTP_CALLS/HANDLES pair into two exact project stores. The suffix + * keeps node QNs unique when a source is linked to more than one target. */ +static bool cross_repo_seed_http_pair(const cross_repo_fixture_t *fixture, + const char *source_project, const char *target_project, + const char *route_path, const char *suffix) { + char source_path[512]; + char target_path[512]; + if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path)) || + !cross_repo_project_path(fixture, target_project, target_path, sizeof(target_path))) { + return false; + } + cbm_store_t *source = cbm_store_open_path(source_path); + cbm_store_t *target = cbm_store_open_path(target_path); + if (!source || !target) { + cbm_store_close(source); + cbm_store_close(target); + return false; + } + + bool ok = cbm_store_upsert_project(source, source_project, fixture->cache) == CBM_STORE_OK && + cbm_store_upsert_project(target, target_project, fixture->cache) == CBM_STORE_OK; + char caller_qn[256]; + char local_route_qn[256]; + char target_route_qn[256]; + char handler_qn[256]; + char route_name[128]; + char edge_props[256]; + snprintf(caller_qn, sizeof(caller_qn), "%s.call.%s", source_project, suffix); + snprintf(local_route_qn, sizeof(local_route_qn), "%s.local-route.%s", source_project, suffix); + snprintf(target_route_qn, sizeof(target_route_qn), "__route__GET__%s", route_path); + snprintf(handler_qn, sizeof(handler_qn), "%s.handle.%s", target_project, suffix); + snprintf(route_name, sizeof(route_name), "GET %s", route_path); + snprintf(edge_props, sizeof(edge_props), "{\"url_path\":\"%s\",\"method\":\"GET\"}", + route_path); + + cbm_node_t caller = {.project = source_project, + .label = "Function", + .name = "call_remote", + .qualified_name = caller_qn, + .file_path = "client.c"}; + cbm_node_t local_route = {.project = source_project, + .label = "Route", + .name = route_name, + .qualified_name = local_route_qn, + .file_path = "client.c"}; + int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0; + int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0; + cbm_edge_t http_call = {.project = source_project, + .source_id = caller_id, + .target_id = local_route_id, + .type = "HTTP_CALLS", + .properties_json = edge_props}; + ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0; + + cbm_node_t target_route = {.project = target_project, + .label = "Route", + .name = route_name, + .qualified_name = target_route_qn, + .file_path = "server.c"}; + cbm_node_t handler = {.project = target_project, + .label = "Function", + .name = "handle_remote", + .qualified_name = handler_qn, + .file_path = "server.c"}; + int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0; + int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0; + cbm_edge_t handles = {.project = target_project, + .source_id = handler_id, + .target_id = target_route_id, + .type = "HANDLES"}; + ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0; + + cbm_store_close(source); + cbm_store_close(target); + return ok; +} + +static bool cross_repo_exec(const cross_repo_fixture_t *fixture, const char *project, + const char *sql) { + char path[512]; + if (!cross_repo_project_path(fixture, project, path, sizeof(path))) { + return false; + } + cbm_store_t *store = cbm_store_open_path_existing(path); + if (!store) { + return false; + } + char *error = NULL; + int rc = sqlite3_exec(cbm_store_get_db(store), sql, NULL, NULL, &error); + sqlite3_free(error); + cbm_store_close(store); + return rc == SQLITE_OK; +} + +static int cross_repo_count_edges(const cross_repo_fixture_t *fixture, const char *project, + const char *edge_type) { + char path[512]; + if (!cross_repo_project_path(fixture, project, path, sizeof(path))) { + return -1; + } + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) { + return -1; + } + int count = cbm_store_count_edges_by_type(store, project, edge_type); + cbm_store_close(store); + return count; +} + +TEST(cross_repo_null_target_fails_without_dereference) { + cross_repo_fixture_t fixture; + if (!cross_repo_fixture_begin(&fixture) || + !cross_repo_create_project(&fixture, "null-target-source")) { + cross_repo_fixture_end(&fixture); + FAIL("failed to create isolated source project"); + } + + bool rejected = false; +#if defined(_WIN32) + const char *targets[] = {NULL}; + cbm_cross_repo_result_t result = cbm_cross_repo_match("null-target-source", targets, 1); + rejected = result.failed; +#else + fflush(NULL); + pid_t child = fork(); + if (child == 0) { + const char *targets[] = {NULL}; + cbm_cross_repo_result_t result = cbm_cross_repo_match("null-target-source", targets, 1); + _exit(result.failed ? 0 : 2); + } + int status = 0; + rejected = child > 0 && waitpid(child, &status, 0) == child && WIFEXITED(status) && + WEXITSTATUS(status) == 0; +#endif + + cross_repo_fixture_end(&fixture); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "wildcard-source", "orders_config_service", + "/config-orders", "a") && + cross_repo_seed_http_pair(&fixture, "wildcard-source", "orders_cross_repo_service", + "/cross-orders", "b") && + cross_repo_seed_http_pair(&fixture, "wildcard-source", "orders-wal-service", + "/wal-orders", "c") && + cross_repo_seed_http_pair(&fixture, "wildcard-source", "orders-shm-service", + "/shm-orders", "d"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed wildcard fixture"); + } + + const char *targets[] = {"*"}; + cbm_cross_repo_result_t result = cbm_cross_repo_match("wildcard-source", targets, 1); + cross_repo_fixture_end(&fixture); + + ASSERT_FALSE(result.failed); + ASSERT_EQ(result.projects_scanned, 4); + ASSERT_EQ(result.http_edges, 4); + PASS(); +} + +static bool cross_repo_seed_bounded_scan(const cross_repo_fixture_t *fixture, + const char *source_project, const char *target_project) { + enum { TEST_SCAN_ROWS = 4097 }; + char source_path[512]; + char target_path[512]; + if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path)) || + !cross_repo_project_path(fixture, target_project, target_path, sizeof(target_path))) { + return false; + } + cbm_store_t *source = cbm_store_open_path(source_path); + cbm_store_t *target = cbm_store_open_path(target_path); + if (!source || !target) { + cbm_store_close(source); + cbm_store_close(target); + return false; + } + bool ok = cbm_store_upsert_project(source, source_project, fixture->cache) == CBM_STORE_OK && + cbm_store_upsert_project(target, target_project, fixture->cache) == CBM_STORE_OK; + cbm_node_t caller = {.project = source_project, + .label = "Function", + .name = "bounded_caller", + .qualified_name = "bounded.source.caller", + .file_path = "client.c"}; + int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0; + ok = ok && caller_id > 0 && + sqlite3_exec(cbm_store_get_db(source), "BEGIN IMMEDIATE", NULL, NULL, NULL) == SQLITE_OK; + for (int i = 0; ok && i < TEST_SCAN_ROWS; i++) { + char name[64]; + char qn[96]; + snprintf(name, sizeof(name), "local_route_%d", i); + snprintf(qn, sizeof(qn), "bounded.source.route.%d", i); + cbm_node_t local_route = {.project = source_project, + .label = "Route", + .name = name, + .qualified_name = qn, + .file_path = "client.c"}; + int64_t route_id = cbm_store_upsert_node(source, &local_route); + cbm_edge_t edge = { + .project = source_project, + .source_id = caller_id, + .target_id = route_id, + .type = "HTTP_CALLS", + .properties_json = i == TEST_SCAN_ROWS - 1 + ? "{\"url_path\":\"/after-bound\",\"method\":\"GET\"}" + : "{}", + }; + ok = route_id > 0 && cbm_store_insert_edge(source, &edge) > 0; + } + if (ok) { + ok = sqlite3_exec(cbm_store_get_db(source), "COMMIT", NULL, NULL, NULL) == SQLITE_OK; + } else { + (void)sqlite3_exec(cbm_store_get_db(source), "ROLLBACK", NULL, NULL, NULL); + } + + cbm_node_t target_route = {.project = target_project, + .label = "Route", + .name = "GET /after-bound", + .qualified_name = "__route__GET__/after-bound", + .file_path = "server.c"}; + cbm_node_t handler = {.project = target_project, + .label = "Function", + .name = "bounded_handler", + .qualified_name = "bounded.target.handler", + .file_path = "server.c"}; + int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0; + int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0; + cbm_edge_t handles = {.project = target_project, + .source_id = handler_id, + .target_id = target_route_id, + .type = "HANDLES"}; + ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0; + cbm_store_close(source); + cbm_store_close(target); + return ok; +} + +TEST(cross_repo_scan_bound_counts_examined_rows_not_matches) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + cross_repo_seed_bounded_scan(&fixture, "bounded-source", "bounded-target"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed bounded scan fixture"); + } + const char *target = "bounded-target"; + cbm_cross_repo_result_t result = cbm_cross_repo_match("bounded-source", &target, 1); + cross_repo_fixture_end(&fixture); + + ASSERT_FALSE(result.failed); + ASSERT_EQ(result.projects_scanned, 1); + ASSERT_EQ(result.http_edges, 0); + PASS(); +} + +TEST(cross_repo_propagates_delete_failure) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "delete-source", "delete-target", + "/delete-failure", "delete"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed delete failure fixture"); + } + const char *target = "delete-target"; + cbm_cross_repo_result_t initial = cbm_cross_repo_match("delete-source", &target, 1); + bool trigger_created = + !initial.failed && initial.http_edges == 1 && + cross_repo_exec(&fixture, "delete-source", + "CREATE TRIGGER fail_cross_delete BEFORE DELETE ON edges " + "WHEN OLD.type = 'CROSS_HTTP_CALLS' BEGIN " + "SELECT RAISE(ABORT, 'forced cross delete failure'); END;"); + cbm_cross_repo_result_t failed = {0}; + if (trigger_created) { + failed = cbm_cross_repo_match("delete-source", &target, 1); + } + cross_repo_fixture_end(&fixture); + + ASSERT_TRUE(trigger_created); + ASSERT_TRUE(failed.failed); + ASSERT_EQ(failed.http_edges, 0); + PASS(); +} + +TEST(cross_repo_failed_bidirectional_insert_is_not_counted) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "insert-source", "insert-target", + "/insert-failure", "insert") && + cross_repo_exec(&fixture, "insert-target", + "CREATE TRIGGER fail_cross_insert BEFORE INSERT ON edges " + "WHEN NEW.type = 'CROSS_HTTP_CALLS' BEGIN " + "SELECT RAISE(ABORT, 'forced cross insert failure'); END;"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed insert failure fixture"); + } + const char *target = "insert-target"; + cbm_cross_repo_result_t result = cbm_cross_repo_match("insert-source", &target, 1); + cross_repo_fixture_end(&fixture); + + ASSERT_TRUE(result.failed); + ASSERT_EQ(result.http_edges, 0); + ASSERT_EQ(result.projects_scanned, 0); + PASS(); +} + +/* Keep the source scan active after the target-b match is published. This + * gives the watcher a deterministic observable hand-off point without adding + * a test hook to the production pass. */ +static bool cross_repo_seed_cancel_scan_tail(const cross_repo_fixture_t *fixture, + const char *source_project) { + enum { CANCEL_SCAN_TOTAL_ROWS = 4096, SEEDED_MATCH_ROWS = 3 }; + char source_path[512]; + if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path))) { + return false; + } + cbm_store_t *source = cbm_store_open_path_existing(source_path); + if (!source) { + return false; + } + bool ok = + sqlite3_exec(cbm_store_get_db(source), "BEGIN IMMEDIATE", NULL, NULL, NULL) == SQLITE_OK; + cbm_node_t caller = {.project = source_project, + .label = "Function", + .name = "cancel_tail_caller", + .qualified_name = "cancel.source.tail-caller", + .file_path = "cancel-tail.c"}; + int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0; + ok = ok && caller_id > 0; + for (int i = SEEDED_MATCH_ROWS; ok && i < CANCEL_SCAN_TOTAL_ROWS; i++) { + char name[64]; + char qn[96]; + snprintf(name, sizeof(name), "cancel_tail_route_%d", i); + snprintf(qn, sizeof(qn), "%s.cancel-tail.%d", source_project, i); + cbm_node_t route = {.project = source_project, + .label = "Route", + .name = name, + .qualified_name = qn, + .file_path = "cancel-tail.c"}; + int64_t route_id = cbm_store_upsert_node(source, &route); + cbm_edge_t edge = {.project = source_project, + .source_id = caller_id, + .target_id = route_id, + .type = "HTTP_CALLS", + .properties_json = "{}"}; + ok = route_id > 0 && caller_id > 0 && cbm_store_insert_edge(source, &edge) > 0; + } + if (ok) { + ok = sqlite3_exec(cbm_store_get_db(source), "COMMIT", NULL, NULL, NULL) == SQLITE_OK; + } else { + (void)sqlite3_exec(cbm_store_get_db(source), "ROLLBACK", NULL, NULL, NULL); + } + cbm_store_close(source); + return ok; +} + +typedef struct { + const cross_repo_fixture_t *fixture; + atomic_int *cancelled; + atomic_bool observed_target_write; +} cross_repo_cancel_watcher_t; + +static void *cross_repo_cancel_after_target_write(void *arg) { + enum { CANCEL_POLL_LIMIT = 10000 }; + cross_repo_cancel_watcher_t *watcher = arg; + for (int i = 0; i < CANCEL_POLL_LIMIT; i++) { + int count = cross_repo_count_edges(watcher->fixture, "cancel-target-b", "CROSS_HTTP_CALLS"); + if (count > 0) { + atomic_store_explicit(&watcher->observed_target_write, true, memory_order_release); + atomic_store_explicit(watcher->cancelled, 1, memory_order_release); + return NULL; + } + cbm_usleep(1000); + } + /* Bound a broken implementation too: the test should fail, not hang. */ + atomic_store_explicit(watcher->cancelled, 1, memory_order_release); + return NULL; +} + +TEST(cross_repo_cancel_mid_run_keeps_completed_target_and_stops_before_later_target) { + cross_repo_fixture_t fixture; + bool setup = + cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "cancel-source", "cancel-target-a", "/cancel-a", "a") && + cross_repo_seed_http_pair(&fixture, "cancel-source", "cancel-target-b", "/cancel-b", "b") && + cross_repo_seed_http_pair(&fixture, "cancel-source", "cancel-target-c", "/cancel-c", "c") && + cross_repo_seed_cancel_scan_tail(&fixture, "cancel-source"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed cancellation fixture"); + } + + atomic_int cancelled; + atomic_init(&cancelled, 0); + cross_repo_cancel_watcher_t watcher = { + .fixture = &fixture, + .cancelled = &cancelled, + }; + atomic_init(&watcher.observed_target_write, false); + cbm_thread_t watcher_thread; + bool watcher_started = + cbm_thread_create(&watcher_thread, 0, cross_repo_cancel_after_target_write, &watcher) == 0; + + const char *targets[] = {"cancel-target-c", "cancel-target-a", "cancel-target-b"}; + cbm_cross_repo_result_t result = {0}; + if (watcher_started) { + result = cbm_cross_repo_match_cancellable("cancel-source", targets, 3, &cancelled); + (void)cbm_thread_join(&watcher_thread); + } + + bool observed = atomic_load_explicit(&watcher.observed_target_write, memory_order_acquire); + int completed_target_edges = + cross_repo_count_edges(&fixture, "cancel-target-a", "CROSS_HTTP_CALLS"); + int interrupted_target_edges = + cross_repo_count_edges(&fixture, "cancel-target-b", "CROSS_HTTP_CALLS"); + int later_target_edges = + cross_repo_count_edges(&fixture, "cancel-target-c", "CROSS_HTTP_CALLS"); + cross_repo_fixture_end(&fixture); + + ASSERT_TRUE(watcher_started); + ASSERT_TRUE(observed); + ASSERT_TRUE(result.cancelled); + ASSERT_TRUE(result.partial_results); + ASSERT_FALSE(result.failed); + ASSERT_EQ(result.projects_scanned, 1); + ASSERT_TRUE(completed_target_edges > 0); + ASSERT_TRUE(interrupted_target_edges > 0); + ASSERT_EQ(later_target_edges, 0); + PASS(); +} + +TEST(cross_repo_pre_cancel_preserves_existing_cross_edges) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "pre-cancel-source", "pre-cancel-target", + "/pre-cancel", "pre"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed pre-cancel fixture"); + } + + const char *target = "pre-cancel-target"; + cbm_cross_repo_result_t initial = cbm_cross_repo_match("pre-cancel-source", &target, 1); + int before = cross_repo_count_edges(&fixture, "pre-cancel-source", "CROSS_HTTP_CALLS"); + atomic_int cancelled; + atomic_init(&cancelled, 1); + cbm_cross_repo_result_t result = + cbm_cross_repo_match_cancellable("pre-cancel-source", &target, 1, &cancelled); + int after = cross_repo_count_edges(&fixture, "pre-cancel-source", "CROSS_HTTP_CALLS"); + cross_repo_fixture_end(&fixture); + + ASSERT_FALSE(initial.failed); + ASSERT_TRUE(before > 0); + ASSERT_TRUE(result.cancelled); + ASSERT_FALSE(result.partial_results); + ASSERT_FALSE(result.failed); + ASSERT_EQ(result.projects_scanned, 0); + ASSERT_EQ(after, before); + PASS(); +} + +SUITE(cross_repo) { + RUN_TEST(cross_repo_null_target_fails_without_dereference); + RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens); + RUN_TEST(cross_repo_scan_bound_counts_examined_rows_not_matches); + RUN_TEST(cross_repo_propagates_delete_failure); + RUN_TEST(cross_repo_failed_bidirectional_insert_is_not_counted); + RUN_TEST(cross_repo_cancel_mid_run_keeps_completed_target_and_stops_before_later_target); + RUN_TEST(cross_repo_pre_cancel_preserves_existing_cross_edges); +} diff --git a/tests/test_cypher.c b/tests/test_cypher.c index fc3a06932..0865ee008 100644 --- a/tests/test_cypher.c +++ b/tests/test_cypher.c @@ -5,8 +5,11 @@ * Covers lexer, parser, and end-to-end execution. */ #include "test_framework.h" +#include "../src/foundation/compat.h" +#include "../src/foundation/compat_thread.h" #include #include +#include #include #include #include @@ -1584,6 +1587,116 @@ TEST(cypher_edge_prop_access) { PASS(); } +typedef struct { + atomic_int *ready; + atomic_int *start; + bool succeeded; +} cypher_edge_thread_ctx_t; + +static void *cypher_edge_props_concurrently(void *opaque) { + cypher_edge_thread_ctx_t *ctx = opaque; + cbm_store_t *store = setup_cypher_http_store(); + if (!store) { + return NULL; + } + + /* Keep projection busy after the store scan has completed. A single-edge + * query can be incidentally ordered by SQLite's internal mutexes, masking + * the independent Cypher scratch-buffer race from TSan. */ + cbm_node_t source = {.project = "test", + .label = "Function", + .name = "HandleOrder", + .qualified_name = "test.main.HandleOrder", + .file_path = "main.go", + .start_line = 10, + .end_line = 30}; + int64_t source_id = cbm_store_upsert_node(store, &source); + for (int i = 0; i < 256; i++) { + char name[64]; + char qualified_name[96]; + snprintf(name, sizeof(name), "ConcurrentTarget%d", i); + snprintf(qualified_name, sizeof(qualified_name), "test.concurrent.%s", name); + cbm_node_t target = {.project = "test", + .label = "Function", + .name = name, + .qualified_name = qualified_name, + .file_path = "concurrent.go"}; + int64_t target_id = cbm_store_upsert_node(store, &target); + cbm_edge_t edge = { + .project = "test", + .source_id = source_id, + .target_id = target_id, + .type = "HTTP_CALLS", + .properties_json = + "{\"url_path\":\"/api/orders\",\"confidence\":0.85,\"method\":\"POST\"}"}; + if (source_id < 0 || target_id < 0 || cbm_store_insert_edge(store, &edge) < 0) { + cbm_store_close(store); + return NULL; + } + } + + atomic_fetch_add_explicit(ctx->ready, 1, memory_order_release); + while (atomic_load_explicit(ctx->start, memory_order_acquire) == 0) { + cbm_usleep(1000); + } + ctx->succeeded = true; + for (int i = 0; i < 128; i++) { + cbm_cypher_result_t result = {0}; + int rc = cbm_cypher_execute(store, + "MATCH (a:Function)-[r:HTTP_CALLS]->(b:Function) " + "RETURN r.url_path, r.confidence, r.method", + "test", 0, &result); + if (rc != 0 || result.row_count != 257 || + strcmp(cypher_get_col(&result, 0, "r.url_path"), "/api/orders") != 0 || + strcmp(cypher_get_col(&result, 0, "r.confidence"), "0.85") != 0 || + strcmp(cypher_get_col(&result, 0, "r.method"), "POST") != 0) { + ctx->succeeded = false; + } + cbm_cypher_result_free(&result); + if (!ctx->succeeded) { + break; + } + } + cbm_store_close(store); + return NULL; +} + +/* Daemon sessions execute independent graph queries concurrently. TSan must + * see no shared rotating edge-property scratch buffer between those threads. */ +TEST(cypher_edge_prop_storage_is_per_thread) { + atomic_int ready; + atomic_int start; + atomic_init(&ready, 0); + atomic_init(&start, 0); + cypher_edge_thread_ctx_t ctx[2] = { + {.ready = &ready, .start = &start}, + {.ready = &ready, .start = &start}, + }; + cbm_thread_t threads[2]; + bool started0 = cbm_thread_create(&threads[0], 0, cypher_edge_props_concurrently, &ctx[0]) == 0; + bool started1 = cbm_thread_create(&threads[1], 0, cypher_edge_props_concurrently, &ctx[1]) == 0; + for (int spins = 0; started0 && started1 && spins < 5000 && + atomic_load_explicit(&ready, memory_order_acquire) < 2; + spins++) { + cbm_usleep(1000); + } + bool both_ready = atomic_load_explicit(&ready, memory_order_acquire) == 2; + atomic_store_explicit(&start, 1, memory_order_release); + if (started0) { + (void)cbm_thread_join(&threads[0]); + } + if (started1) { + (void)cbm_thread_join(&threads[1]); + } + + ASSERT_TRUE(started0); + ASSERT_TRUE(started1); + ASSERT_TRUE(both_ready); + ASSERT_TRUE(ctx[0].succeeded); + ASSERT_TRUE(ctx[1].succeeded); + PASS(); +} + TEST(cypher_edge_prop_in_where) { cbm_store_t *s = setup_cypher_http_store(); cbm_cypher_result_t r = {0}; @@ -3135,6 +3248,7 @@ SUITE(cypher) { RUN_TEST(cypher_parse_where_numeric); /* Edge property tests (ported from cypher_test.go Feature 2) */ RUN_TEST(cypher_edge_prop_access); + RUN_TEST(cypher_edge_prop_storage_is_per_thread); RUN_TEST(cypher_edge_prop_in_where); RUN_TEST(cypher_edge_type_prop); RUN_TEST(cypher_edge_filter_contains); diff --git a/tests/test_daemon.c b/tests/test_daemon.c new file mode 100644 index 000000000..02004365e --- /dev/null +++ b/tests/test_daemon.c @@ -0,0 +1,487 @@ +/* + * test_daemon.c — Guards for shared MCP-daemon coordination and framing. + */ +#include "test_framework.h" + +#include "daemon/daemon.h" +#include "mcp/mcp.h" + +#include +#include +#include +#include + +typedef struct { + size_t job_cancel_count; + size_t shared_job_cancel_count; + size_t lease_job_cancel_count; + size_t shutdown_job_cancel_count; + size_t watch_release_count; + size_t explicit_watch_release_count; + size_t lease_watch_release_count; + size_t shutdown_watch_release_count; + size_t unexpected_key_count; +} daemon_release_tracker_t; + +static void record_job_cancel(const char *project_key, void *context) { + daemon_release_tracker_t *tracker = context; + tracker->job_cancel_count++; + if (strcmp(project_key, "project-shared") == 0) { + tracker->shared_job_cancel_count++; + } else if (strcmp(project_key, "project-lease-job") == 0) { + tracker->lease_job_cancel_count++; + } else if (strcmp(project_key, "project-shutdown-job") == 0) { + tracker->shutdown_job_cancel_count++; + } else { + tracker->unexpected_key_count++; + } +} + +static void record_watch_release(const char *project_key, void *context) { + daemon_release_tracker_t *tracker = context; + tracker->watch_release_count++; + if (strcmp(project_key, "project-explicit-watch") == 0) { + tracker->explicit_watch_release_count++; + } else if (strcmp(project_key, "project-lease-watch") == 0) { + tracker->lease_watch_release_count++; + } else if (strcmp(project_key, "project-shutdown-watch") == 0) { + tracker->shutdown_watch_release_count++; + } else { + tracker->unexpected_key_count++; + } +} + +static bool install_release_tracker(cbm_daemon_coordinator_t *coordinator, + daemon_release_tracker_t *tracker) { + const cbm_daemon_coordinator_hooks_t hooks = { + .cancel_job = record_job_cancel, + .release_watch = record_watch_release, + .context = tracker, + }; + return cbm_daemon_coordinator_set_hooks(coordinator, &hooks); +} + +TEST(daemon_client_ids_are_connection_bound) { + cbm_daemon_coordinator_t *c = cbm_daemon_coordinator_new(250); + ASSERT_NOT_NULL(c); + + cbm_daemon_client_id_t a = cbm_daemon_client_connected(c, 1000); + cbm_daemon_client_id_t b = cbm_daemon_client_connected(c, 1001); + ASSERT_NEQ(a, CBM_DAEMON_CLIENT_ID_INVALID); + ASSERT_NEQ(b, CBM_DAEMON_CLIENT_ID_INVALID); + ASSERT_NEQ(a, b); + ASSERT_EQ(cbm_daemon_active_clients(c), 2); + + cbm_daemon_subscription_id_t rejected = UINT64_MAX; + ASSERT_EQ(cbm_daemon_job_subscribe(c, UINT64_MAX, "project-a", &rejected), + CBM_DAEMON_SUBSCRIPTION_REJECTED); + ASSERT_EQ(rejected, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); + ASSERT_TRUE(cbm_daemon_client_disconnected(c, a, 1010)); + ASSERT_FALSE(cbm_daemon_client_disconnected(c, a, 1011)); + rejected = UINT64_MAX; + ASSERT_EQ(cbm_daemon_job_subscribe(c, a, "project-a", &rejected), + CBM_DAEMON_SUBSCRIPTION_REJECTED); + ASSERT_EQ(rejected, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); + + cbm_daemon_client_id_t c_id = cbm_daemon_client_connected(c, 1012); + ASSERT_NEQ(c_id, CBM_DAEMON_CLIENT_ID_INVALID); + ASSERT_NEQ(c_id, a); /* closed connection IDs are never recycled */ + ASSERT_NEQ(c_id, b); + ASSERT_EQ(cbm_daemon_active_clients(c), 2); + + cbm_daemon_coordinator_free(c); + PASS(); +} + +TEST(daemon_shared_job_survives_until_final_subscriber_disconnects) { + cbm_daemon_coordinator_t *c = cbm_daemon_coordinator_new(250); + daemon_release_tracker_t tracker = {0}; + ASSERT_NOT_NULL(c); + ASSERT_TRUE(install_release_tracker(c, &tracker)); + + cbm_daemon_client_id_t a = cbm_daemon_client_connected(c, 2000); + cbm_daemon_client_id_t b = cbm_daemon_client_connected(c, 2000); + cbm_daemon_client_id_t observer = cbm_daemon_client_connected(c, 2000); + ASSERT_NEQ(a, CBM_DAEMON_CLIENT_ID_INVALID); + ASSERT_NEQ(b, CBM_DAEMON_CLIENT_ID_INVALID); + ASSERT_NEQ(observer, CBM_DAEMON_CLIENT_ID_INVALID); + + cbm_daemon_subscription_id_t a_first = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + cbm_daemon_subscription_id_t a_second = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + cbm_daemon_subscription_id_t b_subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + ASSERT_EQ(cbm_daemon_job_subscribe(c, a, "project-shared", &a_first), + CBM_DAEMON_SUBSCRIPTION_STARTED); + ASSERT_EQ(cbm_daemon_job_subscribe(c, a, "project-shared", &a_second), + CBM_DAEMON_SUBSCRIPTION_JOINED); + ASSERT_EQ(cbm_daemon_job_subscribe(c, b, "project-shared", &b_subscription), + CBM_DAEMON_SUBSCRIPTION_JOINED); + ASSERT_NEQ(a_first, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); + ASSERT_NEQ(a_second, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); + ASSERT_NEQ(b_subscription, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); + ASSERT_NEQ(a_first, a_second); + ASSERT_NEQ(a_first, b_subscription); + ASSERT_NEQ(a_second, b_subscription); + ASSERT_EQ(cbm_daemon_active_jobs(c), 1); + ASSERT_EQ(cbm_daemon_job_state(c, "project-shared"), CBM_DAEMON_JOB_RUNNING); + ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shared"), 3); + + ASSERT_FALSE(cbm_daemon_job_unsubscribe(c, b, a_first)); + ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shared"), 3); + ASSERT_TRUE(cbm_daemon_job_unsubscribe(c, a, a_first)); + ASSERT_FALSE(cbm_daemon_job_unsubscribe(c, a, a_first)); + ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shared"), 2); + ASSERT_EQ(tracker.job_cancel_count, 0); + + ASSERT_TRUE(cbm_daemon_client_disconnected(c, a, 2001)); + ASSERT_EQ(cbm_daemon_active_jobs(c), 1); + ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shared"), 1); + ASSERT_EQ(tracker.job_cancel_count, 0); + + /* The final subscription requests cancellation even while the daemon + * remains RUNNING because unrelated clients are still connected. */ + ASSERT_TRUE(cbm_daemon_job_unsubscribe(c, b, b_subscription)); + ASSERT_FALSE(cbm_daemon_job_unsubscribe(c, b, b_subscription)); + ASSERT_EQ(cbm_daemon_coordinator_state(c), CBM_DAEMON_COORDINATOR_RUNNING); + ASSERT_EQ(cbm_daemon_active_jobs(c), 1); + ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shared"), 0); + ASSERT_EQ(cbm_daemon_job_state(c, "project-shared"), + CBM_DAEMON_JOB_CANCEL_REQUESTED); + ASSERT_EQ(tracker.job_cancel_count, 1); + ASSERT_EQ(tracker.shared_job_cancel_count, 1); + ASSERT_EQ(tracker.unexpected_key_count, 0); + + /* Cancellation is asynchronous: the job remains active until its worker + * has actually been reaped, and duplicate owner cleanup cannot re-fire it. */ + ASSERT_TRUE(cbm_daemon_client_disconnected(c, b, 2002)); + ASSERT_EQ(tracker.job_cancel_count, 1); + ASSERT_EQ(cbm_daemon_active_clients(c), 1); + ASSERT_EQ(cbm_daemon_active_jobs(c), 1); + ASSERT_TRUE(cbm_daemon_job_reaped(c, "project-shared", 2003)); + ASSERT_FALSE(cbm_daemon_job_reaped(c, "project-shared", 2004)); + ASSERT_EQ(cbm_daemon_active_jobs(c), 0); + ASSERT_EQ(cbm_daemon_job_state(c, "project-shared"), CBM_DAEMON_JOB_NONE); + + cbm_daemon_coordinator_free(c); + PASS(); +} + +TEST(daemon_watch_subscription_ids_are_connection_bound) { + cbm_daemon_coordinator_t *c = cbm_daemon_coordinator_new(250); + daemon_release_tracker_t tracker = {0}; + ASSERT_NOT_NULL(c); + ASSERT_TRUE(install_release_tracker(c, &tracker)); + + cbm_daemon_client_id_t a = cbm_daemon_client_connected(c, 3000); + cbm_daemon_client_id_t b = cbm_daemon_client_connected(c, 3000); + cbm_daemon_subscription_id_t a_watch = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + cbm_daemon_subscription_id_t b_watch = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + ASSERT_EQ(cbm_daemon_watch_subscribe(c, a, "project-explicit-watch", &a_watch), + CBM_DAEMON_SUBSCRIPTION_STARTED); + ASSERT_EQ(cbm_daemon_watch_subscribe(c, b, "project-explicit-watch", &b_watch), + CBM_DAEMON_SUBSCRIPTION_JOINED); + ASSERT_NEQ(a_watch, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); + ASSERT_NEQ(b_watch, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); + ASSERT_NEQ(a_watch, b_watch); + ASSERT_EQ(cbm_daemon_watch_subscribers(c, "project-explicit-watch"), 2); + + ASSERT_FALSE(cbm_daemon_watch_unsubscribe(c, b, a_watch)); + ASSERT_TRUE(cbm_daemon_watch_unsubscribe(c, a, a_watch)); + ASSERT_FALSE(cbm_daemon_watch_unsubscribe(c, a, a_watch)); + ASSERT_EQ(cbm_daemon_active_watches(c), 1); + ASSERT_EQ(cbm_daemon_watch_subscribers(c, "project-explicit-watch"), 1); + ASSERT_EQ(tracker.watch_release_count, 0); + + ASSERT_TRUE(cbm_daemon_watch_unsubscribe(c, b, b_watch)); + ASSERT_FALSE(cbm_daemon_watch_unsubscribe(c, b, b_watch)); + ASSERT_EQ(cbm_daemon_active_watches(c), 0); + ASSERT_EQ(cbm_daemon_watch_subscribers(c, "project-explicit-watch"), 0); + ASSERT_EQ(tracker.watch_release_count, 1); + ASSERT_EQ(tracker.explicit_watch_release_count, 1); + ASSERT_EQ(tracker.unexpected_key_count, 0); + + cbm_daemon_coordinator_free(c); + PASS(); +} + +TEST(daemon_heartbeat_extends_lease_and_expiry_releases_connection) { + cbm_daemon_coordinator_t *c = cbm_daemon_coordinator_new(250); + daemon_release_tracker_t tracker = {0}; + ASSERT_NOT_NULL(c); + ASSERT_TRUE(install_release_tracker(c, &tracker)); + + cbm_daemon_client_id_t owner = cbm_daemon_client_connected(c, 1000); + cbm_daemon_client_id_t observer = cbm_daemon_client_connected(c, 1200); + cbm_daemon_subscription_id_t job_subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + cbm_daemon_subscription_id_t watch_subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + ASSERT_EQ(cbm_daemon_job_subscribe(c, owner, "project-lease-job", &job_subscription), + CBM_DAEMON_SUBSCRIPTION_STARTED); + ASSERT_EQ(cbm_daemon_watch_subscribe(c, owner, "project-lease-watch", &watch_subscription), + CBM_DAEMON_SUBSCRIPTION_STARTED); + + ASSERT_TRUE(cbm_daemon_client_heartbeat(c, owner, 1200)); + ASSERT_EQ(cbm_daemon_expire_leases(c, 1250), 0); + ASSERT_EQ(cbm_daemon_active_clients(c), 2); + ASSERT_TRUE(cbm_daemon_client_heartbeat(c, observer, 1449)); + ASSERT_EQ(cbm_daemon_expire_leases(c, 1449), 0); + + ASSERT_EQ(cbm_daemon_expire_leases(c, 1450), 1); + ASSERT_EQ(cbm_daemon_active_clients(c), 1); + ASSERT_EQ(cbm_daemon_coordinator_state(c), CBM_DAEMON_COORDINATOR_RUNNING); + ASSERT_FALSE(cbm_daemon_client_heartbeat(c, owner, 1451)); + ASSERT_FALSE(cbm_daemon_client_disconnected(c, owner, 1451)); + ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-lease-job"), 0); + ASSERT_EQ(cbm_daemon_job_state(c, "project-lease-job"), + CBM_DAEMON_JOB_CANCEL_REQUESTED); + ASSERT_EQ(cbm_daemon_active_jobs(c), 1); + ASSERT_EQ(cbm_daemon_active_watches(c), 0); + ASSERT_EQ(tracker.job_cancel_count, 1); + ASSERT_EQ(tracker.lease_job_cancel_count, 1); + ASSERT_EQ(tracker.watch_release_count, 1); + ASSERT_EQ(tracker.lease_watch_release_count, 1); + ASSERT_EQ(tracker.unexpected_key_count, 0); + ASSERT_TRUE(cbm_daemon_job_reaped(c, "project-lease-job", 1452)); + ASSERT_EQ(cbm_daemon_active_jobs(c), 0); + + cbm_daemon_coordinator_free(c); + PASS(); +} + +TEST(daemon_last_client_stops_immediately_and_releases_owned_work) { + cbm_daemon_coordinator_t *c = cbm_daemon_coordinator_new(250); + daemon_release_tracker_t tracker = {0}; + ASSERT_NOT_NULL(c); + ASSERT_TRUE(install_release_tracker(c, &tracker)); + + cbm_daemon_client_id_t client = cbm_daemon_client_connected(c, 5000); + cbm_daemon_subscription_id_t job_subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + cbm_daemon_subscription_id_t watch_subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + ASSERT_NEQ(client, CBM_DAEMON_CLIENT_ID_INVALID); + ASSERT_EQ(cbm_daemon_coordinator_state(c), CBM_DAEMON_COORDINATOR_RUNNING); + ASSERT_EQ(cbm_daemon_job_subscribe(c, client, "project-shutdown-job", &job_subscription), + CBM_DAEMON_SUBSCRIPTION_STARTED); + ASSERT_EQ( + cbm_daemon_watch_subscribe(c, client, "project-shutdown-watch", &watch_subscription), + CBM_DAEMON_SUBSCRIPTION_STARTED); + ASSERT_NEQ(job_subscription, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); + ASSERT_NEQ(watch_subscription, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); + ASSERT_EQ(cbm_daemon_active_jobs(c), 1); + ASSERT_EQ(cbm_daemon_active_watches(c), 1); + ASSERT_EQ(cbm_daemon_watch_subscribers(c, "project-shutdown-watch"), 1); + + ASSERT_TRUE(cbm_daemon_client_disconnected(c, client, 5200)); + ASSERT_EQ(cbm_daemon_active_clients(c), 0); + ASSERT_EQ(cbm_daemon_coordinator_state(c), CBM_DAEMON_COORDINATOR_STOPPING); + ASSERT_EQ(cbm_daemon_active_jobs(c), 1); + ASSERT_EQ(cbm_daemon_active_watches(c), 0); + ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shutdown-job"), 0); + ASSERT_EQ(cbm_daemon_job_state(c, "project-shutdown-job"), + CBM_DAEMON_JOB_CANCEL_REQUESTED); + ASSERT_EQ(cbm_daemon_watch_subscribers(c, "project-shutdown-watch"), 0); + ASSERT_EQ(tracker.job_cancel_count, 1); + ASSERT_EQ(tracker.shutdown_job_cancel_count, 1); + ASSERT_EQ(tracker.watch_release_count, 1); + ASSERT_EQ(tracker.shutdown_watch_release_count, 1); + ASSERT_EQ(tracker.unexpected_key_count, 0); + + /* There is no post-client grace before STOPPING, but process exit waits + * until the cancelled worker is confirmed reaped. */ + ASSERT_FALSE(cbm_daemon_should_exit(c, 5200)); + ASSERT_FALSE(cbm_daemon_should_exit(c, 10000)); + + /* STOPPING is terminal for this daemon instance. A late connection or a + * stale connection ID cannot resurrect it or enqueue fresh work. */ + ASSERT_EQ(cbm_daemon_client_connected(c, 5201), CBM_DAEMON_CLIENT_ID_INVALID); + cbm_daemon_subscription_id_t rejected = UINT64_MAX; + ASSERT_EQ(cbm_daemon_job_subscribe(c, client, "project-too-late", &rejected), + CBM_DAEMON_SUBSCRIPTION_REJECTED); + ASSERT_EQ(rejected, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); + rejected = UINT64_MAX; + ASSERT_EQ(cbm_daemon_watch_subscribe(c, client, "project-too-late", &rejected), + CBM_DAEMON_SUBSCRIPTION_REJECTED); + ASSERT_EQ(rejected, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); + ASSERT_FALSE(cbm_daemon_client_disconnected(c, client, 5201)); + ASSERT_EQ(tracker.job_cancel_count, 1); + ASSERT_EQ(tracker.watch_release_count, 1); + + ASSERT_TRUE(cbm_daemon_job_reaped(c, "project-shutdown-job", 5202)); + ASSERT_EQ(cbm_daemon_active_jobs(c), 0); + ASSERT_EQ(cbm_daemon_job_state(c, "project-shutdown-job"), CBM_DAEMON_JOB_NONE); + ASSERT_TRUE(cbm_daemon_should_exit(c, 5202)); + + cbm_daemon_coordinator_free(c); + PASS(); +} + +TEST(daemon_completed_job_is_not_cancelled_on_later_disconnect) { + cbm_daemon_coordinator_t *c = cbm_daemon_coordinator_new(250); + daemon_release_tracker_t tracker = {0}; + ASSERT_NOT_NULL(c); + ASSERT_TRUE(install_release_tracker(c, &tracker)); + + cbm_daemon_client_id_t client = cbm_daemon_client_connected(c, 6000); + cbm_daemon_subscription_id_t subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + ASSERT_EQ(cbm_daemon_job_subscribe(c, client, "project-normal", &subscription), + CBM_DAEMON_SUBSCRIPTION_STARTED); + ASSERT_NEQ(subscription, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); + ASSERT_EQ(cbm_daemon_job_state(c, "project-normal"), CBM_DAEMON_JOB_RUNNING); + + ASSERT_TRUE(cbm_daemon_job_completed(c, "project-normal", 6100)); + ASSERT_FALSE(cbm_daemon_job_completed(c, "project-normal", 6101)); + ASSERT_FALSE(cbm_daemon_job_reaped(c, "project-normal", 6101)); + ASSERT_EQ(cbm_daemon_active_jobs(c), 0); + ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-normal"), 0); + ASSERT_EQ(cbm_daemon_job_state(c, "project-normal"), CBM_DAEMON_JOB_NONE); + + ASSERT_TRUE(cbm_daemon_client_disconnected(c, client, 6200)); + ASSERT_EQ(cbm_daemon_coordinator_state(c), CBM_DAEMON_COORDINATOR_STOPPING); + ASSERT_EQ(tracker.job_cancel_count, 0); + ASSERT_EQ(tracker.unexpected_key_count, 0); + ASSERT_TRUE(cbm_daemon_should_exit(c, 6200)); + + cbm_daemon_coordinator_free(c); + PASS(); +} + +TEST(daemon_frame_header_rejects_wrong_protocol_and_oversize) { + uint8_t header[CBM_DAEMON_FRAME_HEADER_SIZE]; + cbm_daemon_frame_t frame; + + ASSERT_TRUE(cbm_daemon_frame_header_encode(header, CBM_DAEMON_FRAME_REQUEST, 0, 1234)); + ASSERT_TRUE(cbm_daemon_frame_header_decode(header, &frame)); + ASSERT_EQ(frame.type, CBM_DAEMON_FRAME_REQUEST); + ASSERT_EQ(frame.flags, 0); + ASSERT_EQ(frame.length, 1234); + + header[4] = (uint8_t)(CBM_DAEMON_RENDEZVOUS_FRAME_VERSION + 1); + ASSERT_FALSE(cbm_daemon_frame_header_decode(header, &frame)); + + ASSERT_FALSE(cbm_daemon_frame_header_encode(header, CBM_DAEMON_FRAME_REQUEST, 0, + CBM_DAEMON_MAX_FRAME_SIZE + 1)); + PASS(); +} + +static FILE *message_stream_bytes(const void *bytes, size_t length) { + FILE *f = tmpfile(); + if (!f) { + return NULL; + } + (void)fwrite(bytes, 1, length, f); + rewind(f); + return f; +} + +static FILE *message_stream(const char *bytes) { + return message_stream_bytes(bytes, strlen(bytes)); +} + +TEST(daemon_bridge_reads_newline_and_content_length_messages) { + char *message = NULL; + bool framed = false; + + FILE *line = message_stream("{\"jsonrpc\":\"2.0\",\"method\":\"ping\"}\n"); + ASSERT_NOT_NULL(line); + ASSERT_EQ(cbm_mcp_read_message(line, &message, &framed), 1); + ASSERT_FALSE(framed); + ASSERT_STR_EQ(message, "{\"jsonrpc\":\"2.0\",\"method\":\"ping\"}"); + free(message); + message = NULL; + fclose(line); + + const char *body = "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/list\"}"; + char wire[512]; + snprintf(wire, sizeof(wire), "Content-Length: %zu\r\nX-Test: yes\r\n\r\n%s", strlen(body), + body); + FILE *content_length = message_stream(wire); + ASSERT_NOT_NULL(content_length); + ASSERT_EQ(cbm_mcp_read_message(content_length, &message, &framed), 1); + ASSERT_TRUE(framed); + ASSERT_STR_EQ(message, body); + free(message); + fclose(content_length); + PASS(); +} + +TEST(daemon_bridge_rejects_malformed_content_length) { + char *message = NULL; + bool framed = false; + FILE *wire = message_stream("Content-Length: 4junk\r\n\r\nping"); + ASSERT_NOT_NULL(wire); + ASSERT_EQ(cbm_mcp_read_message(wire, &message, &framed), -1); + ASSERT_NULL(message); + fclose(wire); + PASS(); +} + +TEST(daemon_bridge_rejects_embedded_nul_body) { + static const unsigned char wire[] = { + 'C', 'o', 'n', 't', 'e', 'n', 't', '-', 'L', 'e', 'n', 'g', 't', + 'h', ':', ' ', '4', '\r', '\n', '\r', '\n', 'a', '\0', 'b', 'c', + }; + char *message = NULL; + bool framed = false; + FILE *stream = message_stream_bytes(wire, sizeof(wire)); + ASSERT_NOT_NULL(stream); + ASSERT_EQ(cbm_mcp_read_message(stream, &message, &framed), -1); + ASSERT_NULL(message); + fclose(stream); + PASS(); +} + +TEST(daemon_bridge_rejects_oversized_headers) { + const size_t fill_length = 9000; + const char prefix[] = "Content-Length: 2\r\nX-Fill: "; + const char suffix[] = "\r\n\r\n{}"; + size_t wire_length = sizeof(prefix) - 1 + fill_length + sizeof(suffix) - 1; + char *wire_bytes = malloc(wire_length); + ASSERT_NOT_NULL(wire_bytes); + memcpy(wire_bytes, prefix, sizeof(prefix) - 1); + memset(wire_bytes + sizeof(prefix) - 1, 'x', fill_length); + memcpy(wire_bytes + sizeof(prefix) - 1 + fill_length, suffix, sizeof(suffix) - 1); + + char *message = NULL; + bool framed = false; + FILE *stream = message_stream_bytes(wire_bytes, wire_length); + free(wire_bytes); + ASSERT_NOT_NULL(stream); + ASSERT_EQ(cbm_mcp_read_message(stream, &message, &framed), -1); + ASSERT_NULL(message); + fclose(stream); + PASS(); +} + +TEST(daemon_sessions_keep_distinct_roots_and_allowed_root_policy) { + cbm_mcp_server_t *a = cbm_mcp_server_new(NULL); + cbm_mcp_server_t *b = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(a); + ASSERT_NOT_NULL(b); + + ASSERT_TRUE(cbm_mcp_server_set_session_context(a, "/tmp/cbm-session-a", "/tmp")); + ASSERT_TRUE(cbm_mcp_server_set_session_context(b, "/tmp/cbm-session-b", NULL)); + cbm_mcp_server_set_background_tasks(a, false); + cbm_mcp_server_set_background_tasks(b, false); + + ASSERT_STR_EQ(cbm_mcp_server_session_root(a), "/tmp/cbm-session-a"); + ASSERT_STR_EQ(cbm_mcp_server_allowed_root(a), "/tmp"); + ASSERT_STR_EQ(cbm_mcp_server_session_root(b), "/tmp/cbm-session-b"); + ASSERT_NULL(cbm_mcp_server_allowed_root(b)); + ASSERT_STR_NEQ(cbm_mcp_server_session_project(a), cbm_mcp_server_session_project(b)); + + cbm_mcp_server_free(a); + cbm_mcp_server_free(b); + PASS(); +} + +SUITE(daemon) { + RUN_TEST(daemon_client_ids_are_connection_bound); + RUN_TEST(daemon_shared_job_survives_until_final_subscriber_disconnects); + RUN_TEST(daemon_watch_subscription_ids_are_connection_bound); + RUN_TEST(daemon_heartbeat_extends_lease_and_expiry_releases_connection); + RUN_TEST(daemon_last_client_stops_immediately_and_releases_owned_work); + RUN_TEST(daemon_completed_job_is_not_cancelled_on_later_disconnect); + RUN_TEST(daemon_frame_header_rejects_wrong_protocol_and_oversize); + RUN_TEST(daemon_bridge_reads_newline_and_content_length_messages); + RUN_TEST(daemon_bridge_rejects_malformed_content_length); + RUN_TEST(daemon_bridge_rejects_embedded_nul_body); + RUN_TEST(daemon_bridge_rejects_oversized_headers); + RUN_TEST(daemon_sessions_keep_distinct_roots_and_allowed_root_policy); +} diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c new file mode 100644 index 000000000..2693e19de --- /dev/null +++ b/tests/test_daemon_application.c @@ -0,0 +1,3944 @@ +/* + * test_daemon_application.c — RED contract for daemon-owned frontend state. + */ +#include "test_framework.h" +#include "test_helpers.h" + +#include "daemon/application.h" +#include "daemon/application_internal.h" +#include "daemon/ipc.h" +#include "daemon/project_lock.h" +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/compat_thread.h" +#include "foundation/platform.h" +#include "mcp/mcp.h" +#include "pipeline/pipeline.h" +#include "store/store.h" +#include "ui/config.h" +#include "watcher/watcher.h" + +#include +#include +#include +#include +#include + +enum { APP_TEST_TIMEOUT_MS = 2000, APP_TEST_PATH_CAP = 1024 }; + +typedef struct { + char runtime_parent[APP_TEST_PATH_CAP]; + cbm_daemon_ipc_endpoint_t *endpoint; + cbm_project_lock_manager_t *external_locks; + cbm_project_lock_manager_t *daemon_locks; + cbm_project_lock_manager_t *worker_locks; + cbm_project_lock_manager_t *probe_locks; +} app_project_lock_fixture_t; + +static bool app_project_lock_fixture_cleanup(app_project_lock_fixture_t *fixture) { + if (!fixture) { + return false; + } + bool clean = true; + if (fixture->daemon_locks && + cbm_project_lock_manager_free(&fixture->daemon_locks) != + CBM_PRIVATE_FILE_LOCK_OK) { + clean = false; + } + if (fixture->external_locks && + cbm_project_lock_manager_free(&fixture->external_locks) != + CBM_PRIVATE_FILE_LOCK_OK) { + clean = false; + } + if (fixture->worker_locks && + cbm_project_lock_manager_free(&fixture->worker_locks) != + CBM_PRIVATE_FILE_LOCK_OK) { + clean = false; + } + if (fixture->probe_locks && + cbm_project_lock_manager_free(&fixture->probe_locks) != + CBM_PRIVATE_FILE_LOCK_OK) { + clean = false; + } + if (!fixture->daemon_locks && !fixture->external_locks && + !fixture->worker_locks && !fixture->probe_locks) { + cbm_daemon_ipc_endpoint_free(fixture->endpoint); + fixture->endpoint = NULL; + if (fixture->runtime_parent[0] && th_rmtree(fixture->runtime_parent) != 0) { + clean = false; + } + } else { + clean = false; + } + return clean; +} + +static bool app_project_lock_fixture_init(app_project_lock_fixture_t *fixture) { + if (!fixture) { + return false; + } + memset(fixture, 0, sizeof(*fixture)); + (void)snprintf(fixture->runtime_parent, sizeof(fixture->runtime_parent), + "%s/cbm-app-project-lock-XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(fixture->runtime_parent)) { + return false; + } + fixture->endpoint = + cbm_daemon_ipc_endpoint_new("fedcba9876543210", fixture->runtime_parent); + if (fixture->endpoint) { + fixture->external_locks = cbm_project_lock_manager_new(fixture->endpoint); + fixture->daemon_locks = cbm_project_lock_manager_new(fixture->endpoint); + fixture->worker_locks = cbm_project_lock_manager_new(fixture->endpoint); + fixture->probe_locks = cbm_project_lock_manager_new(fixture->endpoint); + } + if (fixture->endpoint && fixture->external_locks && fixture->daemon_locks && + fixture->worker_locks && fixture->probe_locks) { + return true; + } + (void)app_project_lock_fixture_cleanup(fixture); + return false; +} + +static bool app_project_lock_release(cbm_project_lock_lease_t **lease) { + if (!lease) { + return false; + } + uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (*lease && cbm_now_ms() < deadline) { + (void)cbm_project_lock_lease_release(lease); + if (*lease) { + cbm_usleep(1000); + } + } + return *lease == NULL; +} + +static cbm_daemon_runtime_application_session_t *app_test_open( + const cbm_daemon_runtime_application_callbacks_t *callbacks, cbm_daemon_client_id_t client_id) { + return callbacks->session_open(callbacks->context, client_id, 1234); +} + +static atomic_uint_fast64_t g_app_test_request_token = ATOMIC_VAR_INIT(1); + +static cbm_daemon_runtime_application_status_t app_test_request_tagged( + const cbm_daemon_runtime_application_callbacks_t *callbacks, + cbm_daemon_runtime_application_session_t *session, + cbm_daemon_runtime_application_token_t request_token, + const uint8_t *request, uint32_t request_length, uint8_t **response_out, + uint32_t *response_length_out) { + return callbacks->request(callbacks->context, session, request_token, + request, request_length, response_out, + response_length_out); +} + +static cbm_daemon_runtime_application_status_t app_test_request( + const cbm_daemon_runtime_application_callbacks_t *callbacks, + cbm_daemon_runtime_application_session_t *session, const uint8_t *request, + uint32_t request_length, uint8_t **response_out, uint32_t *response_length_out) { + cbm_daemon_runtime_application_token_t request_token = + atomic_fetch_add_explicit(&g_app_test_request_token, 1, + memory_order_relaxed); + return app_test_request_tagged(callbacks, session, request_token, request, + request_length, response_out, + response_length_out); +} + +static bool app_test_context_request_options( + const char *root, const char *allowed, + cbm_mcp_tool_profile_t tool_profile, const char *hook_event, + const char *hook_dialect, uint8_t **request_out, uint32_t *length_out) { + size_t root_length = strlen(root); + size_t allowed_length = allowed ? strlen(allowed) : 0; + size_t event_length = hook_event ? strlen(hook_event) : 0; + size_t dialect_length = hook_dialect ? strlen(hook_dialect) : 0; + size_t total = 19U + root_length + allowed_length + event_length + + dialect_length; + if (root_length == 0 || root_length > UINT32_MAX || + allowed_length > UINT32_MAX || event_length > UINT32_MAX || + dialect_length > UINT32_MAX || total > UINT32_MAX) { + return false; + } + uint8_t *request = calloc(1, total); + if (!request) { + return false; + } + request[0] = CBM_DAEMON_APPLICATION_REQUEST_SET_CONTEXT; + request[1] = (uint8_t)(root_length >> 24); + request[2] = (uint8_t)(root_length >> 16); + request[3] = (uint8_t)(root_length >> 8); + request[4] = (uint8_t)root_length; + request[5] = allowed ? 1U : 0U; + request[6] = (uint8_t)(allowed_length >> 24); + request[7] = (uint8_t)(allowed_length >> 16); + request[8] = (uint8_t)(allowed_length >> 8); + request[9] = (uint8_t)allowed_length; + request[10] = (uint8_t)tool_profile; + request[11] = (uint8_t)(event_length >> 24); + request[12] = (uint8_t)(event_length >> 16); + request[13] = (uint8_t)(event_length >> 8); + request[14] = (uint8_t)event_length; + request[15] = (uint8_t)(dialect_length >> 24); + request[16] = (uint8_t)(dialect_length >> 16); + request[17] = (uint8_t)(dialect_length >> 8); + request[18] = (uint8_t)dialect_length; + memcpy(request + 19, root, root_length); + if (allowed_length > 0) { + memcpy(request + 19 + root_length, allowed, allowed_length); + } + if (event_length > 0) { + memcpy(request + 19 + root_length + allowed_length, hook_event, + event_length); + } + if (dialect_length > 0) { + memcpy(request + 19 + root_length + allowed_length + event_length, + hook_dialect, dialect_length); + } + *request_out = request; + *length_out = (uint32_t)total; + return true; +} + +static bool app_test_context_request(const char *root, const char *allowed, + uint8_t **request_out, + uint32_t *length_out) { + return app_test_context_request_options( + root, allowed, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL, request_out, + length_out); +} + +static bool app_test_text_request(cbm_daemon_application_request_kind_t kind, const char *text, + uint8_t **request_out, uint32_t *length_out) { + size_t text_length = strlen(text); + if (text_length == 0 || text_length >= UINT32_MAX) { + return false; + } + uint8_t *request = malloc(text_length + 1U); + if (!request) { + return false; + } + request[0] = (uint8_t)kind; + memcpy(request + 1, text, text_length); + *request_out = request; + *length_out = (uint32_t)text_length + 1U; + return true; +} + +static bool app_test_tool_request(const char *tool, const char *args, uint8_t **request_out, + uint32_t *length_out) { + size_t tool_length = strlen(tool); + size_t args_length = strlen(args); + size_t total = 5U + tool_length + args_length; + if (tool_length == 0 || args_length == 0 || total > UINT32_MAX) { + return false; + } + uint8_t *request = malloc(total); + if (!request) { + return false; + } + request[0] = CBM_DAEMON_APPLICATION_REQUEST_TOOL; + request[1] = (uint8_t)(tool_length >> 24); + request[2] = (uint8_t)(tool_length >> 16); + request[3] = (uint8_t)(tool_length >> 8); + request[4] = (uint8_t)tool_length; + memcpy(request + 5, tool, tool_length); + memcpy(request + 5 + tool_length, args, args_length); + *request_out = request; + *length_out = (uint32_t)total; + return true; +} + +static cbm_daemon_runtime_application_status_t app_test_ui_config_request( + const cbm_daemon_runtime_application_callbacks_t *callbacks, + cbm_daemon_runtime_application_session_t *session, uint8_t mask, + uint8_t enabled, uint32_t port, uint32_t request_length) { + uint8_t request[7] = { + CBM_DAEMON_APPLICATION_REQUEST_SET_UI_CONFIG, + mask, + enabled, + (uint8_t)(port >> 24), + (uint8_t)(port >> 16), + (uint8_t)(port >> 8), + (uint8_t)port, + }; + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t status = + app_test_request(callbacks, session, request, request_length, + &response, &response_length); + if (response || response_length != 0) { + free(response); + return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + return status; +} + +TEST(daemon_application_new_session_does_not_retain_initial_store) { + cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 300); + + bool retains_store = + session && cbm_daemon_application_session_retains_store_for_test(session); + + if (session) { + callbacks.session_close(callbacks.context, session); + } + bool stopped = + application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + + ASSERT_NOT_NULL(application); + ASSERT_NOT_NULL(session); + ASSERT_FALSE(retains_store); + ASSERT_TRUE(stopped); + PASS(); +} + +TEST(daemon_application_request_cancel_is_scoped_to_exact_token) { + char root[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), + "%s/cbm-app-request-cancel-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + cbm_daemon_application_t *application = + cbm_daemon_application_new(NULL); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = + app_test_open(&callbacks, 301); + uint8_t *request = NULL; + uint32_t request_length = 0; + bool request_created = root_ok && + app_test_context_request( + root, root, &request, &request_length); + cbm_daemon_runtime_application_token_t cancelled_token = UINT64_C(7001); + cbm_daemon_runtime_application_token_t next_token = UINT64_C(7002); + uint8_t *cancelled_response = NULL; + uint32_t cancelled_response_length = 0; + cbm_daemon_runtime_application_status_t cancelled_status = + CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + uint8_t *next_response = NULL; + uint32_t next_response_length = 0; + cbm_daemon_runtime_application_status_t next_status = + CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + + if (session && request_created) { + callbacks.request_cancel(callbacks.context, session, + cancelled_token); + cancelled_status = app_test_request_tagged( + &callbacks, session, cancelled_token, request, request_length, + &cancelled_response, &cancelled_response_length); + /* A late duplicate for the completed token must not poison the next + * unique request on this still-live session. */ + callbacks.request_cancel(callbacks.context, session, + cancelled_token); + next_status = app_test_request_tagged( + &callbacks, session, next_token, request, request_length, + &next_response, &next_response_length); + } + + free(request); + free(cancelled_response); + free(next_response); + if (session) { + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && + cbm_daemon_application_shutdown( + application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + if (root_ok) { + (void)cbm_rmdir(root); + } + + ASSERT_TRUE(root_ok); + ASSERT_NOT_NULL(application); + ASSERT_NOT_NULL(session); + ASSERT_TRUE(request_created); + ASSERT_EQ(cancelled_status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); + ASSERT_EQ(cancelled_response_length, 0); + ASSERT_EQ(next_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_EQ(next_response_length, 0); + ASSERT_TRUE(stopped); + PASS(); +} + +TEST(daemon_application_ui_config_updates_are_masked_and_serialized) { + char cache[APP_TEST_PATH_CAP]; + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-ui-config-XXXXXX", + cbm_tmpdir()); + bool cache_ok = cbm_mkdtemp(cache) != NULL; + char *old_cache = getenv("CBM_CACHE_DIR") + ? strdup(getenv("CBM_CACHE_DIR")) + : NULL; + bool env_ok = cache_ok && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + cbm_ui_config_t initial = {.ui_enabled = false, .ui_port = 9749}; + bool initial_saved = env_ok && cbm_ui_config_save(&initial); + + cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *enabled_session = + app_test_open(&callbacks, 301); + cbm_daemon_runtime_application_session_t *port_session = + app_test_open(&callbacks, 302); + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *context_response = NULL; + uint32_t context_response_length = 0; + bool context_encoded = cache_ok && app_test_context_request( + cache, cache, &context, + &context_length); + bool contexts_set = context_encoded && enabled_session && port_session && + app_test_request( + &callbacks, enabled_session, context, + context_length, &context_response, + &context_response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(context_response); + context_response = NULL; + context_response_length = 0; + contexts_set = contexts_set && + app_test_request( + &callbacks, port_session, context, context_length, + &context_response, &context_response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(context_response); + + /* Each request deliberately carries a stale value for the unmasked field. + * Whole-struct replacement would lose one update; masked daemon-side + * read-modify-write must preserve both regardless of request order. */ + cbm_daemon_runtime_application_status_t enabled_status = + app_test_ui_config_request( + &callbacks, enabled_session, + CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 1, 0, 7); + cbm_daemon_runtime_application_status_t port_status = + app_test_ui_config_request( + &callbacks, port_session, + CBM_DAEMON_APPLICATION_UI_CONFIG_PORT, 0, 18432, 7); + + cbm_ui_config_t updated = {0}; + cbm_ui_config_load(&updated); + + if (enabled_session) { + callbacks.session_close(callbacks.context, enabled_session); + } + if (port_session) { + callbacks.session_close(callbacks.context, port_session); + } + bool stopped = application && + cbm_daemon_application_shutdown(application, + APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + free(context); + if (old_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", old_cache, 1); + } else { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(old_cache); + (void)th_rmtree(cache); + + ASSERT_TRUE(cache_ok); + ASSERT_TRUE(env_ok); + ASSERT_TRUE(initial_saved); + ASSERT_NOT_NULL(application); + ASSERT_NOT_NULL(enabled_session); + ASSERT_NOT_NULL(port_session); + ASSERT_TRUE(contexts_set); + ASSERT_EQ(enabled_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_EQ(port_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(updated.ui_enabled); + ASSERT_EQ(updated.ui_port, 18432); + ASSERT_TRUE(stopped); + PASS(); +} + +TEST(daemon_application_ui_config_rejects_noncanonical_frames) { + char cache[APP_TEST_PATH_CAP]; + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-ui-frame-XXXXXX", + cbm_tmpdir()); + bool cache_ok = cbm_mkdtemp(cache) != NULL; + char *old_cache = getenv("CBM_CACHE_DIR") + ? strdup(getenv("CBM_CACHE_DIR")) + : NULL; + bool env_ok = cache_ok && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + cbm_ui_config_t initial = {.ui_enabled = false, .ui_port = 9749}; + bool initial_saved = env_ok && cbm_ui_config_save(&initial); + + cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = + app_test_open(&callbacks, 303); + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *context_response = NULL; + uint32_t context_response_length = 0; + bool context_encoded = cache_ok && app_test_context_request( + cache, cache, &context, + &context_length); + bool context_set = context_encoded && session && + app_test_request( + &callbacks, session, context, context_length, + &context_response, &context_response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(context_response); + + cbm_daemon_runtime_application_status_t short_frame = + app_test_ui_config_request( + &callbacks, session, + CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 1, 0, 6); + cbm_daemon_runtime_application_status_t empty_mask = + app_test_ui_config_request(&callbacks, session, 0, 0, 0, 7); + cbm_daemon_runtime_application_status_t unknown_mask = + app_test_ui_config_request(&callbacks, session, 0x80, 0, 0, 7); + cbm_daemon_runtime_application_status_t invalid_boolean = + app_test_ui_config_request( + &callbacks, session, + CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 2, 0, 7); + cbm_daemon_runtime_application_status_t invalid_port = + app_test_ui_config_request( + &callbacks, session, + CBM_DAEMON_APPLICATION_UI_CONFIG_PORT, 0, 65536, 7); + cbm_daemon_runtime_application_status_t nonzero_unused = + app_test_ui_config_request( + &callbacks, session, + CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 1, 9749, 7); + + cbm_ui_config_t unchanged = {0}; + cbm_ui_config_load(&unchanged); + if (session) { + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && + cbm_daemon_application_shutdown(application, + APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + free(context); + if (old_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", old_cache, 1); + } else { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(old_cache); + (void)th_rmtree(cache); + + ASSERT_TRUE(cache_ok); + ASSERT_TRUE(env_ok); + ASSERT_TRUE(initial_saved); + ASSERT_NOT_NULL(application); + ASSERT_NOT_NULL(session); + ASSERT_TRUE(context_set); + ASSERT_EQ(short_frame, CBM_DAEMON_RUNTIME_APPLICATION_REJECTED); + ASSERT_EQ(empty_mask, CBM_DAEMON_RUNTIME_APPLICATION_REJECTED); + ASSERT_EQ(unknown_mask, CBM_DAEMON_RUNTIME_APPLICATION_REJECTED); + ASSERT_EQ(invalid_boolean, CBM_DAEMON_RUNTIME_APPLICATION_REJECTED); + ASSERT_EQ(invalid_port, CBM_DAEMON_RUNTIME_APPLICATION_REJECTED); + ASSERT_EQ(nonzero_unused, CBM_DAEMON_RUNTIME_APPLICATION_REJECTED); + ASSERT_FALSE(unchanged.ui_enabled); + ASSERT_EQ(unchanged.ui_port, 9749); + ASSERT_TRUE(stopped); + PASS(); +} + +TEST(daemon_application_requires_immutable_explicit_context) { + cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 1); + char root[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-context-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + uint8_t *mcp = NULL; + uint32_t mcp_length = 0; + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool mcp_ok = app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}", &mcp, + &mcp_length); + cbm_daemon_runtime_application_status_t before = + app_test_request(&callbacks, session, mcp, mcp_length, &response, &response_length); + free(response); + response = NULL; + bool context_ok = root_ok && app_test_context_request(root, root, &context, &context_length); + cbm_daemon_runtime_application_status_t first = + context_ok ? app_test_request(&callbacks, session, context, context_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + free(response); + response = NULL; + cbm_daemon_runtime_application_status_t repeated = + context_ok ? app_test_request(&callbacks, session, context, context_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + free(response); + response = NULL; + cbm_daemon_runtime_application_status_t after = + app_test_request(&callbacks, session, mcp, mcp_length, &response, &response_length); + bool ping_response = + response && response_length > 0 && strstr((const char *)response, "\"result\":{}") != NULL; + + callbacks.session_close(callbacks.context, session); + (void)cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + free(mcp); + free(context); + free(response); + (void)cbm_rmdir(root); + + ASSERT_TRUE(application != NULL); + ASSERT_TRUE(session != NULL); + ASSERT_TRUE(mcp_ok); + ASSERT_EQ(before, CBM_DAEMON_RUNTIME_APPLICATION_REJECTED); + ASSERT_EQ(first, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_EQ(repeated, CBM_DAEMON_RUNTIME_APPLICATION_REJECTED); + ASSERT_EQ(after, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(ping_response); + PASS(); +} + +TEST(daemon_application_mcp_notification_has_no_response) { + cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 2); + char root[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-notify-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *notification = NULL; + uint32_t notification_length = 0; + uint8_t *response = (uint8_t *)(uintptr_t)1; + uint32_t response_length = UINT32_MAX; + bool encoded = + root_ok && app_test_context_request(root, NULL, &context, &context_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}", + ¬ification, ¬ification_length); + cbm_daemon_runtime_application_status_t context_status = + encoded ? app_test_request(&callbacks, session, context, context_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + free(response); + response = (uint8_t *)(uintptr_t)1; + response_length = UINT32_MAX; + cbm_daemon_runtime_application_status_t notification_status = + encoded ? app_test_request(&callbacks, session, notification, notification_length, + &response, &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + + callbacks.session_close(callbacks.context, session); + cbm_daemon_application_free(application); + free(context); + free(notification); + (void)cbm_rmdir(root); + + ASSERT_TRUE(encoded); + ASSERT_EQ(context_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_EQ(notification_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(response == NULL); + ASSERT_EQ(response_length, 0); + PASS(); +} + +static int app_test_index_noop(const char *project_name, const char *root_path, void *context) { + (void)project_name; + (void)root_path; + (void)context; + return 0; +} + +static bool app_test_create_empty_file(const char *path) { + FILE *file = path ? cbm_fopen(path, "wb") : NULL; + return file && fclose(file) == 0; +} + +/* Rebase guard: restricted MCP profiles are a property of one authenticated + * daemon session. Before profile propagation, the daemon silently created a + * full-surface MCP server, subscribed that session to the shared watcher, and + * still accepted raw UI mutations. */ +TEST(daemon_application_restricted_profile_owns_no_background_surfaces) { + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), + "%s/cbm-app-profile-root-XXXXXX", cbm_tmpdir()); + (void)snprintf(cache, sizeof(cache), + "%s/cbm-app-profile-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; + bool env_ok = dirs_ok && (!had_cache || saved_cache) && + cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + cbm_ui_config_t initial_ui = {.ui_enabled = false, .ui_port = 9749}; + bool ui_ready = env_ok && cbm_ui_config_save(&initial_ui); + char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL; + char db_path[APP_TEST_PATH_CAP] = {0}; + if (project) { + (void)snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, + project); + } + bool db_ok = project && app_test_create_empty_file(db_path); + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *watcher = + cbm_watcher_new(store, app_test_index_noop, NULL); + cbm_daemon_application_config_t config = {.watcher = watcher}; + cbm_daemon_application_t *application = + cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = + app_test_open(&callbacks, 304); + + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *initialize = NULL; + uint32_t initialize_length = 0; + uint8_t *index_call = NULL; + uint32_t index_call_length = 0; + bool encoded = db_ok && session && + app_test_context_request_options( + root, root, CBM_MCP_TOOL_PROFILE_SCOUT, NULL, NULL, + &context, &context_length) && + app_test_text_request( + CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}", + &initialize, &initialize_length) && + app_test_text_request( + CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\",\"arguments\":{}}}", + &index_call, &index_call_length); + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t context_status = + encoded ? app_test_request(&callbacks, session, context, + context_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + free(response); + response = NULL; + cbm_daemon_runtime_application_status_t initialize_status = + context_status == CBM_DAEMON_RUNTIME_APPLICATION_OK + ? app_test_request(&callbacks, session, initialize, + initialize_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + bool scout_surface = + response && strstr((const char *)response, "scout tool profile") && + !strstr((const char *)response, "index_repository"); + free(response); + response = NULL; + cbm_daemon_runtime_application_status_t index_status = + initialize_status == CBM_DAEMON_RUNTIME_APPLICATION_OK + ? app_test_request(&callbacks, session, index_call, + index_call_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + bool index_refused = + response && strstr((const char *)response, + "not available in the scout tool profile"); + free(response); + response = NULL; + cbm_daemon_runtime_application_status_t ui_status = + app_test_ui_config_request( + &callbacks, session, + CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 1, 0, 7); + int watch_count = watcher ? cbm_watcher_watch_count(watcher) : -1; + size_t active_jobs = application + ? cbm_daemon_application_active_jobs(application) + : SIZE_MAX; + cbm_ui_config_t final_ui = {0}; + cbm_ui_config_load(&final_ui); + + if (session) { + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && cbm_daemon_application_shutdown( + application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + if (watcher) { + cbm_watcher_stop(watcher); + cbm_watcher_free(watcher); + } + cbm_store_close(store); + free(context); + free(initialize); + free(index_call); + free(project); + (void)cbm_unlink(db_path); + (void)cbm_rmdir(root); + if (had_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1); + } else { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(saved_cache); + (void)th_rmtree(cache); + + ASSERT_TRUE(dirs_ok); + ASSERT_TRUE(env_ok); + ASSERT_TRUE(ui_ready); + ASSERT_TRUE(db_ok); + ASSERT_TRUE(encoded); + ASSERT_EQ(context_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_EQ(initialize_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(scout_surface); + ASSERT_EQ(index_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(index_refused); + ASSERT_EQ(ui_status, CBM_DAEMON_RUNTIME_APPLICATION_REJECTED); + ASSERT_EQ(watch_count, 0); + ASSERT_EQ(active_jobs, 0); + ASSERT_FALSE(final_ui.ui_enabled); + ASSERT_TRUE(stopped); + PASS(); +} + +/* The thin hook process parses CLI metadata, but the daemon owns the MCP + * session and therefore must retain that metadata with the session context. + * Copilot deliberately omits the event from stdin, making this non-vacuous. */ +TEST(daemon_application_hook_context_preserves_event_and_dialect) { + char root[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), + "%s/cbm-app-hook-context-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + cbm_daemon_application_t *application = + cbm_daemon_application_new(NULL); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = + app_test_open(&callbacks, 305); + uint8_t *context = NULL; + uint32_t context_length = 0; + char input[APP_TEST_PATH_CAP + 32]; + (void)snprintf(input, sizeof(input), "{\"cwd\":\"%s\"}", root); + uint8_t *hook = NULL; + uint32_t hook_length = 0; + bool encoded = root_ok && session && + app_test_context_request_options( + root, root, CBM_MCP_TOOL_PROFILE_ALL, "SessionStart", + "copilot", &context, &context_length) && + app_test_text_request( + CBM_DAEMON_APPLICATION_REQUEST_HOOK_AUGMENT, input, + &hook, &hook_length); + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t context_status = + encoded ? app_test_request(&callbacks, session, context, + context_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + free(response); + response = NULL; + cbm_daemon_runtime_application_status_t hook_status = + context_status == CBM_DAEMON_RUNTIME_APPLICATION_OK + ? app_test_request(&callbacks, session, hook, hook_length, + &response, &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + bool copilot_shape = + response && strstr((const char *)response, "additionalContext") && + strstr((const char *)response, "Session context") && + !strstr((const char *)response, "hookSpecificOutput"); + + free(response); + free(context); + free(hook); + if (session) { + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && cbm_daemon_application_shutdown( + application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + if (root_ok) { + (void)cbm_rmdir(root); + } + + ASSERT_TRUE(root_ok); + ASSERT_TRUE(encoded); + ASSERT_EQ(context_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_EQ(hook_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(copilot_shape); + ASSERT_TRUE(stopped); + PASS(); +} + +TEST(daemon_application_reference_counts_one_shared_watch) { + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + bool saved_cache_ok = !had_cache || saved_cache; + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-watch-root-XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm-app-watch-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; + bool env_ok = saved_cache_ok && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL; + char db_path[APP_TEST_PATH_CAP] = {0}; + FILE *db = NULL; + if (project) { + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + db = cbm_fopen(db_path, "wb"); + } + bool db_ok = db && fclose(db) == 0; + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL); + cbm_daemon_application_config_t config = {.watcher = watcher, .config = NULL}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *first_session = app_test_open(&callbacks, 10); + cbm_daemon_runtime_application_session_t *second_session = app_test_open(&callbacks, 11); + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool encoded = dirs_ok && db_ok && + app_test_context_request(root, root, &context, &context_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"ping\"}", + &ping, &ping_length); + bool requests_ok = encoded; + cbm_daemon_runtime_application_session_t *sessions[2] = {first_session, second_session}; + for (size_t i = 0; requests_ok && i < 2; i++) { + requests_ok = app_test_request(&callbacks, sessions[i], context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK && + app_test_request(&callbacks, sessions[i], ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + } + int shared_count = cbm_watcher_watch_count(watcher); + callbacks.session_cancel(callbacks.context, first_session); + int after_first_cancel = cbm_watcher_watch_count(watcher); + callbacks.session_cancel(callbacks.context, second_session); + int after_final_cancel = cbm_watcher_watch_count(watcher); + callbacks.session_close(callbacks.context, first_session); + int after_first_close = cbm_watcher_watch_count(watcher); + callbacks.session_close(callbacks.context, second_session); + int after_final_close = cbm_watcher_watch_count(watcher); + + cbm_daemon_application_free(application); + cbm_watcher_stop(watcher); + cbm_watcher_free(watcher); + cbm_store_close(store); + free(context); + free(ping); + free(response); + free(project); + (void)cbm_unlink(db_path); + (void)cbm_rmdir(root); + (void)cbm_rmdir(cache); + if (saved_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1); + } else if (!had_cache) { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(saved_cache); + + ASSERT_TRUE(env_ok); + ASSERT_TRUE(requests_ok); + ASSERT_EQ(shared_count, 1); + ASSERT_EQ(after_first_cancel, 1); + ASSERT_EQ(after_final_cancel, 0); + ASSERT_EQ(after_first_close, 0); + ASSERT_EQ(after_final_close, 0); + PASS(); +} + +TEST(daemon_application_free_releases_live_watch_once) { + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + const char *old_grace = getenv("CBM_WATCHER_PRUNE_GRACE_S"); + bool had_grace = old_grace != NULL; + char *saved_grace = old_grace ? cbm_strdup(old_grace) : NULL; + char root[APP_TEST_PATH_CAP]; + char survivor_root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-free-watch-root-XXXXXX", cbm_tmpdir()); + snprintf(survivor_root, sizeof(survivor_root), + "%s/cbm-app-free-survivor-root-XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm-app-free-watch-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(survivor_root) != NULL && + cbm_mkdtemp(cache) != NULL; + bool env_ok = (!had_cache || saved_cache) && (!had_grace || saved_grace) && + cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 && + cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", "0", 1) == 0; + char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL; + char *survivor_project = dirs_ok ? cbm_project_name_from_path(survivor_root) : NULL; + char db_path[APP_TEST_PATH_CAP] = {0}; + char survivor_db_path[APP_TEST_PATH_CAP] = {0}; + if (project) { + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + } + if (survivor_project) { + snprintf(survivor_db_path, sizeof(survivor_db_path), "%s/%s.db", cache, + survivor_project); + } + bool db_ok = app_test_create_empty_file(db_path) && + app_test_create_empty_file(survivor_db_path); + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL); + cbm_daemon_application_config_t config = {.watcher = watcher}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 12); + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool encoded = dirs_ok && env_ok && db_ok && session && + app_test_context_request(root, root, &context, &context_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"ping\"}", + &ping, &ping_length); + bool requested = encoded && + app_test_request(&callbacks, session, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + requested = requested && + app_test_request(&callbacks, session, ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + bool watch_live = requested && cbm_watcher_watch_count(watcher) == 1; + + /* Keep one watcher entry outside the application's subscription table. + * It survives application teardown and then enters the destructive prune + * path. If application_free leaves its borrowed callbacks installed, the + * third poll calls through the freed application context (caught by ASan). */ + if (watch_live) { + cbm_watcher_watch(watcher, survivor_project, survivor_root); + } + bool survivor_live = watch_live && cbm_watcher_watch_count(watcher) == 2; + bool survivor_removed = survivor_live && cbm_rmdir(survivor_root) == 0; + + /* Deliberately leave the session open: application teardown owns both + * the logical session and its one physical watch. ASan catches a second + * release of the detached watch node. */ + cbm_daemon_application_free(application); + bool survivor_still_watched = cbm_watcher_watch_count(watcher) == 1; + for (int miss = 0; survivor_removed && miss < 3; miss++) { + cbm_watcher_touch(watcher, survivor_project); + (void)cbm_watcher_poll_once(watcher); + } + bool post_free_poll_safe = survivor_removed && cbm_watcher_watch_count(watcher) == 0 && + !cbm_file_exists(survivor_db_path); + cbm_watcher_stop(watcher); + cbm_watcher_free(watcher); + cbm_store_close(store); + free(context); + free(ping); + free(response); + free(project); + free(survivor_project); + (void)cbm_unlink(db_path); + (void)cbm_unlink(survivor_db_path); + (void)cbm_rmdir(root); + (void)cbm_rmdir(survivor_root); + (void)cbm_rmdir(cache); + if (saved_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1); + } else if (!had_cache) { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + if (saved_grace) { + (void)cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", saved_grace, 1); + } else if (!had_grace) { + (void)cbm_unsetenv("CBM_WATCHER_PRUNE_GRACE_S"); + } + free(saved_cache); + free(saved_grace); + + ASSERT_TRUE(encoded); + ASSERT_TRUE(requested); + ASSERT_TRUE(watch_live); + ASSERT_TRUE(survivor_live); + ASSERT_TRUE(survivor_still_watched); + ASSERT_TRUE(post_free_poll_safe); + PASS(); +} + +TEST(daemon_application_prune_clears_logical_watch_for_reregistration) { + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + const char *old_grace = getenv("CBM_WATCHER_PRUNE_GRACE_S"); + bool had_grace = old_grace != NULL; + char *saved_grace = old_grace ? cbm_strdup(old_grace) : NULL; + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-prune-watch-root-XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm-app-prune-watch-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; + bool env_ok = (!had_cache || saved_cache) && (!had_grace || saved_grace) && + cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 && + cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", "0", 1) == 0; + char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL; + char db_path[APP_TEST_PATH_CAP] = {0}; + char wal_path[APP_TEST_PATH_CAP] = {0}; + char shm_path[APP_TEST_PATH_CAP] = {0}; + if (project) { + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(wal_path, sizeof(wal_path), "%s/%s.db-wal", cache, project); + snprintf(shm_path, sizeof(shm_path), "%s/%s.db-shm", cache, project); + } + bool files_ok = app_test_create_empty_file(db_path) && + app_test_create_empty_file(wal_path) && + app_test_create_empty_file(shm_path); + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL); + cbm_daemon_application_config_t config = {.watcher = watcher}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *first = app_test_open(&callbacks, 13); + cbm_daemon_runtime_application_session_t *second = NULL; + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool encoded = dirs_ok && env_ok && files_ok && store && watcher && application && first && + app_test_context_request(root, root, &context, &context_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"ping\"}", + &ping, &ping_length); + bool first_registered = + encoded && app_test_request(&callbacks, first, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + first_registered = + first_registered && app_test_request(&callbacks, first, ping, ping_length, &response, + &response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK && + cbm_watcher_watch_count(watcher) == 1; + free(response); + response = NULL; + + bool root_removed = first_registered && cbm_rmdir(root) == 0; + for (int miss = 0; root_removed && miss < 3; miss++) { + cbm_watcher_touch(watcher, project); + (void)cbm_watcher_poll_once(watcher); + } + bool pruned = root_removed && cbm_watcher_watch_count(watcher) == 0 && + !cbm_file_exists(db_path) && !cbm_file_exists(wal_path) && + !cbm_file_exists(shm_path); + + bool restored = pruned && cbm_mkdir_p(root, 0755) && + app_test_create_empty_file(db_path); + if (restored) { + second = app_test_open(&callbacks, 14); + } + bool second_registered = + second && app_test_request(&callbacks, second, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + second_registered = + second_registered && app_test_request(&callbacks, second, ping, ping_length, &response, + &response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK && + cbm_watcher_watch_count(watcher) == 1; + free(response); + response = NULL; + + if (first) { + callbacks.session_close(callbacks.context, first); + first = NULL; + } + bool first_close_kept_reregistered_watch = + second_registered && cbm_watcher_watch_count(watcher) == 1; + if (second) { + callbacks.session_close(callbacks.context, second); + second = NULL; + } + bool final_close_released_watch = cbm_watcher_watch_count(watcher) == 0; + + cbm_daemon_application_free(application); + cbm_watcher_stop(watcher); + cbm_watcher_free(watcher); + cbm_store_close(store); + free(context); + free(ping); + free(response); + free(project); + (void)cbm_unlink(db_path); + (void)cbm_unlink(wal_path); + (void)cbm_unlink(shm_path); + (void)cbm_rmdir(root); + (void)cbm_rmdir(cache); + if (saved_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1); + } else if (!had_cache) { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + if (saved_grace) { + (void)cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", saved_grace, 1); + } else if (!had_grace) { + (void)cbm_unsetenv("CBM_WATCHER_PRUNE_GRACE_S"); + } + free(saved_cache); + free(saved_grace); + + ASSERT_TRUE(encoded); + ASSERT_TRUE(first_registered); + ASSERT_TRUE(pruned); + ASSERT_TRUE(restored); + ASSERT_TRUE(second_registered); + ASSERT_TRUE(first_close_kept_reregistered_watch); + ASSERT_TRUE(final_close_released_watch); + PASS(); +} + +enum { APP_FAKE_MAX_ATTEMPTS = 16 }; + +typedef struct { + atomic_int starts; + atomic_int cancels; + atomic_int destroys; + atomic_bool allow_completion; + atomic_bool unsafe_clean; + atomic_bool scripted; + atomic_int hold_destroy_attempt; + atomic_bool release_destroy; + atomic_int project_lock_attempts; + atomic_int project_lock_acquisitions; + cbm_project_lock_manager_t *project_locks; + bool project_lock_busy_is_error; + cbm_proc_outcome_t outcomes[APP_FAKE_MAX_ATTEMPTS]; + const char *responses[APP_FAKE_MAX_ATTEMPTS]; + const char *marker_payloads[APP_FAKE_MAX_ATTEMPTS]; + char marker_paths[APP_FAKE_MAX_ATTEMPTS][APP_TEST_PATH_CAP]; + char quarantine_paths[APP_FAKE_MAX_ATTEMPTS][APP_TEST_PATH_CAP]; + char quarantine_seen[APP_FAKE_MAX_ATTEMPTS][APP_TEST_PATH_CAP]; + size_t memory_budgets[APP_FAKE_MAX_ATTEMPTS]; +} app_fake_worker_context_t; + +typedef struct { + app_fake_worker_context_t *context; + int attempt; + atomic_bool cancelled; + char *project_key; + cbm_project_lock_lease_t *project_lock_lease; + cbm_index_worker_result_t result; +} app_fake_worker_t; + +static void app_fake_worker_context_init(app_fake_worker_context_t *context) { + memset(context, 0, sizeof(*context)); + atomic_init(&context->starts, 0); + atomic_init(&context->cancels, 0); + atomic_init(&context->destroys, 0); + atomic_init(&context->allow_completion, false); + atomic_init(&context->unsafe_clean, false); + atomic_init(&context->scripted, false); + atomic_init(&context->hold_destroy_attempt, -1); + atomic_init(&context->release_destroy, false); + atomic_init(&context->project_lock_attempts, 0); + atomic_init(&context->project_lock_acquisitions, 0); +} + +static void app_fake_worker_read_file(const char *path, char *out, size_t out_size) { + if (!path || !path[0] || !out || out_size == 0) { + return; + } + FILE *file = cbm_fopen(path, "rb"); + if (!file) { + return; + } + size_t read = fread(out, 1, out_size - 1U, file); + out[read] = '\0'; + (void)fclose(file); +} + +static int app_fake_worker_start(void *opaque, const char *args_json, + size_t memory_budget_bytes, const char *marker_file, + const char *quarantine_file, + cbm_daemon_application_worker_t *worker_out) { + app_fake_worker_context_t *context = opaque; + app_fake_worker_t *worker = calloc(1, sizeof(*worker)); + if (!worker) { + return -1; + } + if (context->project_locks) { + char *repo_path = cbm_mcp_get_string_arg(args_json, "repo_path"); + char *name_override = cbm_mcp_get_string_arg(args_json, "name"); + worker->project_key = cbm_project_name_from_path( + name_override && name_override[0] ? name_override : repo_path); + free(name_override); + free(repo_path); + if (!worker->project_key) { + free(worker); + return -1; + } + } + worker->context = context; + worker->attempt = atomic_fetch_add(&context->starts, 1); + atomic_init(&worker->cancelled, false); + worker->result.exit_code = -1; + if (worker->attempt < APP_FAKE_MAX_ATTEMPTS) { + context->memory_budgets[worker->attempt] = memory_budget_bytes; + if (marker_file) { + (void)snprintf(context->marker_paths[worker->attempt], APP_TEST_PATH_CAP, "%s", + marker_file); + } + if (quarantine_file) { + (void)snprintf(context->quarantine_paths[worker->attempt], APP_TEST_PATH_CAP, "%s", + quarantine_file); + app_fake_worker_read_file(quarantine_file, context->quarantine_seen[worker->attempt], + sizeof(context->quarantine_seen[worker->attempt])); + } + const char *payload = context->marker_payloads[worker->attempt]; + if (marker_file && payload) { + FILE *marker = cbm_fopen(marker_file, "ab"); + if (marker) { + (void)fputs(payload, marker); + (void)fclose(marker); + } + } + } + *worker_out = worker; + return 0; +} + +static cbm_index_worker_poll_t app_fake_worker_poll(void *opaque, + cbm_daemon_application_worker_t handle, + const cbm_index_worker_result_t **result_out) { + (void)opaque; + app_fake_worker_t *worker = handle; + *result_out = NULL; + if (atomic_load(&worker->cancelled)) { + worker->result.outcome = CBM_PROC_KILLED; + worker->result.cancellation_requested = true; + worker->result.tree_quiesced = true; + *result_out = &worker->result; + return CBM_INDEX_WORKER_POLL_TERMINAL; + } + if (worker->context->project_locks && !worker->project_lock_lease) { + cbm_project_lock_lease_t *lease = NULL; + atomic_fetch_add(&worker->context->project_lock_attempts, 1); + cbm_private_file_lock_status_t status = cbm_project_lock_try_acquire( + worker->context->project_locks, worker->project_key, &lease); + if (status == CBM_PRIVATE_FILE_LOCK_OK && lease) { + worker->project_lock_lease = lease; + atomic_fetch_add(&worker->context->project_lock_acquisitions, 1); + } else { + (void)app_project_lock_release(&lease); + if (status == CBM_PRIVATE_FILE_LOCK_BUSY && + !worker->context->project_lock_busy_is_error) { + return CBM_INDEX_WORKER_POLL_RUNNING; + } + return CBM_INDEX_WORKER_POLL_ERROR; + } + } + if (atomic_load(&worker->context->scripted)) { + cbm_proc_outcome_t outcome = worker->attempt < APP_FAKE_MAX_ATTEMPTS + ? worker->context->outcomes[worker->attempt] + : CBM_PROC_SPAWN_FAILED; + worker->result.outcome = outcome; + worker->result.exit_code = outcome == CBM_PROC_CLEAN ? 0 : -1; + worker->result.tree_quiesced = true; + const char *response = + worker->attempt < APP_FAKE_MAX_ATTEMPTS && + worker->context->responses[worker->attempt] + ? worker->context->responses[worker->attempt] + : "{\"content\":[{\"type\":\"text\",\"text\":\"{" + "\\\"status\\\":\\\"indexed\\\"}\"}]}"; + worker->result.response = + outcome == CBM_PROC_CLEAN ? cbm_strdup(response) : NULL; + *result_out = &worker->result; + return CBM_INDEX_WORKER_POLL_TERMINAL; + } + if (atomic_load(&worker->context->allow_completion)) { + worker->result.outcome = CBM_PROC_CLEAN; + worker->result.exit_code = 0; + worker->result.tree_quiesced = !atomic_load(&worker->context->unsafe_clean); + worker->result.supervision_failed = atomic_load(&worker->context->unsafe_clean); + worker->result.response = cbm_strdup( + "{\"content\":[{\"type\":\"text\",\"text\":\"{\\\"status\\\":\\\"indexed\\\"}\"}]}"); + *result_out = &worker->result; + return CBM_INDEX_WORKER_POLL_TERMINAL; + } + return CBM_INDEX_WORKER_POLL_RUNNING; +} + +static bool app_fake_worker_cancel(void *opaque, cbm_daemon_application_worker_t handle) { + app_fake_worker_context_t *context = opaque; + app_fake_worker_t *worker = handle; + bool first = !atomic_exchange(&worker->cancelled, true); + if (first) { + atomic_fetch_add(&context->cancels, 1); + } + return true; +} + +static const char *app_fake_worker_log_path(void *opaque, cbm_daemon_application_worker_t handle) { + (void)opaque; + (void)handle; + return "/tmp/cbm-fake-worker.log"; +} + +static void app_fake_worker_destroy(void *opaque, cbm_daemon_application_worker_t handle) { + app_fake_worker_context_t *context = opaque; + app_fake_worker_t *worker = handle; + atomic_fetch_add(&context->destroys, 1); + while (worker->attempt == atomic_load(&context->hold_destroy_attempt) && + !atomic_load(&context->release_destroy)) { + cbm_usleep(1000); + } + (void)app_project_lock_release(&worker->project_lock_lease); + free(worker->project_key); + free(handle); +} + +typedef struct { + cbm_daemon_runtime_application_callbacks_t callbacks; + cbm_daemon_runtime_application_session_t *session; + cbm_daemon_runtime_application_token_t request_token; + const uint8_t *request; + uint32_t request_length; + cbm_daemon_runtime_application_status_t status; + uint8_t *response; + uint32_t response_length; + atomic_bool done; +} app_request_thread_t; + +static void *app_request_thread(void *opaque) { + app_request_thread_t *request = opaque; + request->status = request->request_token == + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID + ? app_test_request( + &request->callbacks, request->session, + request->request, request->request_length, + &request->response, &request->response_length) + : app_test_request_tagged( + &request->callbacks, request->session, + request->request_token, request->request, + request->request_length, &request->response, + &request->response_length); + atomic_store(&request->done, true); + return NULL; +} + +static bool app_wait_for_subscribers(cbm_daemon_application_t *application, const char *project, + size_t expected) { + uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (cbm_now_ms() < deadline) { + if (cbm_daemon_application_job_subscribers(application, project) == expected) { + return true; + } + cbm_usleep(1000); + } + return false; +} + +static bool app_wait_for_atomic_int(atomic_int *value, int expected) { + uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (cbm_now_ms() < deadline) { + if (atomic_load(value) == expected) { + return true; + } + cbm_usleep(1000); + } + return false; +} + +static bool app_wait_for_atomic_int_at_least(atomic_int *value, int minimum) { + uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (cbm_now_ms() < deadline) { + if (atomic_load(value) >= minimum) { + return true; + } + cbm_usleep(1000); + } + return false; +} + +static bool app_wait_for_atomic_bool(atomic_bool *value, bool expected) { + uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (cbm_now_ms() < deadline) { + if (atomic_load(value) == expected) { + return true; + } + cbm_usleep(1000); + } + return false; +} + +static bool app_wait_for_active_jobs(cbm_daemon_application_t *application, size_t expected) { + uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (cbm_now_ms() < deadline) { + if (cbm_daemon_application_active_jobs(application) == expected) { + return true; + } + cbm_usleep(1000); + } + return false; +} + +static bool app_wait_for_terminal_job_with_subscribers( + cbm_daemon_application_t *application, const char *project, size_t minimum_subscribers) { + uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (cbm_now_ms() < deadline) { + if (cbm_daemon_application_active_jobs(application) == 0 && + cbm_daemon_application_job_subscribers(application, project) >= + minimum_subscribers) { + return true; + } + } + return false; +} + +typedef struct { + cbm_daemon_application_t *application; + const char *project; + const char *root; + int result; +} app_index_thread_t; + +static void *app_index_thread(void *opaque) { + app_index_thread_t *request = opaque; + request->result = + cbm_daemon_application_index(request->application, request->project, request->root); + return NULL; +} + +typedef struct { + bool had_cache; + char *saved_cache; + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + char db_path[APP_TEST_PATH_CAP]; + char *project; + app_fake_worker_context_t fake; + cbm_store_t *store; + cbm_watcher_t *watcher; + cbm_daemon_application_t *application; + cbm_daemon_runtime_application_callbacks_t callbacks; + cbm_daemon_runtime_application_session_t *session; +} app_watch_race_fixture_t; + +static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture, + cbm_daemon_client_id_t client_id) { + memset(fixture, 0, sizeof(*fixture)); + app_fake_worker_context_init(&fixture->fake); + const char *old_cache = getenv("CBM_CACHE_DIR"); + fixture->had_cache = old_cache != NULL; + fixture->saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + bool saved_cache_ok = !fixture->had_cache || fixture->saved_cache; + (void)snprintf(fixture->root, sizeof(fixture->root), + "%s/cbm-app-watch-race-root-XXXXXX", cbm_tmpdir()); + (void)snprintf(fixture->cache, sizeof(fixture->cache), + "%s/cbm-app-watch-race-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(fixture->root) != NULL && + cbm_mkdtemp(fixture->cache) != NULL; + bool env_ok = saved_cache_ok && + cbm_setenv("CBM_CACHE_DIR", fixture->cache, 1) == 0; + fixture->project = dirs_ok ? cbm_project_name_from_path(fixture->root) : NULL; + if (fixture->project) { + (void)snprintf(fixture->db_path, sizeof(fixture->db_path), "%s/%s.db", + fixture->cache, fixture->project); + } + bool db_ok = fixture->project && + app_test_create_empty_file(fixture->db_path); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fixture->fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + fixture->store = cbm_store_open_memory(); + fixture->watcher = + cbm_watcher_new(fixture->store, app_test_index_noop, NULL); + cbm_daemon_application_config_t config = { + .watcher = fixture->watcher, + .worker_ops = &worker_ops, + }; + fixture->application = cbm_daemon_application_new(&config); + fixture->callbacks = + cbm_daemon_application_runtime_callbacks(fixture->application); + if (fixture->application) { + fixture->session = app_test_open(&fixture->callbacks, client_id); + } + + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool encoded = env_ok && dirs_ok && db_ok && fixture->store && + fixture->watcher && fixture->application && fixture->session && + app_test_context_request(fixture->root, fixture->root, + &context, &context_length) && + app_test_text_request( + CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":19,\"method\":\"ping\"}", + &ping, &ping_length); + bool initialized = encoded && + app_test_request(&fixture->callbacks, fixture->session, + context, context_length, &response, + &response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + response_length = 0; + initialized = initialized && + app_test_request(&fixture->callbacks, fixture->session, ping, + ping_length, &response, &response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + free(context); + free(ping); + return initialized && cbm_watcher_watch_count(fixture->watcher) == 1; +} + +static bool app_watch_race_fixture_finish(app_watch_race_fixture_t *fixture) { + if (fixture->session) { + fixture->callbacks.session_close(fixture->callbacks.context, + fixture->session); + fixture->session = NULL; + } + bool stopped = !fixture->application || + cbm_daemon_application_shutdown(fixture->application, + APP_TEST_TIMEOUT_MS); + if (stopped) { + cbm_daemon_application_free(fixture->application); + fixture->application = NULL; + cbm_watcher_stop(fixture->watcher); + cbm_watcher_free(fixture->watcher); + fixture->watcher = NULL; + cbm_store_close(fixture->store); + fixture->store = NULL; + } + free(fixture->project); + fixture->project = NULL; + (void)th_rmtree(fixture->root); + (void)th_rmtree(fixture->cache); + bool restored = fixture->saved_cache + ? cbm_setenv("CBM_CACHE_DIR", fixture->saved_cache, 1) == 0 + : (!fixture->had_cache && + cbm_unsetenv("CBM_CACHE_DIR") == 0); + free(fixture->saved_cache); + fixture->saved_cache = NULL; + return stopped && restored; +} + +typedef struct { + cbm_daemon_application_t *application; + const char *project; + const char *root; + bool pause_before_subscribe; + atomic_bool ready; + atomic_bool proceed; + atomic_bool done; + int result; +} app_watcher_index_thread_t; + +static void *app_watcher_index_thread(void *opaque) { + app_watcher_index_thread_t *request = opaque; + if (request->pause_before_subscribe) { + atomic_store(&request->ready, true); + while (!atomic_load(&request->proceed)) { + cbm_usleep(1000); + } + } + request->result = cbm_daemon_application_watcher_index( + request->project, request->root, request->application); + atomic_store(&request->done, true); + return NULL; +} + +TEST(daemon_application_coalesces_identical_index_requests) { + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = { + .worker_ops = &worker_ops, + .physical_job_limit = 1, + }; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *sessions[2] = {app_test_open(&callbacks, 21), + app_test_open(&callbacks, 22)}; + char root[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-coalesce-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + char *project = root_ok ? cbm_project_name_from_path(root) : NULL; + uint8_t *context = NULL; + uint32_t context_length = 0; + char args[APP_TEST_PATH_CAP + 32]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", root); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + bool encoded = root_ok && project && + app_test_context_request(root, root, &context, &context_length) && + app_test_tool_request("index_repository", args, &tool, &tool_length); + bool contexts_ok = encoded; + uint8_t *empty = NULL; + uint32_t empty_length = 0; + for (size_t i = 0; contexts_ok && i < 2; i++) { + contexts_ok = app_test_request(&callbacks, sessions[i], context, context_length, &empty, + &empty_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(empty); + empty = NULL; + } + app_request_thread_t requests[2] = { + {.callbacks = callbacks, + .session = sessions[0], + .request = tool, + .request_length = tool_length}, + {.callbacks = callbacks, + .session = sessions[1], + .request = tool, + .request_length = tool_length}, + }; + cbm_thread_t threads[2]; + bool thread_started[2] = {false, false}; + if (contexts_ok) { + thread_started[0] = + cbm_thread_create(&threads[0], 0, app_request_thread, &requests[0]) == 0; + } + bool first_joined = thread_started[0] && app_wait_for_subscribers(application, project, 1); + if (first_joined) { + thread_started[1] = + cbm_thread_create(&threads[1], 0, app_request_thread, &requests[1]) == 0; + } + bool both_joined = thread_started[1] && app_wait_for_subscribers(application, project, 2); + atomic_store(&fake.allow_completion, true); + for (size_t i = 0; i < 2; i++) { + if (thread_started[i]) { + (void)cbm_thread_join(&threads[i]); + } + } + for (size_t i = 0; i < 2; i++) { + callbacks.session_close(callbacks.context, sessions[i]); + } + (void)cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + + ASSERT_TRUE(contexts_ok); + ASSERT_TRUE(first_joined); + ASSERT_TRUE(both_joined); + ASSERT_EQ(atomic_load(&fake.starts), 1); + ASSERT_EQ(atomic_load(&fake.cancels), 0); + ASSERT_EQ(atomic_load(&fake.destroys), 1); + ASSERT_EQ(requests[0].status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_EQ(requests[1].status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(requests[0].response && strstr((char *)requests[0].response, "indexed")); + ASSERT_TRUE(requests[1].response && strstr((char *)requests[1].response, "indexed")); + + free(requests[0].response); + free(requests[1].response); + free(context); + free(tool); + free(project); + (void)cbm_rmdir(root); + PASS(); +} + +TEST(daemon_application_fresh_request_does_not_reuse_terminal_subscribed_job) { + enum { PRIOR_SUBSCRIBERS = 16 }; + static const char stale_response[] = + "{\"content\":[{\"type\":\"text\",\"text\":\"stale-generation\"}]}"; + static const char fresh_response[] = + "{\"content\":[{\"type\":\"text\",\"text\":\"fresh-generation\"}]}"; + + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + atomic_store(&fake.scripted, true); + atomic_store(&fake.hold_destroy_attempt, 0); + fake.outcomes[0] = CBM_PROC_CLEAN; + fake.outcomes[1] = CBM_PROC_CLEAN; + fake.responses[0] = stale_response; + fake.responses[1] = fresh_response; + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *sessions[PRIOR_SUBSCRIBERS + 1] = {0}; + bool sessions_ok = application != NULL; + for (size_t i = 0; sessions_ok && i < PRIOR_SUBSCRIBERS + 1U; i++) { + sessions[i] = app_test_open(&callbacks, (cbm_daemon_client_id_t)(51U + i)); + sessions_ok = sessions[i] != NULL; + } + + char root[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-terminal-fresh-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + char *project = root_ok ? cbm_project_name_from_path(root) : NULL; + uint8_t *context = NULL; + uint32_t context_length = 0; + char args[APP_TEST_PATH_CAP + 32]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", root); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + bool setup = sessions_ok && root_ok && project && + app_test_context_request(root, root, &context, &context_length) && + app_test_tool_request("index_repository", args, &tool, &tool_length); + for (size_t i = 0; setup && i < PRIOR_SUBSCRIBERS + 1U; i++) { + uint8_t *empty = NULL; + uint32_t empty_length = 0; + setup = app_test_request(&callbacks, sessions[i], context, context_length, &empty, + &empty_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(empty); + } + + app_request_thread_t prior[PRIOR_SUBSCRIBERS]; + cbm_thread_t threads[PRIOR_SUBSCRIBERS]; + bool started[PRIOR_SUBSCRIBERS] = {0}; + memset(prior, 0, sizeof(prior)); + bool all_subscribed = setup; + for (size_t i = 0; all_subscribed && i < PRIOR_SUBSCRIBERS; i++) { + prior[i] = (app_request_thread_t){ + .callbacks = callbacks, + .session = sessions[i], + .request = tool, + .request_length = tool_length, + }; + atomic_init(&prior[i].done, false); + started[i] = cbm_thread_create(&threads[i], 0, app_request_thread, &prior[i]) == 0; + all_subscribed = started[i] && + app_wait_for_subscribers(application, project, i + 1U); + if (all_subscribed) { + cbm_usleep(1000); + } + } + bool first_worker_ready_to_publish = + all_subscribed && app_wait_for_atomic_int(&fake.destroys, 1); + atomic_store(&fake.release_destroy, true); + bool terminal_with_prior_subscribers = + first_worker_ready_to_publish && app_wait_for_terminal_job_with_subscribers( + application, project, PRIOR_SUBSCRIBERS / 2U); + + uint8_t *fresh = NULL; + uint32_t fresh_length = 0; + cbm_daemon_runtime_application_status_t fresh_status = + terminal_with_prior_subscribers + ? app_test_request(&callbacks, sessions[PRIOR_SUBSCRIBERS], tool, tool_length, &fresh, + &fresh_length) + : CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + for (size_t i = 0; i < PRIOR_SUBSCRIBERS; i++) { + if (started[i]) { + (void)cbm_thread_join(&threads[i]); + } + } + for (size_t i = 0; i < PRIOR_SUBSCRIBERS + 1U; i++) { + if (sessions[i]) { + callbacks.session_close(callbacks.context, sessions[i]); + } + } + bool stopped = + application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + + ASSERT_TRUE(setup); + ASSERT_TRUE(all_subscribed); + ASSERT_TRUE(first_worker_ready_to_publish); + ASSERT_TRUE(terminal_with_prior_subscribers); + ASSERT_EQ(fresh_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_EQ(atomic_load(&fake.starts), 2); + ASSERT_EQ(atomic_load(&fake.destroys), 2); + ASSERT_TRUE(fresh && strstr((char *)fresh, "fresh-generation")); + ASSERT_TRUE(!fresh || !strstr((char *)fresh, "stale-generation")); + ASSERT_TRUE(!fresh || !strstr((char *)fresh, "different options")); + ASSERT_TRUE(stopped); + for (size_t i = 0; i < PRIOR_SUBSCRIBERS; i++) { + ASSERT_EQ(prior[i].status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(prior[i].response && strstr((char *)prior[i].response, "stale-generation")); + free(prior[i].response); + } + + free(fresh); + free(context); + free(tool); + free(project); + (void)cbm_rmdir(root); + PASS(); +} + +TEST(daemon_application_request_cancel_detaches_only_one_coalesced_subscriber) { + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = + cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *sessions[2] = { + app_test_open(&callbacks, 3011), app_test_open(&callbacks, 3012)}; + char root[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), + "%s/cbm-app-request-cancel-coalesce-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + char *project = root_ok ? cbm_project_name_from_path(root) : NULL; + uint8_t *context = NULL; + uint32_t context_length = 0; + char args[APP_TEST_PATH_CAP + 32]; + (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", root); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + bool setup = application && sessions[0] && sessions[1] && root_ok && + project && + app_test_context_request(root, root, &context, + &context_length) && + app_test_tool_request("index_repository", args, &tool, + &tool_length); + uint8_t *empty = NULL; + uint32_t empty_length = 0; + for (size_t i = 0; setup && i < 2; i++) { + setup = app_test_request(&callbacks, sessions[i], context, + context_length, &empty, &empty_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(empty); + empty = NULL; + empty_length = 0; + } + + app_request_thread_t requests[2] = { + {.callbacks = callbacks, + .session = sessions[0], + .request_token = UINT64_C(8101), + .request = tool, + .request_length = tool_length}, + {.callbacks = callbacks, + .session = sessions[1], + .request_token = UINT64_C(8102), + .request = tool, + .request_length = tool_length}, + }; + atomic_init(&requests[0].done, false); + atomic_init(&requests[1].done, false); + cbm_thread_t threads[2]; + bool started[2] = {false, false}; + if (setup) { + started[0] = + cbm_thread_create(&threads[0], 0, app_request_thread, + &requests[0]) == 0; + } + bool first_joined = started[0] && + app_wait_for_subscribers(application, project, 1); + if (first_joined) { + started[1] = + cbm_thread_create(&threads[1], 0, app_request_thread, + &requests[1]) == 0; + } + bool both_joined = started[1] && + app_wait_for_subscribers(application, project, 2); + if (both_joined) { + callbacks.request_cancel(callbacks.context, sessions[0], + requests[0].request_token); + } + bool one_left = both_joined && + app_wait_for_subscribers(application, project, 1); + bool cancelled_returned = + one_left && app_wait_for_atomic_bool(&requests[0].done, true); + int cancels_after_first = atomic_load(&fake.cancels); + + /* Always release the fake worker so a failed RED assertion cannot strand + * either joinable request thread. */ + atomic_store(&fake.allow_completion, true); + bool thread_joined[2] = {false, false}; + for (size_t i = 0; i < 2; i++) { + if (started[i]) { + thread_joined[i] = cbm_thread_join(&threads[i]) == 0; + } + } + for (size_t i = 0; i < 2; i++) { + if (sessions[i]) { + callbacks.session_close(callbacks.context, sessions[i]); + } + } + bool stopped = application && cbm_daemon_application_shutdown( + application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + bool second_indexed = + requests[1].response && + strstr((char *)requests[1].response, "indexed") != NULL; + int final_cancels = atomic_load(&fake.cancels); + int final_starts = atomic_load(&fake.starts); + int final_destroys = atomic_load(&fake.destroys); + free(requests[0].response); + free(requests[1].response); + free(context); + free(tool); + free(project); + (void)cbm_rmdir(root); + + ASSERT_TRUE(setup); + ASSERT_TRUE(first_joined); + ASSERT_TRUE(both_joined); + ASSERT_TRUE(one_left); + ASSERT_TRUE(cancelled_returned); + ASSERT_EQ(cancels_after_first, 0); + ASSERT_TRUE(thread_joined[0]); + ASSERT_TRUE(thread_joined[1]); + ASSERT_EQ(requests[0].status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); + ASSERT_NULL(requests[0].response); + ASSERT_EQ(requests[0].response_length, 0); + ASSERT_EQ(requests[1].status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(second_indexed); + ASSERT_EQ(final_cancels, 0); + ASSERT_EQ(final_starts, 1); + ASSERT_EQ(final_destroys, 1); + ASSERT_TRUE(stopped); + PASS(); +} + +TEST(daemon_application_cancels_physical_job_only_after_final_session) { + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *sessions[2] = {app_test_open(&callbacks, 31), + app_test_open(&callbacks, 32)}; + char root[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-cancel-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + char *project = root_ok ? cbm_project_name_from_path(root) : NULL; + uint8_t *context = NULL; + uint32_t context_length = 0; + char args[APP_TEST_PATH_CAP + 32]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", root); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + bool setup = root_ok && project && + app_test_context_request(root, root, &context, &context_length) && + app_test_tool_request("index_repository", args, &tool, &tool_length); + uint8_t *empty = NULL; + uint32_t empty_length = 0; + for (size_t i = 0; setup && i < 2; i++) { + setup = app_test_request(&callbacks, sessions[i], context, context_length, &empty, + &empty_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(empty); + empty = NULL; + } + app_request_thread_t requests[2] = { + {.callbacks = callbacks, + .session = sessions[0], + .request = tool, + .request_length = tool_length}, + {.callbacks = callbacks, + .session = sessions[1], + .request = tool, + .request_length = tool_length}, + }; + cbm_thread_t threads[2]; + bool started[2] = {false, false}; + for (size_t i = 0; setup && i < 2; i++) { + started[i] = cbm_thread_create(&threads[i], 0, app_request_thread, &requests[i]) == 0; + } + bool subscribed = started[0] && started[1] && app_wait_for_subscribers(application, project, 2); + if (subscribed) { + callbacks.session_cancel(callbacks.context, sessions[0]); + } + bool one_left = subscribed && app_wait_for_subscribers(application, project, 1); + int cancels_after_first = atomic_load(&fake.cancels); + if (one_left) { + callbacks.session_cancel(callbacks.context, sessions[1]); + } + for (size_t i = 0; i < 2; i++) { + if (started[i]) { + (void)cbm_thread_join(&threads[i]); + } + callbacks.session_close(callbacks.context, sessions[i]); + } + (void)cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + + ASSERT_TRUE(setup); + ASSERT_TRUE(subscribed); + ASSERT_TRUE(one_left); + ASSERT_EQ(atomic_load(&fake.starts), 1); + ASSERT_EQ(cancels_after_first, 0); + ASSERT_EQ(atomic_load(&fake.cancels), 1); + ASSERT_EQ(atomic_load(&fake.destroys), 1); + ASSERT_EQ(requests[0].status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); + ASSERT_NULL(requests[0].response); + ASSERT_EQ(requests[1].status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); + ASSERT_NULL(requests[1].response); + + free(requests[0].response); + free(requests[1].response); + free(context); + free(tool); + free(project); + (void)cbm_rmdir(root); + PASS(); +} + +TEST(daemon_application_disconnect_before_request_callback_is_sticky) { + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + atomic_store(&fake.scripted, true); + fake.outcomes[0] = CBM_PROC_CLEAN; + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *cancelled_session = + app_test_open(&callbacks, 37); + cbm_daemon_runtime_application_session_t *healthy_session = + app_test_open(&callbacks, 38); + char root[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-pre-callback-cancel-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + uint8_t *context = NULL; + uint32_t context_length = 0; + char args[APP_TEST_PATH_CAP + 32]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", root); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + bool setup = application && cancelled_session && healthy_session && root_ok && + app_test_context_request(root, root, &context, &context_length) && + app_test_tool_request("index_repository", args, &tool, &tool_length); + uint8_t *response = NULL; + uint32_t response_length = 0; + for (size_t i = 0; setup && i < 2; i++) { + cbm_daemon_runtime_application_session_t *session = + i == 0 ? cancelled_session : healthy_session; + setup = app_test_request(&callbacks, session, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + response_length = 0; + } + + if (setup) { + /* Runtime disconnect may win immediately after it creates the request + * thread but before that thread enters the application callback. */ + callbacks.session_cancel(callbacks.context, cancelled_session); + } + cbm_daemon_runtime_application_status_t cancelled_status = + setup ? app_test_request(&callbacks, cancelled_session, tool, tool_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + uint8_t *cancelled_response = response; + uint32_t cancelled_response_length = response_length; + response = NULL; + response_length = 0; + int starts_after_cancelled_callback = atomic_load(&fake.starts); + + cbm_daemon_runtime_application_status_t healthy_status = + setup ? app_test_request(&callbacks, healthy_session, tool, tool_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + + if (cancelled_session) { + callbacks.session_close(callbacks.context, cancelled_session); + } + if (healthy_session) { + callbacks.session_close(callbacks.context, healthy_session); + } + bool stopped = application && + cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + + ASSERT_TRUE(setup); + ASSERT_EQ(cancelled_status, CBM_DAEMON_RUNTIME_APPLICATION_REJECTED); + ASSERT_NULL(cancelled_response); + ASSERT_EQ(cancelled_response_length, 0); + ASSERT_EQ(starts_after_cancelled_callback, 0); + ASSERT_EQ(healthy_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(response && strstr((char *)response, "indexed")); + ASSERT_EQ(atomic_load(&fake.starts), 1); + ASSERT_EQ(atomic_load(&fake.cancels), 0); + ASSERT_EQ(atomic_load(&fake.destroys), 1); + ASSERT_TRUE(stopped); + + free(cancelled_response); + free(response); + free(context); + free(tool); + (void)cbm_rmdir(root); + PASS(); +} + +TEST(daemon_application_request_cancel_preserves_persistent_watch_and_session) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init(&fixture, 3021); + char args[APP_TEST_PATH_CAP + 32]; + (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", + fixture.root); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + bool encoded = + fixture_ready && + app_test_tool_request("index_repository", args, &tool, + &tool_length) && + app_test_text_request( + CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":3021,\"method\":\"ping\"}", + &ping, &ping_length); + app_request_thread_t request = { + .callbacks = fixture.callbacks, + .session = fixture.session, + .request_token = UINT64_C(8201), + .request = tool, + .request_length = tool_length, + .status = CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR, + }; + atomic_init(&request.done, false); + cbm_thread_t request_thread; + bool thread_started = + encoded && cbm_thread_create(&request_thread, 0, app_request_thread, + &request) == 0; + bool subscribed = + thread_started && + app_wait_for_subscribers(fixture.application, fixture.project, 1); + if (subscribed) { + fixture.callbacks.request_cancel( + fixture.callbacks.context, fixture.session, + request.request_token); + } + bool request_returned = + subscribed && app_wait_for_atomic_bool(&request.done, true); + if (!request_returned) { + /* Keep cleanup bounded if the RED path fails to detach the request. */ + atomic_store(&fixture.fake.allow_completion, true); + } + bool thread_joined = + thread_started && cbm_thread_join(&request_thread) == 0; + int watch_count_after_cancel = + fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + + uint8_t *ping_response = NULL; + uint32_t ping_response_length = 0; + cbm_daemon_runtime_application_status_t ping_status = + CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + if (thread_joined && encoded) { + ping_status = app_test_request_tagged( + &fixture.callbacks, fixture.session, UINT64_C(8202), ping, + ping_length, &ping_response, &ping_response_length); + } + int watch_count_after_ping = + fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool worker_cancelled = + app_wait_for_atomic_int(&fixture.fake.cancels, 1); + bool cleaned = app_watch_race_fixture_finish(&fixture); + bool ping_matches = + ping_response && strstr((char *)ping_response, "\"id\":3021") != NULL; + int worker_starts = atomic_load(&fixture.fake.starts); + int worker_destroys = atomic_load(&fixture.fake.destroys); + free(request.response); + free(ping_response); + free(tool); + free(ping); + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(encoded); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(subscribed); + ASSERT_TRUE(request_returned); + ASSERT_TRUE(thread_joined); + ASSERT_EQ(request.status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); + ASSERT_NULL(request.response); + ASSERT_EQ(request.response_length, 0); + ASSERT_EQ(watch_count_after_cancel, 1); + ASSERT_EQ(ping_status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(ping_matches); + ASSERT_GT(ping_response_length, 0); + ASSERT_EQ(watch_count_after_ping, 1); + ASSERT_TRUE(worker_cancelled); + ASSERT_EQ(worker_starts, 1); + ASSERT_EQ(worker_destroys, 1); + ASSERT_TRUE(cleaned); + PASS(); +} + +TEST(daemon_application_cancel_drops_watch_before_inflight_request_returns) { + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + bool saved_cache_ok = !had_cache || saved_cache; + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), "%s/cbm-app-cancel-watch-root-XXXXXX", + cbm_tmpdir()); + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-cancel-watch-cache-XXXXXX", + cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; + bool env_ok = saved_cache_ok && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL; + char db_path[APP_TEST_PATH_CAP] = {0}; + if (project) { + (void)snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + } + bool db_ok = project && app_test_create_empty_file(db_path); + + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL); + cbm_daemon_application_config_t config = { + .watcher = watcher, + .worker_ops = &worker_ops, + }; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 39); + + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + char args[APP_TEST_PATH_CAP + 32]; + (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", root); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool setup = env_ok && db_ok && store && watcher && application && session && + app_test_context_request(root, root, &context, &context_length) && + app_test_text_request( + CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":17,\"method\":\"ping\"}", + &ping, &ping_length) && + app_test_tool_request("index_repository", args, &tool, + &tool_length); + if (setup) { + setup = app_test_request(&callbacks, session, context, context_length, + &response, &response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + response_length = 0; + } + if (setup) { + setup = app_test_request(&callbacks, session, ping, ping_length, + &response, &response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + response_length = 0; + } + bool watch_registered = setup && cbm_watcher_watch_count(watcher) == 1; + + app_request_thread_t request = { + .callbacks = callbacks, + .session = session, + .request = tool, + .request_length = tool_length, + .status = CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR, + }; + atomic_init(&request.done, false); + cbm_thread_t request_thread; + bool thread_started = + watch_registered && + cbm_thread_create(&request_thread, 0, app_request_thread, &request) == 0; + bool worker_started = + thread_started && app_wait_for_atomic_int(&fake.starts, 1); + if (thread_started) { + /* Runtime close is intentionally delayed until the in-flight request + * returns; cancel is the disconnect ownership boundary. */ + callbacks.session_cancel(callbacks.context, session); + } + int watch_count_at_cancel = cbm_watcher_watch_count(watcher); + bool thread_joined = thread_started && cbm_thread_join(&request_thread) == 0; + int watch_count_after_request = cbm_watcher_watch_count(watcher); + bool response_cancelled = + request.status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && + request.response == NULL && request.response_length == 0; + + if (session) { + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && + cbm_daemon_application_shutdown(application, + APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + cbm_watcher_stop(watcher); + cbm_watcher_free(watcher); + cbm_store_close(store); + free(request.response); + free(context); + free(ping); + free(tool); + free(project); + (void)cbm_unlink(db_path); + (void)cbm_rmdir(root); + (void)cbm_rmdir(cache); + if (saved_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1); + } else if (!had_cache) { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(saved_cache); + + ASSERT_TRUE(setup); + ASSERT_TRUE(watch_registered); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(worker_started); + ASSERT_EQ(watch_count_at_cancel, 0); + ASSERT_TRUE(thread_joined); + ASSERT_EQ(watch_count_after_request, 0); + ASSERT_TRUE(response_cancelled); + ASSERT_EQ(atomic_load(&fake.cancels), 1); + ASSERT_EQ(atomic_load(&fake.destroys), 1); + ASSERT_TRUE(stopped); + PASS(); +} + +TEST(daemon_application_stale_watcher_callback_is_rejected_at_job_admission) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init(&fixture, 40); + app_watcher_index_thread_t request = { + .application = fixture.application, + .project = fixture.project, + .root = fixture.root, + .pause_before_subscribe = true, + .result = -1, + }; + atomic_init(&request.ready, false); + atomic_init(&request.proceed, false); + atomic_init(&request.done, false); + cbm_thread_t thread; + bool thread_started = + fixture_ready && + cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0; + bool paused_before_subscribe = + thread_started && app_wait_for_atomic_bool(&request.ready, true); + + if (thread_started) { + /* Models a watcher callback selected before unwatch but scheduled + * after the last owning session crosses its cancel boundary. */ + fixture.callbacks.session_cancel(fixture.callbacks.context, + fixture.session); + } + int watches_after_cancel = cbm_watcher_watch_count(fixture.watcher); + atomic_store(&request.proceed, true); + /* On unfixed code the stale callback starts a worker. Let that RED path + * finish instead of hanging the suite; the zero-start assertion remains + * the reproduction oracle. */ + atomic_store(&fixture.fake.allow_completion, true); + bool request_done = thread_started && + app_wait_for_atomic_bool(&request.done, true); + bool thread_joined = request_done && cbm_thread_join(&thread) == 0; + int worker_starts = atomic_load(&fixture.fake.starts); + size_t active_jobs = + cbm_daemon_application_active_jobs(fixture.application); + bool cleaned = app_watch_race_fixture_finish(&fixture); + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(paused_before_subscribe); + ASSERT_EQ(watches_after_cancel, 0); + ASSERT_TRUE(thread_joined); + ASSERT_EQ(request.result, 1); + ASSERT_EQ(worker_starts, 0); + ASSERT_EQ(active_jobs, 0); + ASSERT_TRUE(cleaned); + PASS(); +} + +TEST(daemon_application_final_cancel_drains_admitted_watcher_job) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init(&fixture, 41); + app_watcher_index_thread_t request = { + .application = fixture.application, + .project = fixture.project, + .root = fixture.root, + .pause_before_subscribe = false, + .result = -1, + }; + atomic_init(&request.ready, false); + atomic_init(&request.proceed, true); + atomic_init(&request.done, false); + cbm_thread_t thread; + bool thread_started = + fixture_ready && + cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0; + bool worker_started = + thread_started && app_wait_for_atomic_int(&fixture.fake.starts, 1); + bool job_active = worker_started && + cbm_daemon_application_active_jobs( + fixture.application) == 1; + + if (thread_started) { + fixture.callbacks.session_cancel(fixture.callbacks.context, + fixture.session); + } + int watches_after_cancel = cbm_watcher_watch_count(fixture.watcher); + bool cancelled_by_final_session = + thread_started && app_wait_for_atomic_int(&fixture.fake.cancels, 1); + if (!cancelled_by_final_session) { + /* RED cleanup: prior behavior leaves this watcher-owned worker alive + * while another daemon client could keep the process running. */ + atomic_store(&fixture.fake.allow_completion, true); + } + bool request_done = thread_started && + app_wait_for_atomic_bool(&request.done, true); + bool thread_joined = request_done && cbm_thread_join(&thread) == 0; + size_t active_jobs_after_join = + cbm_daemon_application_active_jobs(fixture.application); + int worker_cancels = atomic_load(&fixture.fake.cancels); + int worker_destroys = atomic_load(&fixture.fake.destroys); + bool cleaned = app_watch_race_fixture_finish(&fixture); + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(worker_started); + ASSERT_TRUE(job_active); + ASSERT_EQ(watches_after_cancel, 0); + ASSERT_TRUE(thread_joined); + ASSERT_EQ(request.result, 1); + ASSERT_EQ(active_jobs_after_join, 0); + ASSERT_TRUE(cancelled_by_final_session); + ASSERT_EQ(worker_cancels, 1); + ASSERT_EQ(worker_destroys, 1); + ASSERT_TRUE(cleaned); + PASS(); +} + +TEST(daemon_application_watcher_job_follows_exact_live_watch_owners) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init(&fixture, 42); + cbm_daemon_runtime_application_session_t *second = + fixture_ready ? app_test_open(&fixture.callbacks, 43) : NULL; + /* This unrelated live session keeps the daemon generation alive after + * both matching watch owners disconnect. The watcher job must still be + * cancelled when its own final owner disappears. */ + cbm_daemon_runtime_application_session_t *unrelated = + fixture_ready ? app_test_open(&fixture.callbacks, 44) : NULL; + + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool encoded = fixture_ready && second && unrelated && + app_test_context_request(fixture.root, fixture.root, + &context, &context_length) && + app_test_text_request( + CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":20,\"method\":\"ping\"}", + &ping, &ping_length); + bool second_watching = + encoded && + app_test_request(&fixture.callbacks, second, context, context_length, + &response, &response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + response_length = 0; + second_watching = + second_watching && + app_test_request(&fixture.callbacks, second, ping, ping_length, + &response, &response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK && + cbm_watcher_watch_count(fixture.watcher) == 1; + free(response); + response = NULL; + + app_watcher_index_thread_t request = { + .application = fixture.application, + .project = fixture.project, + .root = fixture.root, + .pause_before_subscribe = false, + .result = -1, + }; + atomic_init(&request.ready, false); + atomic_init(&request.proceed, true); + atomic_init(&request.done, false); + cbm_thread_t thread; + bool thread_started = + second_watching && + cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0; + bool worker_started = + thread_started && app_wait_for_atomic_int(&fixture.fake.starts, 1); + size_t initial_subscribers = + worker_started + ? cbm_daemon_application_job_subscribers(fixture.application, + fixture.project) + : 0; + + if (fixture.session) { + fixture.callbacks.session_close(fixture.callbacks.context, + fixture.session); + fixture.session = NULL; + } + int watches_after_first = cbm_watcher_watch_count(fixture.watcher); + size_t subscribers_after_first = + cbm_daemon_application_job_subscribers(fixture.application, + fixture.project); + int cancels_after_first = atomic_load(&fixture.fake.cancels); + + if (second) { + fixture.callbacks.session_close(fixture.callbacks.context, second); + second = NULL; + } + int watches_after_second = cbm_watcher_watch_count(fixture.watcher); + size_t subscribers_after_second = + cbm_daemon_application_job_subscribers(fixture.application, + fixture.project); + bool cancelled_without_final_session = + thread_started && app_wait_for_atomic_int(&fixture.fake.cancels, 1); + if (!cancelled_without_final_session) { + /* RED cleanup: the old anonymous watcher subscription keeps the job + * alive here, so let it finish without leaking the test thread. */ + atomic_store(&fixture.fake.allow_completion, true); + } + bool request_done = + thread_started && app_wait_for_atomic_bool(&request.done, true); + bool thread_joined = request_done && cbm_thread_join(&thread) == 0; + + if (unrelated) { + fixture.callbacks.session_close(fixture.callbacks.context, unrelated); + unrelated = NULL; + } + bool cleaned = app_watch_race_fixture_finish(&fixture); + free(context); + free(ping); + free(response); + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(second_watching); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(worker_started); + ASSERT_EQ(initial_subscribers, 2); + ASSERT_EQ(watches_after_first, 1); + ASSERT_EQ(subscribers_after_first, 1); + ASSERT_EQ(cancels_after_first, 0); + ASSERT_EQ(watches_after_second, 0); + ASSERT_EQ(subscribers_after_second, 0); + ASSERT_TRUE(cancelled_without_final_session); + ASSERT_TRUE(thread_joined); + ASSERT_EQ(request.result, 1); + ASSERT_EQ(atomic_load(&fixture.fake.destroys), 1); + ASSERT_TRUE(cleaned); + PASS(); +} + +/* A staged index snapshots the current DB before it publishes a replacement. + * Any ADR write accepted during that lifetime can therefore be silently lost + * (and can make replacement fail on Windows). The daemon job must keep the + * project reserved against mutations until the physical worker is terminal. */ +TEST(daemon_application_serializes_adr_mutation_with_index_job) { + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-mutation-root-XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm-app-mutation-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; + bool env_ok = dirs_ok && (!had_cache || saved_cache) && + cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + char *project = env_ok ? cbm_project_name_from_path(root) : NULL; + char db_path[APP_TEST_PATH_CAP] = {0}; + cbm_store_t *seed = NULL; + bool seeded = false; + if (project) { + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + seed = cbm_store_open_path(db_path); + seeded = seed && cbm_store_upsert_project(seed, project, root) == CBM_STORE_OK; + cbm_store_close(seed); + } + + cbm_daemon_application_t *application = + seeded ? cbm_daemon_application_new(&config) : NULL; + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *index_session = + application ? app_test_open(&callbacks, 33) : NULL; + cbm_daemon_runtime_application_session_t *adr_session = + application ? app_test_open(&callbacks, 34) : NULL; + uint8_t *context = NULL; + uint32_t context_length = 0; + char index_args[APP_TEST_PATH_CAP + 32]; + snprintf(index_args, sizeof(index_args), "{\"repo_path\":\"%s\"}", root); + uint8_t *index_tool = NULL; + uint32_t index_tool_length = 0; + char adr_args[APP_TEST_PATH_CAP + 128]; + snprintf(adr_args, sizeof(adr_args), + "{\"project\":\"%s\",\"mode\":\"update\"," + "\"content\":\"serialized ADR\"}", + project ? project : ""); + uint8_t *adr_tool = NULL; + uint32_t adr_tool_length = 0; + bool setup = application && index_session && adr_session && + app_test_context_request(root, root, &context, &context_length) && + app_test_tool_request("index_repository", index_args, &index_tool, + &index_tool_length) && + app_test_tool_request("manage_adr", adr_args, &adr_tool, &adr_tool_length); + uint8_t *response = NULL; + uint32_t response_length = 0; + if (setup) { + setup = app_test_request(&callbacks, index_session, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + } + if (setup) { + setup = app_test_request(&callbacks, adr_session, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + } + + app_request_thread_t index_request = { + .callbacks = callbacks, + .session = index_session, + .request = index_tool, + .request_length = index_tool_length, + }; + app_request_thread_t adr_request = { + .callbacks = callbacks, + .session = adr_session, + .request = adr_tool, + .request_length = adr_tool_length, + }; + cbm_thread_t index_thread; + cbm_thread_t adr_thread; + bool index_started = + setup && cbm_thread_create(&index_thread, 0, app_request_thread, &index_request) == 0; + bool index_active = index_started && project && + app_wait_for_subscribers(application, project, 1) && + app_wait_for_atomic_int(&fake.starts, 1); + bool adr_started = index_active && + cbm_thread_create(&adr_thread, 0, app_request_thread, &adr_request) == 0; + uint64_t early_deadline = cbm_now_ms() + 100; + while (adr_started && !atomic_load(&adr_request.done) && cbm_now_ms() < early_deadline) { + cbm_usleep(1000); + } + bool adr_completed_while_index_active = adr_started && atomic_load(&adr_request.done); + + atomic_store(&fake.allow_completion, true); + if (index_started) { + (void)cbm_thread_join(&index_thread); + } + if (adr_started) { + (void)cbm_thread_join(&adr_thread); + } + + cbm_store_t *check = cbm_store_open_path_query(db_path); + cbm_adr_t adr = {0}; + bool adr_persisted = check && cbm_store_adr_get(check, project, &adr) == CBM_STORE_OK && + adr.content && strcmp(adr.content, "serialized ADR") == 0; + if (adr.content) { + cbm_store_adr_free(&adr); + } + cbm_store_close(check); + if (index_session) { + callbacks.session_close(callbacks.context, index_session); + } + if (adr_session) { + callbacks.session_close(callbacks.context, adr_session); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + bool adr_response_updated = + adr_request.response && strstr((char *)adr_request.response, "updated") != NULL; + + free(index_request.response); + free(adr_request.response); + free(context); + free(index_tool); + free(adr_tool); + (void)cbm_unlink(db_path); + char sidecar[APP_TEST_PATH_CAP]; + snprintf(sidecar, sizeof(sidecar), "%s-wal", db_path); + (void)cbm_unlink(sidecar); + snprintf(sidecar, sizeof(sidecar), "%s-shm", db_path); + (void)cbm_unlink(sidecar); + free(project); + (void)cbm_rmdir(root); + (void)cbm_rmdir(cache); + if (saved_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1); + } else if (!had_cache) { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(saved_cache); + + ASSERT_TRUE(env_ok); + ASSERT_TRUE(seeded); + ASSERT_TRUE(setup); + ASSERT_TRUE(index_active); + ASSERT_TRUE(adr_started); + ASSERT_FALSE(adr_completed_while_index_active); + ASSERT_EQ(index_request.status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_EQ(adr_request.status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(adr_response_updated); + ASSERT_TRUE(adr_persisted); + ASSERT_TRUE(stopped); + PASS(); +} + +TEST(daemon_application_reserved_mutation_delays_worker_start) { + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + char root[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-mutation-first-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + const char *project = "mutation-first"; + bool lease_held = application && + cbm_daemon_application_project_mutation_try_begin(application, project); + app_index_thread_t request = { + .application = application, + .project = project, + .root = root, + .result = -1, + }; + cbm_thread_t thread; + bool thread_started = root_ok && lease_held && + cbm_thread_create(&thread, 0, app_index_thread, &request) == 0; + bool reserved = thread_started && app_wait_for_subscribers(application, project, 1); + uint64_t early_deadline = cbm_now_ms() + 100; + while (atomic_load(&fake.starts) == 0 && cbm_now_ms() < early_deadline) { + cbm_usleep(1000); + } + bool worker_started_while_mutating = atomic_load(&fake.starts) != 0; + if (lease_held) { + cbm_daemon_application_project_mutation_end(application, project); + } + bool worker_started = thread_started && app_wait_for_atomic_int(&fake.starts, 1); + atomic_store(&fake.allow_completion, true); + if (thread_started) { + (void)cbm_thread_join(&thread); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + (void)cbm_rmdir(root); + + ASSERT_TRUE(root_ok); + ASSERT_TRUE(lease_held); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(reserved); + ASSERT_FALSE(worker_started_while_mutating); + ASSERT_TRUE(worker_started); + ASSERT_EQ(request.result, 0); + ASSERT_TRUE(stopped); + PASS(); +} + +TEST(daemon_application_mutation_does_not_block_other_project) { + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + char root[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-mutation-other-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + bool lease_held = application && + cbm_daemon_application_project_mutation_try_begin(application, + "project-a"); + app_index_thread_t request = { + .application = application, + .project = "project-b", + .root = root, + .result = -1, + }; + cbm_thread_t thread; + bool thread_started = root_ok && lease_held && + cbm_thread_create(&thread, 0, app_index_thread, &request) == 0; + bool worker_started = thread_started && app_wait_for_atomic_int(&fake.starts, 1); + atomic_store(&fake.allow_completion, true); + if (thread_started) { + (void)cbm_thread_join(&thread); + } + if (lease_held) { + cbm_daemon_application_project_mutation_end(application, "project-a"); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + (void)cbm_rmdir(root); + + ASSERT_TRUE(root_ok); + ASSERT_TRUE(lease_held); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(worker_started); + ASSERT_EQ(request.result, 0); + ASSERT_TRUE(stopped); + PASS(); +} + +TEST(daemon_application_mutation_still_owns_os_project_lock) { + app_project_lock_fixture_t locks; + bool locks_ready = app_project_lock_fixture_init(&locks); + cbm_daemon_application_config_t config = { + .project_locks = locks_ready ? locks.daemon_locks : NULL, + }; + cbm_daemon_application_t *application = + locks_ready ? cbm_daemon_application_new(&config) : NULL; + const char *project = "daemon-mutation-native-lock"; + bool mutation_started = + application && + cbm_daemon_application_project_mutation_try_begin(application, + project); + + cbm_project_lock_lease_t *active_probe = NULL; + cbm_private_file_lock_status_t active_status = + mutation_started + ? cbm_project_lock_try_acquire(locks.probe_locks, project, + &active_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool mutation_holds_lock = + active_status == CBM_PRIVATE_FILE_LOCK_BUSY && !active_probe; + bool active_probe_released = app_project_lock_release(&active_probe); + if (mutation_started) { + cbm_daemon_application_project_mutation_end(application, project); + } + + cbm_project_lock_lease_t *terminal_probe = NULL; + cbm_private_file_lock_status_t terminal_status = + mutation_started + ? cbm_project_lock_try_acquire(locks.probe_locks, project, + &terminal_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool mutation_released_lock = + terminal_status == CBM_PRIVATE_FILE_LOCK_OK && terminal_probe; + bool terminal_probe_released = app_project_lock_release(&terminal_probe); + bool stopped = application && + cbm_daemon_application_shutdown(application, + APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + bool locks_clean = app_project_lock_fixture_cleanup(&locks); + + ASSERT_TRUE(locks_ready); + ASSERT_NOT_NULL(application); + ASSERT_TRUE(mutation_started); + ASSERT_TRUE(mutation_holds_lock); + ASSERT_TRUE(active_probe_released); + ASSERT_TRUE(mutation_released_lock); + ASSERT_TRUE(terminal_probe_released); + ASSERT_TRUE(stopped); + ASSERT_TRUE(locks_clean); + PASS(); +} + +/* RED on daemon-held project leases: the injected physical-worker boundary + * attempts the exact same native lock. A parent lease makes that attempt fail; + * the correct ownership has the worker acquire it and hold it until destroy. */ +TEST(daemon_application_worker_boundary_is_sole_os_project_lock_owner) { + app_project_lock_fixture_t locks; + bool locks_ready = app_project_lock_fixture_init(&locks); + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + fake.project_locks = locks_ready ? locks.worker_locks : NULL; + fake.project_lock_busy_is_error = true; + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = { + .worker_ops = &worker_ops, + .project_locks = locks_ready ? locks.daemon_locks : NULL, + }; + cbm_daemon_application_t *application = + locks_ready ? cbm_daemon_application_new(&config) : NULL; + + char root[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), + "%s/cbm-app-worker-lock-owner-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + const char *project = "worker-lock-owner"; + app_index_thread_t request = { + .application = application, + .project = project, + .root = root, + .result = -1, + }; + cbm_thread_t thread; + bool thread_started = root_ok && application && + cbm_thread_create(&thread, 0, app_index_thread, + &request) == 0; + bool boundary_started = + thread_started && app_wait_for_atomic_int(&fake.starts, 1); + bool worker_acquired = + boundary_started && + app_wait_for_atomic_int(&fake.project_lock_acquisitions, 1); + cbm_project_lock_lease_t *active_probe = NULL; + cbm_private_file_lock_status_t active_probe_status = + worker_acquired + ? cbm_project_lock_try_acquire(locks.probe_locks, project, + &active_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool worker_holds_lock = + active_probe_status == CBM_PRIVATE_FILE_LOCK_BUSY && !active_probe; + bool active_probe_released = app_project_lock_release(&active_probe); + atomic_store(&fake.allow_completion, true); + if (thread_started) { + (void)cbm_thread_join(&thread); + } + cbm_project_lock_lease_t *terminal_probe = NULL; + cbm_private_file_lock_status_t terminal_probe_status = + thread_started + ? cbm_project_lock_try_acquire(locks.probe_locks, project, + &terminal_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool worker_released_lock = + terminal_probe_status == CBM_PRIVATE_FILE_LOCK_OK && terminal_probe; + bool terminal_probe_released = app_project_lock_release(&terminal_probe); + + bool stopped = application && + cbm_daemon_application_shutdown(application, + APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + bool locks_clean = app_project_lock_fixture_cleanup(&locks); + (void)th_rmtree(root); + + ASSERT_TRUE(locks_ready); + ASSERT_TRUE(root_ok); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(boundary_started); + ASSERT_TRUE(worker_acquired); + ASSERT_EQ(atomic_load(&fake.project_lock_attempts), 1); + ASSERT_TRUE(worker_holds_lock); + ASSERT_TRUE(active_probe_released); + ASSERT_EQ(request.result, 0); + ASSERT_TRUE(worker_released_lock); + ASSERT_TRUE(terminal_probe_released); + ASSERT_TRUE(stopped); + ASSERT_TRUE(locks_clean); + ASSERT_EQ(atomic_load(&fake.starts), 1); + ASSERT_EQ(atomic_load(&fake.destroys), 1); + PASS(); +} + +TEST(daemon_application_worker_lock_serializes_external_mutation) { + app_project_lock_fixture_t locks; + bool locks_ready = app_project_lock_fixture_init(&locks); + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + fake.project_locks = locks_ready ? locks.worker_locks : NULL; + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = { + .worker_ops = &worker_ops, + .project_locks = locks_ready ? locks.daemon_locks : NULL, + }; + cbm_daemon_application_t *application = + locks_ready ? cbm_daemon_application_new(&config) : NULL; + + char blocked_root[APP_TEST_PATH_CAP]; + char other_root[APP_TEST_PATH_CAP]; + (void)snprintf(blocked_root, sizeof(blocked_root), + "%s/cbm-app-external-blocked-XXXXXX", cbm_tmpdir()); + (void)snprintf(other_root, sizeof(other_root), + "%s/cbm-app-external-other-XXXXXX", cbm_tmpdir()); + bool blocked_root_ok = cbm_mkdtemp(blocked_root) != NULL; + bool other_root_ok = cbm_mkdtemp(other_root) != NULL; + const char *blocked_project = "external-blocked"; + const char *other_project = "external-other"; + + cbm_project_lock_lease_t *external_lease = NULL; + bool external_lease_held = + application && blocked_root_ok && other_root_ok && + cbm_project_lock_acquire(locks.external_locks, blocked_project, + UINT64_MAX, NULL, &external_lease) == + CBM_PRIVATE_FILE_LOCK_OK && + external_lease; + app_index_thread_t blocked_request = { + .application = application, + .project = blocked_project, + .root = blocked_root, + .result = -1, + }; + cbm_thread_t blocked_thread; + bool blocked_thread_started = + external_lease_held && + cbm_thread_create(&blocked_thread, 0, app_index_thread, + &blocked_request) == 0; + bool blocked_boundary_started = + blocked_thread_started && app_wait_for_atomic_int(&fake.starts, 1); + bool blocked_boundary_attempted = + blocked_boundary_started && + app_wait_for_atomic_int_at_least(&fake.project_lock_attempts, 1); + bool blocked_waiting = blocked_boundary_attempted && + atomic_load(&fake.project_lock_acquisitions) == 0; + + app_index_thread_t other_request = { + .application = application, + .project = other_project, + .root = other_root, + .result = -1, + }; + cbm_thread_t other_thread; + bool other_thread_started = + blocked_waiting && + cbm_thread_create(&other_thread, 0, app_index_thread, + &other_request) == 0; + bool both_boundaries_started = + other_thread_started && app_wait_for_atomic_int(&fake.starts, 2); + bool other_worker_acquired = + both_boundaries_started && + app_wait_for_atomic_int(&fake.project_lock_acquisitions, 1); + + cbm_project_lock_lease_t *other_probe = NULL; + cbm_private_file_lock_status_t active_other_status = + other_worker_acquired + ? cbm_project_lock_try_acquire(locks.probe_locks, other_project, + &other_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool other_lock_held_while_worker_active = + active_other_status == CBM_PRIVATE_FILE_LOCK_BUSY && !other_probe; + bool active_other_probe_released = app_project_lock_release(&other_probe); + + atomic_store(&fake.allow_completion, true); + if (other_thread_started && !other_worker_acquired && application) { + (void)cbm_daemon_application_shutdown(application, + APP_TEST_TIMEOUT_MS); + } + if (other_thread_started) { + (void)cbm_thread_join(&other_thread); + } + cbm_project_lock_lease_t *terminal_other_probe = NULL; + cbm_private_file_lock_status_t terminal_other_status = + other_worker_acquired + ? cbm_project_lock_try_acquire(locks.probe_locks, other_project, + &terminal_other_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool other_lock_released_at_terminal = + terminal_other_status == CBM_PRIVATE_FILE_LOCK_OK && + terminal_other_probe; + bool terminal_other_probe_released = + app_project_lock_release(&terminal_other_probe); + bool blocked_still_waiting = + atomic_load(&fake.project_lock_acquisitions) == 1; + + bool external_lease_released = app_project_lock_release(&external_lease); + bool blocked_worker_acquired = + blocked_thread_started && + app_wait_for_atomic_int(&fake.project_lock_acquisitions, 2); + if (blocked_thread_started && !blocked_worker_acquired && application) { + (void)cbm_daemon_application_shutdown(application, + APP_TEST_TIMEOUT_MS); + } + if (blocked_thread_started) { + (void)cbm_thread_join(&blocked_thread); + } + cbm_project_lock_lease_t *blocked_probe = NULL; + cbm_private_file_lock_status_t terminal_blocked_status = + blocked_worker_acquired + ? cbm_project_lock_try_acquire(locks.probe_locks, + blocked_project, &blocked_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool blocked_lock_released_at_terminal = + terminal_blocked_status == CBM_PRIVATE_FILE_LOCK_OK && blocked_probe; + bool blocked_probe_released = app_project_lock_release(&blocked_probe); + + bool stopped = application && + cbm_daemon_application_shutdown(application, + APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + bool locks_clean = app_project_lock_fixture_cleanup(&locks); + (void)th_rmtree(blocked_root); + (void)th_rmtree(other_root); + + ASSERT_TRUE(locks_ready); + ASSERT_TRUE(blocked_root_ok); + ASSERT_TRUE(other_root_ok); + ASSERT_TRUE(external_lease_held); + ASSERT_TRUE(blocked_thread_started); + ASSERT_TRUE(blocked_boundary_started); + ASSERT_TRUE(blocked_boundary_attempted); + ASSERT_TRUE(blocked_waiting); + ASSERT_TRUE(other_thread_started); + ASSERT_TRUE(both_boundaries_started); + ASSERT_TRUE(other_worker_acquired); + ASSERT_TRUE(other_lock_held_while_worker_active); + ASSERT_TRUE(active_other_probe_released); + ASSERT_EQ(other_request.result, 0); + ASSERT_TRUE(other_lock_released_at_terminal); + ASSERT_TRUE(terminal_other_probe_released); + ASSERT_TRUE(blocked_still_waiting); + ASSERT_TRUE(external_lease_released); + ASSERT_TRUE(blocked_worker_acquired); + ASSERT_EQ(blocked_request.result, 0); + ASSERT_TRUE(blocked_lock_released_at_terminal); + ASSERT_TRUE(blocked_probe_released); + ASSERT_TRUE(stopped); + ASSERT_TRUE(locks_clean); + ASSERT_EQ(atomic_load(&fake.starts), 2); + ASSERT_EQ(atomic_load(&fake.project_lock_acquisitions), 2); + ASSERT_EQ(atomic_load(&fake.destroys), 2); + PASS(); +} + +TEST(daemon_application_final_unsubscribe_cancels_external_lock_wait) { + app_project_lock_fixture_t locks; + bool locks_ready = app_project_lock_fixture_init(&locks); + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + fake.project_locks = locks_ready ? locks.worker_locks : NULL; + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = { + .worker_ops = &worker_ops, + .project_locks = locks_ready ? locks.daemon_locks : NULL, + }; + cbm_daemon_application_t *application = + locks_ready ? cbm_daemon_application_new(&config) : NULL; + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = + application ? app_test_open(&callbacks, 37) : NULL; + + char root[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), + "%s/cbm-app-external-cancel-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + char *project = root_ok ? cbm_project_name_from_path(root) : NULL; + cbm_project_lock_lease_t *external_lease = NULL; + bool external_lease_held = + session && project && + cbm_project_lock_acquire(locks.external_locks, project, UINT64_MAX, + NULL, &external_lease) == + CBM_PRIVATE_FILE_LOCK_OK && + external_lease; + + uint8_t *context = NULL; + uint32_t context_length = 0; + char args[APP_TEST_PATH_CAP + 32]; + (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", root); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + bool setup = external_lease_held && + app_test_context_request(root, root, &context, + &context_length) && + app_test_tool_request("index_repository", args, &tool, + &tool_length); + uint8_t *empty = NULL; + uint32_t empty_length = 0; + if (setup) { + setup = app_test_request(&callbacks, session, context, context_length, + &empty, &empty_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + } + free(empty); + + app_request_thread_t request = { + .callbacks = callbacks, + .session = session, + .request = tool, + .request_length = tool_length, + }; + atomic_init(&request.done, false); + cbm_thread_t request_thread; + bool request_started = + setup && cbm_thread_create(&request_thread, 0, app_request_thread, + &request) == 0; + bool subscribed = request_started && + app_wait_for_subscribers(application, project, 1); + bool worker_boundary_started = + subscribed && app_wait_for_atomic_int(&fake.starts, 1); + bool worker_lock_attempted = + worker_boundary_started && + app_wait_for_atomic_int_at_least(&fake.project_lock_attempts, 1); + bool worker_waiting = worker_lock_attempted && + atomic_load(&fake.project_lock_acquisitions) == 0; + if (subscribed) { + callbacks.session_cancel(callbacks.context, session); + } + bool final_unsubscribed = + subscribed && + app_wait_for_subscribers(application, project, 0); + uint64_t request_deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (request_started && !atomic_load(&request.done) && + cbm_now_ms() < request_deadline) { + cbm_usleep(1000); + } + bool request_finished = request_started && atomic_load(&request.done); + bool job_terminal = + final_unsubscribed && app_wait_for_active_jobs(application, 0); + if (request_started) { + (void)cbm_thread_join(&request_thread); + } + bool cancellation_reported = + request.status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && + request.response == NULL && request.response_length == 0; + bool worker_never_acquired = + atomic_load(&fake.project_lock_acquisitions) == 0; + + bool external_lease_released = app_project_lock_release(&external_lease); + cbm_project_lock_lease_t *leak_probe = NULL; + bool can_probe_for_leak = job_terminal && external_lease_held && + external_lease_released && locks.probe_locks && + project; + cbm_private_file_lock_status_t leak_probe_status = + can_probe_for_leak + ? cbm_project_lock_try_acquire(locks.probe_locks, project, + &leak_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool no_daemon_lease_leaked = + leak_probe_status == CBM_PRIVATE_FILE_LOCK_OK && leak_probe; + bool leak_probe_released = app_project_lock_release(&leak_probe); + + if (session) { + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && + cbm_daemon_application_shutdown(application, + APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + bool locks_clean = app_project_lock_fixture_cleanup(&locks); + + free(request.response); + free(context); + free(tool); + free(project); + (void)th_rmtree(root); + + ASSERT_TRUE(locks_ready); + ASSERT_TRUE(root_ok); + ASSERT_TRUE(external_lease_held); + ASSERT_TRUE(setup); + ASSERT_TRUE(request_started); + ASSERT_TRUE(subscribed); + ASSERT_TRUE(worker_boundary_started); + ASSERT_TRUE(worker_lock_attempted); + ASSERT_TRUE(worker_waiting); + ASSERT_TRUE(final_unsubscribed); + ASSERT_TRUE(request_finished); + ASSERT_TRUE(job_terminal); + ASSERT_EQ(request.status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); + ASSERT_TRUE(cancellation_reported); + ASSERT_TRUE(worker_never_acquired); + ASSERT_EQ(atomic_load(&fake.starts), 1); + ASSERT_EQ(atomic_load(&fake.cancels), 1); + ASSERT_EQ(atomic_load(&fake.destroys), 1); + ASSERT_TRUE(external_lease_released); + ASSERT_TRUE(no_daemon_lease_leaked); + ASSERT_TRUE(leak_probe_released); + ASSERT_TRUE(stopped); + ASSERT_TRUE(locks_clean); + PASS(); +} + +TEST(daemon_application_cancels_mutation_wait_without_cancelling_shared_job) { + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *index_session = app_test_open(&callbacks, 35); + cbm_daemon_runtime_application_session_t *mutation_session = app_test_open(&callbacks, 36); + char root[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-mutation-cancel-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + char *project = root_ok ? cbm_project_name_from_path(root) : NULL; + uint8_t *context = NULL; + uint32_t context_length = 0; + char index_args[APP_TEST_PATH_CAP + 32]; + snprintf(index_args, sizeof(index_args), "{\"repo_path\":\"%s\"}", root); + uint8_t *index_tool = NULL; + uint32_t index_tool_length = 0; + char mutation_args[APP_TEST_PATH_CAP + 96]; + snprintf(mutation_args, sizeof(mutation_args), + "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"x\"}", + project ? project : ""); + uint8_t *mutation_tool = NULL; + uint32_t mutation_tool_length = 0; + bool setup = application && index_session && mutation_session && project && + app_test_context_request(root, root, &context, &context_length) && + app_test_tool_request("index_repository", index_args, &index_tool, + &index_tool_length) && + app_test_tool_request("manage_adr", mutation_args, &mutation_tool, + &mutation_tool_length); + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_session_t *sessions[2] = {index_session, mutation_session}; + for (size_t i = 0; setup && i < 2; i++) { + setup = app_test_request(&callbacks, sessions[i], context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + } + + app_request_thread_t index_request = { + .callbacks = callbacks, + .session = index_session, + .request = index_tool, + .request_length = index_tool_length, + }; + app_request_thread_t mutation_request = { + .callbacks = callbacks, + .session = mutation_session, + .request = mutation_tool, + .request_length = mutation_tool_length, + }; + cbm_thread_t index_thread; + cbm_thread_t mutation_thread; + bool index_started = setup && + cbm_thread_create(&index_thread, 0, app_request_thread, + &index_request) == 0; + bool index_active = index_started && + app_wait_for_subscribers(application, project, 1) && + app_wait_for_atomic_int(&fake.starts, 1); + bool mutation_started = index_active && + cbm_thread_create(&mutation_thread, 0, app_request_thread, + &mutation_request) == 0; + uint64_t blocked_deadline = cbm_now_ms() + 100; + while (mutation_started && !atomic_load(&mutation_request.done) && + cbm_now_ms() < blocked_deadline) { + cbm_usleep(1000); + } + bool initially_blocked = mutation_started && !atomic_load(&mutation_request.done); + if (initially_blocked) { + callbacks.session_cancel(callbacks.context, mutation_session); + } + uint64_t cancel_deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (mutation_started && !atomic_load(&mutation_request.done) && + cbm_now_ms() < cancel_deadline) { + cbm_usleep(1000); + } + bool cancelled_wait_finished = mutation_started && atomic_load(&mutation_request.done); + int physical_cancels_before_release = atomic_load(&fake.cancels); + atomic_store(&fake.allow_completion, true); + if (mutation_started) { + (void)cbm_thread_join(&mutation_thread); + } + if (index_started) { + (void)cbm_thread_join(&index_thread); + } + bool cancellation_reported = + mutation_request.status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && + mutation_request.response == NULL && + mutation_request.response_length == 0; + callbacks.session_close(callbacks.context, index_session); + callbacks.session_close(callbacks.context, mutation_session); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + + free(index_request.response); + free(mutation_request.response); + free(context); + free(index_tool); + free(mutation_tool); + free(project); + (void)cbm_rmdir(root); + + ASSERT_TRUE(setup); + ASSERT_TRUE(index_active); + ASSERT_TRUE(mutation_started); + ASSERT_TRUE(initially_blocked); + ASSERT_TRUE(cancelled_wait_finished); + ASSERT_TRUE(cancellation_reported); + ASSERT_EQ(physical_cancels_before_release, 0); + ASSERT_EQ(index_request.status, CBM_DAEMON_RUNTIME_APPLICATION_OK); + ASSERT_TRUE(stopped); + PASS(); +} + +TEST(daemon_application_recovers_with_unique_per_job_quarantine_files) { + const char *old_restarts = getenv("CBM_INDEX_MAX_RESTARTS"); + char *saved_restarts = old_restarts ? cbm_strdup(old_restarts) : NULL; + bool saved_ok = !old_restarts || saved_restarts; + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + bool saved_cache_ok = !had_cache || saved_cache; + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + atomic_store(&fake.scripted, true); + for (int base = 0; base <= 4; base += 4) { + fake.outcomes[base] = CBM_PROC_CRASH; + fake.outcomes[base + 1] = CBM_PROC_CRASH; + fake.outcomes[base + 2] = CBM_PROC_CRASH; + fake.outcomes[base + 3] = CBM_PROC_CLEAN; + fake.marker_payloads[base + 1] = "S crashing.c\n"; + fake.marker_payloads[base + 2] = "S crashing.c\n"; + } + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-recovery-XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm-app-recovery-cache-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + bool cache_ok = cbm_mkdtemp(cache) != NULL; + bool env_ok = saved_ok && saved_cache_ok && cache_ok && + cbm_setenv("CBM_INDEX_MAX_RESTARTS", "3", 1) == 0 && + cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + + int first = application && root_ok && env_ok + ? cbm_daemon_application_index(application, "recovery-one", root) + : -1; + int second = application && root_ok && env_ok + ? cbm_daemon_application_index(application, "recovery-two", root) + : -1; + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + bool first_marker_removed = !cbm_file_exists(fake.marker_paths[1]); + bool first_quarantine_removed = !cbm_file_exists(fake.quarantine_paths[1]); + bool second_marker_removed = !cbm_file_exists(fake.marker_paths[5]); + bool second_quarantine_removed = !cbm_file_exists(fake.quarantine_paths[5]); + cbm_daemon_application_free(application); + if (saved_restarts) { + (void)cbm_setenv("CBM_INDEX_MAX_RESTARTS", saved_restarts, 1); + } else if (!old_restarts) { + (void)cbm_unsetenv("CBM_INDEX_MAX_RESTARTS"); + } + free(saved_restarts); + if (saved_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_cache, 1); + } else if (!had_cache) { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(saved_cache); + + ASSERT_TRUE(env_ok); + ASSERT_TRUE(root_ok); + ASSERT_TRUE(cache_ok); + ASSERT_EQ(first, 0); + ASSERT_EQ(second, 0); + ASSERT_TRUE(stopped); + ASSERT_EQ(atomic_load(&fake.starts), 8); + ASSERT_EQ(atomic_load(&fake.destroys), 8); + ASSERT_STR_EQ(fake.marker_paths[0], ""); + ASSERT_STR_EQ(fake.quarantine_paths[0], ""); + ASSERT_TRUE(fake.marker_paths[1][0] != '\0'); + ASSERT_TRUE(fake.quarantine_paths[1][0] != '\0'); + ASSERT_STR_EQ(fake.marker_paths[1], fake.marker_paths[2]); + ASSERT_STR_EQ(fake.marker_paths[2], fake.marker_paths[3]); + ASSERT_STR_EQ(fake.quarantine_paths[1], fake.quarantine_paths[3]); + ASSERT_STR_NEQ(fake.marker_paths[1], fake.quarantine_paths[1]); + ASSERT_STR_NEQ(fake.marker_paths[1], fake.marker_paths[5]); + ASSERT_STR_NEQ(fake.quarantine_paths[1], fake.quarantine_paths[5]); + ASSERT_TRUE(strstr(fake.quarantine_seen[3], "crashing.c\tcrash\n") != NULL); + ASSERT_TRUE(strstr(fake.quarantine_seen[7], "crashing.c\tcrash\n") != NULL); + ASSERT_TRUE(first_marker_removed); + ASSERT_TRUE(first_quarantine_removed); + ASSERT_TRUE(second_marker_removed); + ASSERT_TRUE(second_quarantine_removed); + + (void)cbm_rmdir(root); + char logs[APP_TEST_PATH_CAP]; + snprintf(logs, sizeof(logs), "%s/logs", cache); + (void)cbm_rmdir(logs); + (void)cbm_rmdir(cache); + PASS(); +} + +TEST(daemon_application_cancellation_between_recovery_attempts_stops_retry) { + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + atomic_store(&fake.scripted, true); + atomic_store(&fake.hold_destroy_attempt, 0); + fake.outcomes[0] = CBM_PROC_CRASH; + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 41); + char root[APP_TEST_PATH_CAP]; + snprintf(root, sizeof(root), "%s/cbm-app-retry-cancel-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + uint8_t *context = NULL; + uint32_t context_length = 0; + char args[APP_TEST_PATH_CAP + 32]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", root); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + bool setup = application && session && root_ok && + app_test_context_request(root, root, &context, &context_length) && + app_test_tool_request("index_repository", args, &tool, &tool_length); + uint8_t *empty = NULL; + uint32_t empty_length = 0; + if (setup) { + setup = app_test_request(&callbacks, session, context, context_length, &empty, + &empty_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + } + free(empty); + app_request_thread_t request = { + .callbacks = callbacks, + .session = session, + .request = tool, + .request_length = tool_length, + }; + cbm_thread_t thread; + bool started = setup && cbm_thread_create(&thread, 0, app_request_thread, &request) == 0; + bool between_attempts = started && app_wait_for_atomic_int(&fake.destroys, 1); + if (between_attempts) { + callbacks.session_cancel(callbacks.context, session); + } + atomic_store(&fake.release_destroy, true); + if (started) { + (void)cbm_thread_join(&thread); + } + callbacks.session_close(callbacks.context, session); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + + ASSERT_TRUE(setup); + ASSERT_TRUE(started); + ASSERT_TRUE(between_attempts); + ASSERT_TRUE(stopped); + ASSERT_EQ(atomic_load(&fake.starts), 1); + ASSERT_EQ(atomic_load(&fake.destroys), 1); + ASSERT_EQ(request.status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); + ASSERT_NULL(request.response); + ASSERT_EQ(request.response_length, 0); + + free(request.response); + free(context); + free(tool); + (void)cbm_rmdir(root); + PASS(); +} + +TEST(daemon_application_enforces_global_physical_job_limit) { + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = { + .worker_ops = &worker_ops, + .physical_job_limit = 1, + }; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + char first_root[APP_TEST_PATH_CAP]; + char second_root[APP_TEST_PATH_CAP]; + snprintf(first_root, sizeof(first_root), "%s/cbm-app-cap-first-XXXXXX", cbm_tmpdir()); + snprintf(second_root, sizeof(second_root), "%s/cbm-app-cap-second-XXXXXX", cbm_tmpdir()); + bool roots_ok = cbm_mkdtemp(first_root) != NULL && cbm_mkdtemp(second_root) != NULL; + app_index_thread_t first = { + .application = application, + .project = "cap-first", + .root = first_root, + .result = -1, + }; + cbm_thread_t thread; + bool started = + application && roots_ok && cbm_thread_create(&thread, 0, app_index_thread, &first) == 0; + bool admitted = started && app_wait_for_subscribers(application, "cap-first", 1) && + app_wait_for_atomic_int(&fake.starts, 1); + int busy = admitted ? cbm_daemon_application_index(application, "cap-second", second_root) : -1; + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 51); + uint8_t *session_context = NULL; + uint32_t session_context_length = 0; + char args[APP_TEST_PATH_CAP + 32]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", second_root); + uint8_t *tool = NULL; + uint32_t tool_length = 0; + bool session_encoded = admitted && session && + app_test_context_request(second_root, second_root, &session_context, + &session_context_length) && + app_test_tool_request("index_repository", args, &tool, &tool_length); + uint8_t *response = NULL; + uint32_t response_length = 0; + bool session_busy = + session_encoded && + app_test_request(&callbacks, session, session_context, session_context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + session_busy = session_busy && + app_test_request(&callbacks, session, tool, tool_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK && + response && strstr((char *)response, "physical index job limit reached"); + callbacks.session_close(callbacks.context, session); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + if (started) { + (void)cbm_thread_join(&thread); + } + cbm_daemon_application_free(application); + free(session_context); + free(tool); + free(response); + + ASSERT_TRUE(roots_ok); + ASSERT_TRUE(started); + ASSERT_TRUE(admitted); + ASSERT_EQ(busy, 1); + ASSERT_TRUE(session_busy); + ASSERT_TRUE(stopped); + ASSERT_EQ(atomic_load(&fake.starts), 1); + ASSERT_EQ(atomic_load(&fake.cancels), 1); + ASSERT_EQ(atomic_load(&fake.destroys), 1); + + (void)cbm_rmdir(first_root); + (void)cbm_rmdir(second_root); + PASS(); +} + +TEST(daemon_application_default_limit_admits_four_and_rejects_fifth) { + enum { DEFAULT_CAP_RUNNING = 4, DEFAULT_CAP_TOTAL = 5 }; + const size_t aggregate_budget = 4099; + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = { + .worker_ops = &worker_ops, + .aggregate_memory_budget_bytes = aggregate_budget, + }; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + char roots[DEFAULT_CAP_TOTAL][APP_TEST_PATH_CAP]; + char projects[DEFAULT_CAP_TOTAL][32]; + bool roots_ok = true; + for (int i = 0; i < DEFAULT_CAP_TOTAL; i++) { + (void)snprintf(roots[i], sizeof(roots[i]), "%s/cbm-app-default-cap-%d-XXXXXX", cbm_tmpdir(), + i); + (void)snprintf(projects[i], sizeof(projects[i]), "default-cap-%d", i); + roots_ok = roots_ok && cbm_mkdtemp(roots[i]) != NULL; + } + + app_index_thread_t requests[DEFAULT_CAP_RUNNING]; + cbm_thread_t threads[DEFAULT_CAP_RUNNING]; + bool started[DEFAULT_CAP_RUNNING] = {false}; + for (int i = 0; application && roots_ok && i < DEFAULT_CAP_RUNNING; i++) { + requests[i] = (app_index_thread_t){ + .application = application, + .project = projects[i], + .root = roots[i], + .result = -1, + }; + started[i] = cbm_thread_create(&threads[i], 0, app_index_thread, &requests[i]) == 0; + } + bool all_started = true; + for (int i = 0; i < DEFAULT_CAP_RUNNING; i++) { + all_started = all_started && started[i]; + } + bool four_admitted = all_started && + app_wait_for_active_jobs(application, DEFAULT_CAP_RUNNING) && + app_wait_for_atomic_int(&fake.starts, DEFAULT_CAP_RUNNING); + int fifth = + four_admitted ? cbm_daemon_application_index(application, projects[4], roots[4]) : -1; + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + for (int i = 0; i < DEFAULT_CAP_RUNNING; i++) { + if (started[i]) { + (void)cbm_thread_join(&threads[i]); + } + } + cbm_daemon_application_free(application); + for (int i = 0; i < DEFAULT_CAP_TOTAL; i++) { + (void)cbm_rmdir(roots[i]); + } + + ASSERT_TRUE(roots_ok); + ASSERT_TRUE(all_started); + ASSERT_TRUE(four_admitted); + ASSERT_EQ(fifth, 1); + ASSERT_TRUE(stopped); + ASSERT_EQ(atomic_load(&fake.starts), DEFAULT_CAP_RUNNING); + ASSERT_EQ(atomic_load(&fake.cancels), DEFAULT_CAP_RUNNING); + ASSERT_EQ(atomic_load(&fake.destroys), DEFAULT_CAP_RUNNING); + size_t assigned_budget = 0; + for (int i = 0; i < DEFAULT_CAP_RUNNING; i++) { + ASSERT_EQ(fake.memory_budgets[i], aggregate_budget / DEFAULT_CAP_RUNNING); + assigned_budget += fake.memory_budgets[i]; + } + ASSERT_TRUE(assigned_budget <= aggregate_budget); + PASS(); +} + +TEST(daemon_application_rejects_clean_exit_when_process_tree_is_not_contained) { + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + atomic_store(&fake.allow_completion, true); + atomic_store(&fake.unsafe_clean, true); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + + int result = application + ? cbm_daemon_application_index( + application, "unsafe-clean-project", cbm_tmpdir()) + : -1; + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + + ASSERT_NOT_NULL(application); + ASSERT_LT(result, 0); + ASSERT_TRUE(stopped); + ASSERT_EQ(atomic_load(&fake.starts), 1); + ASSERT_EQ(atomic_load(&fake.destroys), 1); + PASS(); +} + +SUITE(daemon_application) { + RUN_TEST(daemon_application_new_session_does_not_retain_initial_store); + RUN_TEST(daemon_application_request_cancel_is_scoped_to_exact_token); + RUN_TEST(daemon_application_requires_immutable_explicit_context); + RUN_TEST(daemon_application_ui_config_updates_are_masked_and_serialized); + RUN_TEST(daemon_application_ui_config_rejects_noncanonical_frames); + RUN_TEST(daemon_application_restricted_profile_owns_no_background_surfaces); + RUN_TEST(daemon_application_hook_context_preserves_event_and_dialect); + RUN_TEST(daemon_application_mcp_notification_has_no_response); + RUN_TEST(daemon_application_reference_counts_one_shared_watch); + RUN_TEST(daemon_application_free_releases_live_watch_once); + RUN_TEST(daemon_application_prune_clears_logical_watch_for_reregistration); + RUN_TEST(daemon_application_coalesces_identical_index_requests); + RUN_TEST(daemon_application_fresh_request_does_not_reuse_terminal_subscribed_job); + RUN_TEST(daemon_application_request_cancel_detaches_only_one_coalesced_subscriber); + RUN_TEST(daemon_application_cancels_physical_job_only_after_final_session); + RUN_TEST(daemon_application_disconnect_before_request_callback_is_sticky); + RUN_TEST(daemon_application_request_cancel_preserves_persistent_watch_and_session); + RUN_TEST(daemon_application_cancel_drops_watch_before_inflight_request_returns); + RUN_TEST(daemon_application_stale_watcher_callback_is_rejected_at_job_admission); + RUN_TEST(daemon_application_final_cancel_drains_admitted_watcher_job); + RUN_TEST(daemon_application_watcher_job_follows_exact_live_watch_owners); + RUN_TEST(daemon_application_serializes_adr_mutation_with_index_job); + RUN_TEST(daemon_application_reserved_mutation_delays_worker_start); + RUN_TEST(daemon_application_mutation_does_not_block_other_project); + RUN_TEST(daemon_application_mutation_still_owns_os_project_lock); + RUN_TEST(daemon_application_worker_boundary_is_sole_os_project_lock_owner); + RUN_TEST(daemon_application_worker_lock_serializes_external_mutation); + RUN_TEST(daemon_application_final_unsubscribe_cancels_external_lock_wait); + RUN_TEST(daemon_application_cancels_mutation_wait_without_cancelling_shared_job); + RUN_TEST(daemon_application_recovers_with_unique_per_job_quarantine_files); + RUN_TEST(daemon_application_cancellation_between_recovery_attempts_stops_retry); + RUN_TEST(daemon_application_enforces_global_physical_job_limit); + RUN_TEST(daemon_application_default_limit_admits_four_and_rejects_fifth); + RUN_TEST(daemon_application_rejects_clean_exit_when_process_tree_is_not_contained); +} diff --git a/tests/test_daemon_bootstrap.c b/tests/test_daemon_bootstrap.c new file mode 100644 index 000000000..402dfb4a0 --- /dev/null +++ b/tests/test_daemon_bootstrap.c @@ -0,0 +1,688 @@ +/* RED contract for early process-role classification. */ +#include "test_framework.h" + +#include "daemon/bootstrap.h" +#include "daemon/ipc.h" +#include "daemon/service.h" +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/compat_thread.h" +#include "foundation/platform.h" + +#include +#include +#include +#include + +enum { + BOOTSTRAP_TEST_PATH_CAP = 1024, + BOOTSTRAP_TEST_TIMEOUT_MS = 2000, + BOOTSTRAP_TEST_SHORT_TIMEOUT_MS = 20, +}; + +static const char BOOTSTRAP_BUILD_A[] = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +static const char BOOTSTRAP_BUILD_B[] = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +typedef struct { + char parent[BOOTSTRAP_TEST_PATH_CAP]; + char runtime_dir[BOOTSTRAP_TEST_PATH_CAP]; + cbm_daemon_ipc_endpoint_t *endpoint; +} bootstrap_endpoint_fixture_t; + +typedef struct { + atomic_int cohort_acquire_count; + atomic_int cohort_release_count; + atomic_int lock_held; + atomic_int spawn_count; + atomic_int probe_count; + atomic_int lock_attempt_count; + atomic_int handoff_count; + atomic_int diagnostic_count; + atomic_int reserved_probes_remaining; + atomic_int terminal_probes_remaining; + atomic_bool available; + atomic_bool connect_after_reserved; + cbm_daemon_bootstrap_probe_status_t forced_probe; + cbm_version_cohort_status_t forced_cohort; + char diagnostic[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; +} bootstrap_fake_ops_t; + +typedef struct { + const cbm_daemon_bootstrap_config_t *config; + const cbm_daemon_bootstrap_ops_t *ops; + atomic_int *ready; + atomic_bool *go; + cbm_daemon_bootstrap_result_t result; + cbm_daemon_bootstrap_status_t status; +} bootstrap_thread_call_t; + +static cbm_daemon_build_identity_t bootstrap_identity(const char *version, const char *build) { + cbm_daemon_build_identity_t identity = { + .semantic_version = version, + .build_fingerprint = build, + .protocol_abi = 3, + .store_abi = 11, + .feature_abi = 7, + }; + return identity; +} + +static bool bootstrap_endpoint_fixture_start(bootstrap_endpoint_fixture_t *fixture, + const char *tag) { + memset(fixture, 0, sizeof(*fixture)); + int written = snprintf(fixture->parent, sizeof(fixture->parent), "%s/cbm-bootstrap-%s-XXXXXX", + cbm_tmpdir(), tag); + if (written <= 0 || written >= (int)sizeof(fixture->parent) || !cbm_mkdtemp(fixture->parent)) { + return false; + } + fixture->endpoint = cbm_daemon_bootstrap_endpoint_new(fixture->parent); + const char *runtime_dir = + fixture->endpoint ? cbm_daemon_ipc_endpoint_runtime_dir(fixture->endpoint) : NULL; + if (!runtime_dir) { + return false; + } + written = snprintf(fixture->runtime_dir, sizeof(fixture->runtime_dir), "%s", runtime_dir); + return written > 0 && written < (int)sizeof(fixture->runtime_dir); +} + +static void bootstrap_endpoint_fixture_finish(bootstrap_endpoint_fixture_t *fixture) { + cbm_daemon_ipc_endpoint_free(fixture->endpoint); + if (fixture->runtime_dir[0] != '\0') { + (void)cbm_rmdir(fixture->runtime_dir); + } + if (fixture->parent[0] != '\0') { + (void)cbm_rmdir(fixture->parent); + } + memset(fixture, 0, sizeof(*fixture)); +} + +static cbm_daemon_bootstrap_probe_status_t bootstrap_fake_probe( + void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, uint32_t timeout_ms, + cbm_daemon_runtime_client_t **client_out, cbm_daemon_runtime_connect_result_t *result_out) { + (void)endpoint; + (void)identity; + (void)timeout_ms; + bootstrap_fake_ops_t *fake = opaque; + atomic_fetch_add(&fake->probe_count, 1); + memset(result_out, 0, sizeof(*result_out)); + *client_out = NULL; + int reserved_remaining = atomic_load(&fake->reserved_probes_remaining); + while (reserved_remaining > 0 && + !atomic_compare_exchange_weak(&fake->reserved_probes_remaining, + &reserved_remaining, reserved_remaining - 1)) { + } + if (reserved_remaining > 0) { + if (reserved_remaining == 1 && atomic_load(&fake->connect_after_reserved)) { + atomic_store(&fake->available, true); + } + return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED; + } + int terminal_remaining = atomic_load(&fake->terminal_probes_remaining); + while (terminal_remaining > 0 && + !atomic_compare_exchange_weak(&fake->terminal_probes_remaining, + &terminal_remaining, + terminal_remaining - 1)) { + } + if (terminal_remaining > 0) { + return CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL; + } + if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED) { + return fake->forced_probe; + } + if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT) { + result_out->status = CBM_DAEMON_RUNTIME_CONNECT_CONFLICT; + result_out->hello_status = CBM_DAEMON_HELLO_BUILD_CONFLICT; + snprintf(result_out->message, sizeof(result_out->message), + "CBM daemon could not start: conflicting versions"); + return fake->forced_probe; + } + if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL) { + return fake->forced_probe; + } + if (atomic_load(&fake->available)) { + result_out->status = CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED; + result_out->hello_status = CBM_DAEMON_HELLO_COMPATIBLE; + result_out->client_id = 1; + *client_out = (cbm_daemon_runtime_client_t *)(uintptr_t)1; + return CBM_DAEMON_BOOTSTRAP_PROBE_CONNECTED; + } + return CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE; +} + +static cbm_version_cohort_status_t bootstrap_fake_cohort_acquire( + void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms, + cbm_daemon_bootstrap_cohort_t *cohort_out, + cbm_daemon_conflict_t *conflict_out) { + (void)endpoint; + (void)deadline_ms; + bootstrap_fake_ops_t *fake = opaque; + atomic_fetch_add(&fake->cohort_acquire_count, 1); + *cohort_out = NULL; + memset(conflict_out, 0, sizeof(*conflict_out)); + if (fake->forced_cohort == CBM_VERSION_COHORT_CONFLICT) { + cbm_daemon_build_identity_t active = + bootstrap_identity("2.3.0", BOOTSTRAP_BUILD_A); + (void)cbm_daemon_hello_compare(&active, identity, conflict_out); + return fake->forced_cohort; + } + if (fake->forced_cohort != CBM_VERSION_COHORT_OK) { + return fake->forced_cohort; + } + *cohort_out = fake; + return CBM_VERSION_COHORT_OK; +} + +static void bootstrap_fake_cohort_release( + void *opaque, cbm_daemon_bootstrap_cohort_t cohort) { + bootstrap_fake_ops_t *fake = opaque; + if (cohort == fake) { + atomic_fetch_add(&fake->cohort_release_count, 1); + } +} + +static int bootstrap_fake_lock(void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_bootstrap_lock_t *lock_out) { + (void)endpoint; + bootstrap_fake_ops_t *fake = opaque; + atomic_fetch_add(&fake->lock_attempt_count, 1); + int expected = 0; + if (!atomic_compare_exchange_strong(&fake->lock_held, &expected, 1)) { + return 0; + } + *lock_out = fake; + return 1; +} + +static bool bootstrap_fake_unlock( + void *opaque, cbm_daemon_bootstrap_lock_t *lock_io) { + bootstrap_fake_ops_t *fake = opaque; + if (lock_io && *lock_io == fake) { + atomic_store(&fake->lock_held, 0); + *lock_io = NULL; + return true; + } + return lock_io && !*lock_io; +} + +static bool bootstrap_fake_handoff(void *opaque, + cbm_daemon_bootstrap_lock_t lock) { + bootstrap_fake_ops_t *fake = opaque; + if (lock != fake || atomic_load(&fake->lock_held) != 1) { + return false; + } + atomic_fetch_add(&fake->handoff_count, 1); + return true; +} + +static bool bootstrap_fake_spawn(void *opaque, const cbm_daemon_bootstrap_launch_spec_t *spec) { + bootstrap_fake_ops_t *fake = opaque; + bool exact = spec && spec->argc == CBM_DAEMON_BOOTSTRAP_LAUNCH_ARGC && spec->argv[0] && + spec->argv[1] && !spec->argv[2] && + strcmp(spec->argv[1], CBM_DAEMON_INTERNAL_ARG) == 0 && spec->detached && + !spec->inherit_standard_handles && !spec->use_shell && + atomic_load(&fake->handoff_count) > 0 && + atomic_load(&fake->lock_held) == 1; + if (!exact) { + return false; + } + atomic_fetch_add(&fake->spawn_count, 1); + atomic_store(&fake->available, true); + return true; +} + +static void bootstrap_fake_diagnostic(void *opaque, const char *message) { + bootstrap_fake_ops_t *fake = opaque; + atomic_fetch_add(&fake->diagnostic_count, 1); + snprintf(fake->diagnostic, sizeof(fake->diagnostic), "%s", message ? message : ""); +} + +static cbm_daemon_bootstrap_ops_t bootstrap_fake_callbacks(bootstrap_fake_ops_t *fake) { + cbm_daemon_bootstrap_ops_t ops = { + .context = fake, + .cohort_acquire = bootstrap_fake_cohort_acquire, + .cohort_release = bootstrap_fake_cohort_release, + .probe = bootstrap_fake_probe, + .startup_lock_try_acquire = bootstrap_fake_lock, + .startup_lock_prepare_handoff = bootstrap_fake_handoff, + .startup_lock_release = bootstrap_fake_unlock, + .spawn_daemon = bootstrap_fake_spawn, + .visible_diagnostic = bootstrap_fake_diagnostic, + }; + return ops; +} + +static void *bootstrap_thread_execute(void *opaque) { + bootstrap_thread_call_t *call = opaque; + atomic_fetch_add(call->ready, 1); + while (!atomic_load(call->go)) { + const struct timespec pause = {0, 1000000L}; + (void)cbm_nanosleep(&pause, NULL); + } + call->status = cbm_daemon_bootstrap_execute_with_ops(call->config, call->ops, &call->result); + return NULL; +} + +static cbm_daemon_process_role_t classify(int argc, char **argv) { + return cbm_daemon_process_role(argc, argv); +} + +TEST(daemon_bootstrap_classifies_default_and_ui_as_mcp_clients) { + char *plain[] = {"codebase-memory-mcp", NULL}; + char *ui[] = {"codebase-memory-mcp", "--ui=true", "--port=9750", NULL}; + ASSERT_EQ(classify(1, plain), CBM_DAEMON_PROCESS_MCP_CLIENT); + ASSERT_EQ(classify(3, ui), CBM_DAEMON_PROCESS_MCP_CLIENT); + ASSERT_TRUE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_MCP_CLIENT)); + PASS(); +} + +TEST(daemon_bootstrap_classifies_stateless_commands_without_client) { + char *version[] = {"codebase-memory-mcp", "--version", NULL}; + char *help[] = {"codebase-memory-mcp", "--profile", "--help", NULL}; + char *install[] = {"codebase-memory-mcp", "install", "--dry-run", NULL}; + char *uninstall[] = {"codebase-memory-mcp", "uninstall", NULL}; + char *update[] = {"codebase-memory-mcp", "update", "-n", NULL}; + ASSERT_EQ(classify(2, version), CBM_DAEMON_PROCESS_STATELESS); + ASSERT_EQ(classify(3, help), CBM_DAEMON_PROCESS_STATELESS); + ASSERT_EQ(classify(3, install), CBM_DAEMON_PROCESS_STATELESS); + ASSERT_EQ(classify(2, uninstall), CBM_DAEMON_PROCESS_STATELESS); + ASSERT_EQ(classify(3, update), CBM_DAEMON_PROCESS_STATELESS); + ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_STATELESS)); + PASS(); +} + +TEST(daemon_bootstrap_classifies_config_as_coordinated_local_cli) { + char *list[] = {"codebase-memory-mcp", "config", "list", NULL}; + char *set[] = {"codebase-memory-mcp", "config", "set", "auto_watch", + "false", NULL}; + char *help[] = {"codebase-memory-mcp", "config", "--help", NULL}; + ASSERT_EQ(classify(3, list), CBM_DAEMON_PROCESS_LOCAL_CLI); + ASSERT_EQ(classify(5, set), CBM_DAEMON_PROCESS_LOCAL_CLI); + ASSERT_EQ(classify(3, help), CBM_DAEMON_PROCESS_STATELESS); + ASSERT_FALSE(cbm_daemon_process_role_requires_client( + CBM_DAEMON_PROCESS_LOCAL_CLI)); + PASS(); +} + +TEST(daemon_bootstrap_cli_help_is_stateless_but_tool_calls_are_local) { + char *tool_help[] = {"codebase-memory-mcp", "cli", "search_graph", "--help", NULL}; + char *tool_call[] = {"codebase-memory-mcp", "cli", "search_graph", "{}", NULL}; + ASSERT_EQ(classify(4, tool_help), CBM_DAEMON_PROCESS_STATELESS); + ASSERT_EQ(classify(4, tool_call), CBM_DAEMON_PROCESS_LOCAL_CLI); + ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_LOCAL_CLI)); + PASS(); +} + +TEST(daemon_bootstrap_cli_arguments_cannot_reclassify_the_process) { + char *install_value[] = {"codebase-memory-mcp", "cli", "search_code", + "--query", "install", NULL}; + char *version_value[] = {"codebase-memory-mcp", "cli", "search_code", + "--query", "--version", NULL}; + ASSERT_EQ(classify(5, install_value), CBM_DAEMON_PROCESS_LOCAL_CLI); + ASSERT_EQ(classify(5, version_value), CBM_DAEMON_PROCESS_LOCAL_CLI); + PASS(); +} + +TEST(daemon_bootstrap_internal_roles_never_take_client_leases) { + static char build[] = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + char *daemon[] = {"codebase-memory-mcp", CBM_DAEMON_INTERNAL_ARG, NULL}; + char *worker[] = {"codebase-memory-mcp", "cli", "--index-worker", + "--index-worker-build", build, "index_repository", + "{}", "--response-out", "/tmp/response", NULL}; + char *malformed_worker[] = {"codebase-memory-mcp", "cli", "--index-worker", + "index_repository", "{}", NULL}; + char *reserved_user_value[] = {"codebase-memory-mcp", "cli", "search_code", "--query", + "--index-worker", NULL}; + char *hook[] = {"codebase-memory-mcp", "hook-augment", NULL}; + ASSERT_EQ(classify(2, daemon), CBM_DAEMON_PROCESS_DAEMON); + ASSERT_EQ(classify(9, worker), CBM_DAEMON_PROCESS_WORKER); + ASSERT_EQ(classify(5, malformed_worker), CBM_DAEMON_PROCESS_INVALID); + ASSERT_EQ(classify(5, reserved_user_value), CBM_DAEMON_PROCESS_INVALID); + ASSERT_EQ(classify(2, hook), CBM_DAEMON_PROCESS_HOOK_CLIENT); + ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_DAEMON)); + ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_WORKER)); + ASSERT_TRUE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_HOOK_CLIENT)); + PASS(); +} + +TEST(daemon_bootstrap_rejects_ambiguous_internal_daemon_argv) { + char *missing[] = {NULL}; + char *mixed[] = {"codebase-memory-mcp", CBM_DAEMON_INTERNAL_ARG, "cli", NULL}; + ASSERT_EQ(classify(0, missing), CBM_DAEMON_PROCESS_INVALID); + ASSERT_EQ(classify(3, mixed), CBM_DAEMON_PROCESS_INVALID); + ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_INVALID)); + PASS(); +} + +TEST(daemon_bootstrap_uses_one_stable_per_account_endpoint) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "stable-endpoint")); + cbm_daemon_ipc_endpoint_t *second = cbm_daemon_bootstrap_endpoint_new(fixture.parent); + ASSERT_NOT_NULL(second); + ASSERT_STR_EQ(cbm_daemon_ipc_endpoint_address(fixture.endpoint), + cbm_daemon_ipc_endpoint_address(second)); + cbm_daemon_ipc_endpoint_free(second); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} + +TEST(daemon_bootstrap_launches_only_exact_detached_hidden_role) { + cbm_daemon_bootstrap_launch_spec_t spec; + ASSERT_TRUE(cbm_daemon_bootstrap_launch_spec_init("/tmp/cbm exact", &spec)); + ASSERT_EQ(spec.argc, CBM_DAEMON_BOOTSTRAP_LAUNCH_ARGC); + ASSERT_STR_EQ(spec.executable_path, "/tmp/cbm exact"); + ASSERT_STR_EQ(spec.argv[0], "/tmp/cbm exact"); + ASSERT_STR_EQ(spec.argv[1], CBM_DAEMON_INTERNAL_ARG); + ASSERT_NULL(spec.argv[2]); + ASSERT_TRUE(spec.detached); + ASSERT_FALSE(spec.inherit_standard_handles); + ASSERT_FALSE(spec.use_shell); + PASS(); +} + +TEST(daemon_bootstrap_stateless_roles_bypass_every_daemon_operation) { + bootstrap_fake_ops_t fake = {0}; + cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_STATELESS, + }; + cbm_daemon_bootstrap_result_t result; + ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result), + CBM_DAEMON_BOOTSTRAP_BYPASSED); + ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_BYPASSED); + ASSERT_EQ(atomic_load(&fake.cohort_acquire_count), 0); + ASSERT_EQ(atomic_load(&fake.probe_count), 0); + ASSERT_EQ(atomic_load(&fake.spawn_count), 0); + ASSERT_EQ(atomic_load(&fake.diagnostic_count), 0); + PASS(); +} + +TEST(daemon_bootstrap_cohort_conflict_is_visible_before_probe_or_spawn) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "cohort-conflict")); + bootstrap_fake_ops_t fake = {0}; + fake.forced_cohort = CBM_VERSION_COHORT_CONFLICT; + cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); + cbm_daemon_build_identity_t identity = + bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_B); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = "/tmp/cbm", + .connect_timeout_ms = 50, + .startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS, + }; + cbm_daemon_bootstrap_result_t result; + ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result), + CBM_DAEMON_BOOTSTRAP_CONFLICT); + ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONFLICT); + ASSERT_EQ(atomic_load(&fake.cohort_acquire_count), 1); + ASSERT_EQ(atomic_load(&fake.cohort_release_count), 0); + ASSERT_EQ(atomic_load(&fake.probe_count), 0); + ASSERT_EQ(atomic_load(&fake.lock_attempt_count), 0); + ASSERT_EQ(atomic_load(&fake.spawn_count), 0); + ASSERT_EQ(atomic_load(&fake.diagnostic_count), 1); + ASSERT_NOT_NULL(strstr(fake.diagnostic, "conflicting CBM process is active")); + ASSERT_NOT_NULL(strstr(fake.diagnostic, + "Close all CBM sessions and commands")); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} + +TEST(daemon_bootstrap_existing_exact_daemon_connects_without_spawn) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "existing")); + bootstrap_fake_ops_t fake = {0}; + atomic_store(&fake.available, true); + cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = "/tmp/cbm", + .connect_timeout_ms = 50, + .startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS, + }; + cbm_daemon_bootstrap_result_t result; + ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result), + CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_NOT_NULL(result.client); + ASSERT_FALSE(result.daemon_spawned); + ASSERT_EQ(atomic_load(&fake.spawn_count), 0); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} + +TEST(daemon_bootstrap_conflict_is_visible_and_never_spawns) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "conflict")); + bootstrap_fake_ops_t fake = {0}; + fake.forced_probe = CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT; + cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_B); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = "/tmp/cbm", + .connect_timeout_ms = 50, + .startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS, + }; + cbm_daemon_bootstrap_result_t result; + ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result), + CBM_DAEMON_BOOTSTRAP_CONFLICT); + ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONFLICT); + ASSERT_NULL(result.client); + ASSERT_EQ(atomic_load(&fake.spawn_count), 0); + ASSERT_EQ(atomic_load(&fake.diagnostic_count), 1); + ASSERT_NOT_NULL(strstr(fake.diagnostic, "conflicting versions")); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} + +TEST(daemon_bootstrap_terminal_generation_that_never_exits_is_not_replaced) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "terminal")); + bootstrap_fake_ops_t fake = {0}; + fake.forced_probe = CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL; + cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_HOOK_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = "/tmp/cbm", + .connect_timeout_ms = 1, + .startup_timeout_ms = BOOTSTRAP_TEST_SHORT_TIMEOUT_MS, + }; + cbm_daemon_bootstrap_result_t result; + ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result), + CBM_DAEMON_BOOTSTRAP_FAILED); + ASSERT_EQ(atomic_load(&fake.lock_held), 0); + ASSERT_EQ(atomic_load(&fake.spawn_count), 0); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} + +/* RED for final-session/new-session overlap: STOPPING is a temporary state of + * the previous same-build generation. Once that generation disappears, the + * already-running bootstrap attempt must serialize and become the new first + * client instead of forcing the coding agent to restart its MCP process. */ +TEST(daemon_bootstrap_terminal_then_absent_spawns_replacement) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "terminal-absent")); + bootstrap_fake_ops_t fake = {0}; + atomic_store(&fake.terminal_probes_remaining, 1); + cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = "/tmp/cbm", + .connect_timeout_ms = 1, + .startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS, + }; + cbm_daemon_bootstrap_result_t result; + ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result), + CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_TRUE(result.daemon_spawned); + ASSERT_EQ(atomic_load(&fake.spawn_count), 1); + ASSERT(atomic_load(&fake.lock_attempt_count) >= 1); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} + +TEST(daemon_bootstrap_reserved_generation_becomes_connectable_without_spawn) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "reserved-connect")); + bootstrap_fake_ops_t fake = {0}; + atomic_store(&fake.reserved_probes_remaining, 2); + atomic_store(&fake.connect_after_reserved, true); + cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = "/tmp/cbm", + .connect_timeout_ms = 1, + .startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS, + }; + cbm_daemon_bootstrap_result_t result; + ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result), + CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_FALSE(result.daemon_spawned); + ASSERT_EQ(atomic_load(&fake.lock_attempt_count), 0); + ASSERT_EQ(atomic_load(&fake.spawn_count), 0); + ASSERT(atomic_load(&fake.probe_count) >= 3); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} + +/* RED for the equivalent race when the old listener vanishes without first + * returning the explicit STOPPING response. Startup serialization decides + * whether absence is now safe; a historical RESERVED sample is not sticky. */ +TEST(daemon_bootstrap_reserved_then_absent_spawns_replacement) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "reserved-absent")); + bootstrap_fake_ops_t fake = {0}; + atomic_store(&fake.reserved_probes_remaining, 1); + cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = "/tmp/cbm", + .connect_timeout_ms = 1, + .startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS, + }; + cbm_daemon_bootstrap_result_t result; + ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result), + CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_TRUE(result.daemon_spawned); + ASSERT(atomic_load(&fake.lock_attempt_count) >= 1); + ASSERT_EQ(atomic_load(&fake.spawn_count), 1); + ASSERT(atomic_load(&fake.probe_count) > 1); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} + +TEST(daemon_bootstrap_rejected_connect_is_reserved_and_never_unavailable) { + cbm_daemon_runtime_connect_result_t capacity = {0}; + capacity.status = CBM_DAEMON_RUNTIME_CONNECT_REJECTED; + snprintf(capacity.message, sizeof(capacity.message), + "CBM daemon connection capacity reached"); + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, 1), + CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED); + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, 0), + CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED); + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, -1), + CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED); + + cbm_daemon_runtime_connect_result_t stopping = capacity; + snprintf(stopping.message, sizeof(stopping.message), "CBM daemon is stopping"); + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&stopping, 1), + CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL); + + cbm_daemon_runtime_connect_result_t absent = {0}; + absent.status = CBM_DAEMON_RUNTIME_CONNECT_ERROR; + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&absent, 1), + CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED); + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&absent, 0), + CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE); + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&absent, -1), + CBM_DAEMON_BOOTSTRAP_PROBE_ERROR); + PASS(); +} + +TEST(daemon_bootstrap_concurrent_first_clients_spawn_one_daemon) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "startup-race")); + bootstrap_fake_ops_t fake = {0}; + cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = "/tmp/cbm", + .connect_timeout_ms = 10, + .startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS, + }; + atomic_int ready = 0; + atomic_bool go = false; + bootstrap_thread_call_t calls[2] = { + {.config = &config, .ops = &ops, .ready = &ready, .go = &go}, + {.config = &config, .ops = &ops, .ready = &ready, .go = &go}, + }; + cbm_thread_t threads[2]; + ASSERT_EQ(cbm_thread_create(&threads[0], 0, bootstrap_thread_execute, &calls[0]), 0); + ASSERT_EQ(cbm_thread_create(&threads[1], 0, bootstrap_thread_execute, &calls[1]), 0); + uint64_t ready_deadline = cbm_now_ms() + BOOTSTRAP_TEST_TIMEOUT_MS; + while (atomic_load(&ready) != 2 && cbm_now_ms() < ready_deadline) { + const struct timespec pause = {0, 1000000L}; + (void)cbm_nanosleep(&pause, NULL); + } + atomic_store(&go, true); + ASSERT_EQ(cbm_thread_join(&threads[0]), 0); + ASSERT_EQ(cbm_thread_join(&threads[1]), 0); + ASSERT_EQ(calls[0].status, CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_EQ(calls[1].status, CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_EQ(atomic_load(&fake.spawn_count), 1); + ASSERT_TRUE(calls[0].result.daemon_spawned != calls[1].result.daemon_spawned); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} + +SUITE(daemon_bootstrap) { + RUN_TEST(daemon_bootstrap_classifies_default_and_ui_as_mcp_clients); + RUN_TEST(daemon_bootstrap_classifies_stateless_commands_without_client); + RUN_TEST(daemon_bootstrap_classifies_config_as_coordinated_local_cli); + RUN_TEST(daemon_bootstrap_cli_help_is_stateless_but_tool_calls_are_local); + RUN_TEST(daemon_bootstrap_cli_arguments_cannot_reclassify_the_process); + RUN_TEST(daemon_bootstrap_internal_roles_never_take_client_leases); + RUN_TEST(daemon_bootstrap_rejects_ambiguous_internal_daemon_argv); + RUN_TEST(daemon_bootstrap_uses_one_stable_per_account_endpoint); + RUN_TEST(daemon_bootstrap_launches_only_exact_detached_hidden_role); + RUN_TEST(daemon_bootstrap_stateless_roles_bypass_every_daemon_operation); + RUN_TEST(daemon_bootstrap_cohort_conflict_is_visible_before_probe_or_spawn); + RUN_TEST(daemon_bootstrap_existing_exact_daemon_connects_without_spawn); + RUN_TEST(daemon_bootstrap_conflict_is_visible_and_never_spawns); + RUN_TEST(daemon_bootstrap_terminal_generation_that_never_exits_is_not_replaced); + RUN_TEST(daemon_bootstrap_terminal_then_absent_spawns_replacement); + RUN_TEST(daemon_bootstrap_reserved_generation_becomes_connectable_without_spawn); + RUN_TEST(daemon_bootstrap_reserved_then_absent_spawns_replacement); + RUN_TEST(daemon_bootstrap_rejected_connect_is_reserved_and_never_unavailable); + RUN_TEST(daemon_bootstrap_concurrent_first_clients_spawn_one_daemon); +} diff --git a/tests/test_daemon_frontend.c b/tests/test_daemon_frontend.c new file mode 100644 index 000000000..3dda3f174 --- /dev/null +++ b/tests/test_daemon_frontend.c @@ -0,0 +1,489 @@ +/* + * test_daemon_frontend.c — Exact JSON-RPC cancellation and maintenance. + */ +#include "test_framework.h" +#include "test_helpers.h" + +#include "daemon/frontend.h" +#include "daemon/ipc.h" +#include "daemon/service.h" +#include "daemon/version_cohort.h" +#include "foundation/compat.h" +#include "foundation/platform.h" + +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#include +#include +#endif + +enum { FRONTEND_TEST_PATH_CAP = 1024 }; + +typedef struct { + char parent[FRONTEND_TEST_PATH_CAP]; + cbm_daemon_ipc_endpoint_t *endpoint; + cbm_version_cohort_manager_t *manager; +} frontend_maintenance_fixture_t; + +static bool frontend_maintenance_fixture_start( + frontend_maintenance_fixture_t *fixture, const char *tag) { + memset(fixture, 0, sizeof(*fixture)); + int written = snprintf(fixture->parent, sizeof(fixture->parent), + "%s/cbm-frontend-%s-XXXXXX", cbm_tmpdir(), tag); + if (written <= 0 || written >= (int)sizeof(fixture->parent) || + !cbm_mkdtemp(fixture->parent)) { + return false; + } + fixture->endpoint = + cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture->parent); + fixture->manager = fixture->endpoint + ? cbm_version_cohort_manager_new(fixture->endpoint) + : NULL; + return fixture->endpoint && fixture->manager; +} + +static void frontend_maintenance_fixture_finish( + frontend_maintenance_fixture_t *fixture) { + while (fixture->manager && + cbm_version_cohort_manager_free(&fixture->manager) != + CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + cbm_daemon_ipc_endpoint_free(fixture->endpoint); + if (fixture->parent[0]) { + (void)th_rmtree(fixture->parent); + } + memset(fixture, 0, sizeof(*fixture)); +} + +#ifndef _WIN32 +static const char FRONTEND_TEST_BUILD[] = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +static cbm_daemon_build_identity_t frontend_test_identity(void) { + cbm_daemon_build_identity_t identity = { + .semantic_version = "2.4.0", + .build_fingerprint = FRONTEND_TEST_BUILD, + .protocol_abi = 3, + .store_abi = 11, + .feature_abi = 7, + }; + return identity; +} + +static void frontend_test_release_lease( + cbm_version_cohort_lease_t **lease) { + while (lease && *lease && + cbm_version_cohort_lease_release(lease) != + CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } +} + +static bool frontend_test_wait_byte(int fd, char expected, + uint64_t deadline_ms) { + int flags = fcntl(fd, F_GETFL, 0); + if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) { + return false; + } + for (;;) { + char value = '\0'; + ssize_t count = read(fd, &value, 1); + if (count == 1) { + return value == expected; + } + if (count == 0 || + (count < 0 && errno != EAGAIN && errno != EWOULDBLOCK) || + cbm_now_ms() >= deadline_ms) { + return false; + } + cbm_usleep(1000); + } +} + +static cbm_version_cohort_quiesce_result_t +frontend_test_quiesce_requested(void *context) { + (void)context; + return CBM_VERSION_COHORT_QUIESCE_REQUESTED; +} + +static bool frontend_test_cancel_active(void *context) { + int fd = *(int *)context; + const char marker = 'C'; + return write(fd, &marker, 1) == 1; +} + +static bool frontend_test_cancel_and_mark(void *context) { + atomic_bool *cancelled = context; + atomic_store_explicit(cancelled, true, memory_order_release); + return true; +} +#endif + +TEST(daemon_frontend_recognizes_exact_cancellation_notification) { + ASSERT_TRUE(cbm_daemon_frontend_is_cancellation_notification( + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\"," + "\"params\":{\"requestId\":7}}")); + ASSERT_TRUE(cbm_daemon_frontend_is_cancellation_notification( + "{ \"params\": {}, \"method\": \"notifications/cancelled\", " + "\"jsonrpc\": \"2.0\" }")); + PASS(); +} + +/* RED on the whole-session cancellation shortcut: merely recognizing the + * notification method is not authority to close a session. The requestId + * must match the exact numeric/string identity currently being executed. */ +TEST(daemon_frontend_correlates_cancellation_to_exact_request) { + ASSERT_TRUE(cbm_daemon_frontend_cancellation_matches_request( + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\"," + "\"params\":{\"requestId\":7}}", + 7, NULL)); + ASSERT_FALSE(cbm_daemon_frontend_cancellation_matches_request( + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\"," + "\"params\":{\"requestId\":8}}", + 7, NULL)); + ASSERT_TRUE(cbm_daemon_frontend_cancellation_matches_request( + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\"," + "\"params\":{\"requestId\":\"request-7\"}}", + -1, "request-7")); + ASSERT_FALSE(cbm_daemon_frontend_cancellation_matches_request( + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\"," + "\"params\":{\"requestId\":7}}", + -1, "7")); + ASSERT_FALSE(cbm_daemon_frontend_cancellation_matches_request( + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\"," + "\"params\":{}}", + 7, NULL)); + ASSERT_FALSE(cbm_daemon_frontend_cancellation_matches_request( + "{\"jsonrpc\":\"2.0\",\"id\":9," + "\"method\":\"notifications/cancelled\"," + "\"params\":{\"requestId\":7}}", + 7, NULL)); + PASS(); +} + +TEST(daemon_frontend_ignores_cancellation_text_in_string_content) { + ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_graph\",\"arguments\":{\"query\":" + "\"notifications/cancelled\"}}}")); + ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification( + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"method\":\"notifications/cancelled\"}}")); + PASS(); +} + +TEST(daemon_frontend_rejects_non_notification_cancellation_shapes) { + ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification( + "{\"jsonrpc\":\"2.0\",\"id\":3," + "\"method\":\"notifications/cancelled\"}")); + ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification( + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled-extra\"}")); + ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification( + "{\"jsonrpc\":\"2.0\",\"method\":\"prefix/notifications/cancelled\"}")); + ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification( + "{\"jsonrpc\":\"2.0\",\"params\":{\"method\":" + "\"notifications/cancelled\"}}")); + ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification("not-json")); + ASSERT_FALSE(cbm_daemon_frontend_is_cancellation_notification(NULL)); + PASS(); +} + +#ifndef _WIN32 +/* RED: an MCP frontend's main thread can remain blocked forever in stdio while + * install/update/uninstall owns maintenance intent. The already-present + * frontend worker must observe that native intent and terminate this stateless + * child, releasing its cohort lease and kernel IPC ownership. A deliberately + * invalid client pointer is safe only if the maintenance path exits before + * ordinary EOF/session-close handling, which also pins that exact path. */ +TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked) { + frontend_maintenance_fixture_t fixture; + ASSERT_TRUE(frontend_maintenance_fixture_start(&fixture, "blocked-stdio")); + int input_pipe[2] = {-1, -1}; + int ready_pipe[2] = {-1, -1}; + bool pipes_ready = pipe(input_pipe) == 0 && pipe(ready_pipe) == 0; + pid_t child = pipes_ready ? fork() : -1; + if (child == 0) { + (void)close(input_pipe[1]); + (void)close(ready_pipe[0]); + cbm_daemon_ipc_endpoint_t *endpoint = + cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture.parent); + cbm_version_cohort_manager_t *manager = + endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL; + cbm_version_cohort_lease_t *participant = NULL; + cbm_daemon_conflict_t conflict; + cbm_daemon_build_identity_t identity = frontend_test_identity(); + cbm_version_cohort_status_t admitted = + manager ? cbm_version_cohort_acquire( + manager, &identity, cbm_now_ms() + 2000U, + &participant, &conflict) + : CBM_VERSION_COHORT_IO; + FILE *input = admitted == CBM_VERSION_COHORT_OK + ? fdopen(input_pipe[0], "rb") + : NULL; + FILE *output = input ? tmpfile() : NULL; + const char ready = 'R'; + bool announced = output && write(ready_pipe[1], &ready, 1) == 1; + (void)close(ready_pipe[1]); + if (!announced) { + _exit(70); + } + int result = cbm_daemon_frontend_mcp_run( + (cbm_daemon_runtime_client_t *)(uintptr_t)1, manager, input, + output); + (void)result; + _exit(71); + } + + if (pipes_ready) { + (void)close(input_pipe[0]); + (void)close(ready_pipe[1]); + } + bool announced = child > 0 && frontend_test_wait_byte( + ready_pipe[0], 'R', cbm_now_ms() + 2000U); + cbm_version_cohort_lease_t *mutation = NULL; + cbm_version_cohort_quiesce_result_t quiesce = + CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + cbm_version_cohort_status_t status = + announced ? cbm_version_cohort_reserve_for_mutation( + fixture.manager, cbm_now_ms() + 3000U, + frontend_test_quiesce_requested, NULL, &quiesce, + &mutation) + : CBM_VERSION_COHORT_IO; + if (status != CBM_VERSION_COHORT_OK && child > 0) { + (void)kill(child, SIGKILL); + } + int child_status = 0; + bool waited = child > 0 && waitpid(child, &child_status, 0) == child; + if (pipes_ready) { + (void)close(input_pipe[1]); + (void)close(ready_pipe[0]); + } + frontend_test_release_lease(&mutation); + frontend_maintenance_fixture_finish(&fixture); + + ASSERT_TRUE(pipes_ready); + ASSERT_TRUE(child > 0); + ASSERT_TRUE(announced); + ASSERT_EQ(status, CBM_VERSION_COHORT_OK); + ASSERT_EQ(quiesce, CBM_VERSION_COHORT_QUIESCE_REQUESTED); + ASSERT_TRUE(waited); + ASSERT_TRUE(WIFEXITED(child_status)); + ASSERT_EQ(WEXITSTATUS(child_status), 0); + PASS(); +} + +/* RED: local CLI/worker work has no standing daemon session to interrupt. Its + * command-lifetime monitor must request cooperative cancellation once, allow a + * bounded grace, then hard-exit the isolated process so SQLite/native locks + * roll back and the mutation barrier can prove that every lifetime participant + * is gone. */ +TEST(daemon_local_participant_monitor_cancels_then_bounds_active_operation) { + frontend_maintenance_fixture_t fixture; + ASSERT_TRUE(frontend_maintenance_fixture_start(&fixture, "local-monitor")); + int ready_pipe[2] = {-1, -1}; + int cancel_pipe[2] = {-1, -1}; + bool pipes_ready = pipe(ready_pipe) == 0 && pipe(cancel_pipe) == 0; + pid_t child = pipes_ready ? fork() : -1; + if (child == 0) { + (void)close(ready_pipe[0]); + (void)close(cancel_pipe[0]); + cbm_daemon_ipc_endpoint_t *endpoint = + cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture.parent); + cbm_version_cohort_manager_t *manager = + endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL; + cbm_version_cohort_lease_t *participant = NULL; + cbm_daemon_conflict_t conflict; + cbm_daemon_build_identity_t identity = frontend_test_identity(); + cbm_version_cohort_status_t admitted = + manager ? cbm_version_cohort_acquire( + manager, &identity, cbm_now_ms() + 2000U, + &participant, &conflict) + : CBM_VERSION_COHORT_IO; + cbm_daemon_maintenance_monitor_t *monitor = + admitted == CBM_VERSION_COHORT_OK + ? cbm_daemon_maintenance_monitor_start( + manager, frontend_test_cancel_active, &cancel_pipe[1], + 37, "test-local-operation") + : NULL; + const char ready = 'R'; + bool announced = monitor && write(ready_pipe[1], &ready, 1) == 1; + (void)close(ready_pipe[1]); + if (!announced) { + _exit(72); + } + for (;;) { + cbm_usleep(100000); + } + } + + if (pipes_ready) { + (void)close(ready_pipe[1]); + (void)close(cancel_pipe[1]); + } + bool announced = child > 0 && frontend_test_wait_byte( + ready_pipe[0], 'R', cbm_now_ms() + 2000U); + cbm_version_cohort_lease_t *mutation = NULL; + cbm_version_cohort_quiesce_result_t quiesce = + CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + cbm_version_cohort_status_t status = + announced ? cbm_version_cohort_reserve_for_mutation( + fixture.manager, cbm_now_ms() + 5000U, + frontend_test_quiesce_requested, NULL, &quiesce, + &mutation) + : CBM_VERSION_COHORT_IO; + bool cancelled = status == CBM_VERSION_COHORT_OK && + frontend_test_wait_byte(cancel_pipe[0], 'C', + cbm_now_ms() + 1000U); + if (status != CBM_VERSION_COHORT_OK && child > 0) { + (void)kill(child, SIGKILL); + } + int child_status = 0; + bool waited = child > 0 && waitpid(child, &child_status, 0) == child; + if (pipes_ready) { + (void)close(ready_pipe[0]); + (void)close(cancel_pipe[0]); + } + frontend_test_release_lease(&mutation); + frontend_maintenance_fixture_finish(&fixture); + + ASSERT_TRUE(pipes_ready); + ASSERT_TRUE(child > 0); + ASSERT_TRUE(announced); + ASSERT_EQ(status, CBM_VERSION_COHORT_OK); + ASSERT_EQ(quiesce, CBM_VERSION_COHORT_QUIESCE_REQUESTED); + ASSERT_TRUE(cancelled); + ASSERT_TRUE(waited); + ASSERT_TRUE(WIFEXITED(child_status)); + ASSERT_EQ(WEXITSTATUS(child_status), 37); + PASS(); +} + +/* RED: supervised process-tree cancellation permits one second of graceful + * shutdown followed by one second of forced containment. The maintenance + * observer must not _Exit the owning process before that bounded supervisor + * window can finish and join the observer normally. */ +TEST(daemon_local_participant_monitor_allows_supervisor_containment_window) { + frontend_maintenance_fixture_t fixture; + ASSERT_TRUE(frontend_maintenance_fixture_start(&fixture, "monitor-window")); + int ready_pipe[2] = {-1, -1}; + bool pipe_ready = pipe(ready_pipe) == 0; + pid_t child = pipe_ready ? fork() : -1; + if (child == 0) { + (void)close(ready_pipe[0]); + cbm_daemon_ipc_endpoint_t *endpoint = + cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture.parent); + cbm_version_cohort_manager_t *manager = + endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL; + cbm_version_cohort_lease_t *participant = NULL; + cbm_daemon_conflict_t conflict; + cbm_daemon_build_identity_t identity = frontend_test_identity(); + cbm_version_cohort_status_t admitted = + manager ? cbm_version_cohort_acquire( + manager, &identity, cbm_now_ms() + 2000U, + &participant, &conflict) + : CBM_VERSION_COHORT_IO; + atomic_bool cancelled; + atomic_init(&cancelled, false); + cbm_daemon_maintenance_monitor_t *monitor = + admitted == CBM_VERSION_COHORT_OK + ? cbm_daemon_maintenance_monitor_start( + manager, frontend_test_cancel_and_mark, &cancelled, + 38, "test-supervisor-window") + : NULL; + const char ready = 'R'; + bool announced = monitor && write(ready_pipe[1], &ready, 1) == 1; + (void)close(ready_pipe[1]); + if (!announced) { + _exit(72); + } + while (!atomic_load_explicit(&cancelled, memory_order_acquire)) { + cbm_usleep(1000); + } + + /* Model the maximum graceful + forced-settle supervisor bounds. */ + cbm_usleep(2100U * 1000U); + bool stopped = cbm_daemon_maintenance_monitor_stop(&monitor); + frontend_test_release_lease(&participant); + while (manager && + cbm_version_cohort_manager_free(&manager) != + CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + cbm_daemon_ipc_endpoint_free(endpoint); + _exit(stopped ? 0 : 73); + } + + if (pipe_ready) { + (void)close(ready_pipe[1]); + } + bool announced = child > 0 && frontend_test_wait_byte( + ready_pipe[0], 'R', cbm_now_ms() + 2000U); + cbm_version_cohort_lease_t *mutation = NULL; + cbm_version_cohort_quiesce_result_t quiesce = + CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + cbm_version_cohort_status_t status = + announced ? cbm_version_cohort_reserve_for_mutation( + fixture.manager, cbm_now_ms() + 5000U, + frontend_test_quiesce_requested, NULL, &quiesce, + &mutation) + : CBM_VERSION_COHORT_IO; + if (status != CBM_VERSION_COHORT_OK && child > 0) { + (void)kill(child, SIGKILL); + } + int child_status = 0; + bool waited = child > 0 && waitpid(child, &child_status, 0) == child; + if (pipe_ready) { + (void)close(ready_pipe[0]); + } + frontend_test_release_lease(&mutation); + frontend_maintenance_fixture_finish(&fixture); + + ASSERT_TRUE(pipe_ready); + ASSERT_TRUE(child > 0); + ASSERT_TRUE(announced); + ASSERT_EQ(status, CBM_VERSION_COHORT_OK); + ASSERT_EQ(quiesce, CBM_VERSION_COHORT_QUIESCE_REQUESTED); + ASSERT_TRUE(waited); + ASSERT_TRUE(WIFEXITED(child_status)); + ASSERT_EQ(WEXITSTATUS(child_status), 0); + PASS(); +} +#endif + +TEST(daemon_local_participant_monitor_joins_before_manager_teardown) { + frontend_maintenance_fixture_t fixture; + ASSERT_TRUE(frontend_maintenance_fixture_start(&fixture, "monitor-join")); + cbm_daemon_maintenance_monitor_t *monitor = + cbm_daemon_maintenance_monitor_start( + fixture.manager, NULL, NULL, EXIT_FAILURE, "test-idle-command"); + bool started = monitor != NULL; + bool stopped = started && cbm_daemon_maintenance_monitor_stop(&monitor); + bool consumed = monitor == NULL; + frontend_maintenance_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(stopped); + ASSERT_TRUE(consumed); + PASS(); +} + +SUITE(daemon_frontend) { + RUN_TEST(daemon_frontend_recognizes_exact_cancellation_notification); + RUN_TEST(daemon_frontend_correlates_cancellation_to_exact_request); + RUN_TEST(daemon_frontend_ignores_cancellation_text_in_string_content); + RUN_TEST(daemon_frontend_rejects_non_notification_cancellation_shapes); +#ifndef _WIN32 + RUN_TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked); + RUN_TEST(daemon_local_participant_monitor_cancels_then_bounds_active_operation); + RUN_TEST(daemon_local_participant_monitor_allows_supervisor_containment_window); +#endif + RUN_TEST(daemon_local_participant_monitor_joins_before_manager_teardown); +} diff --git a/tests/test_daemon_ipc.c b/tests/test_daemon_ipc.c new file mode 100644 index 000000000..ace3a348f --- /dev/null +++ b/tests/test_daemon_ipc.c @@ -0,0 +1,4870 @@ +/* + * test_daemon_ipc.c — RED guards for the local CBM daemon transport. + * + * These tests deliberately exercise the public transport boundary rather than + * platform socket/pipe primitives. The same API is expected to use Unix + * domain sockets on POSIX and current-user named pipes on Windows. + */ +#include "test_framework.h" + +#include "daemon/daemon.h" +#include "daemon/ipc.h" +#include "daemon/ipc_internal.h" +#include "foundation/compat.h" +#include "foundation/compat_thread.h" +#include "foundation/platform.h" +#include "foundation/private_file_lock_internal.h" +#include "foundation/subprocess.h" + +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include "foundation/win_utf8.h" +#include +#include +#ifndef PIPE_REJECT_REMOTE_CLIENTS +#define PIPE_REJECT_REMOTE_CLIENTS 0x00000008 +#endif +#define ipc_test_chdir _chdir +#define ipc_test_getcwd _getcwd +#else +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#include +#endif +#define ipc_test_chdir chdir +#define ipc_test_getcwd getcwd +#endif + +enum { TEST_PATH_CAP = 1024 }; + +static bool ipc_test_write_byte(const char *path, unsigned char byte); + +static bool ipc_test_parent_new(char out[TEST_PATH_CAP], const char *tag) { + int n = snprintf(out, TEST_PATH_CAP, "%s/cbm-ipc-%s-XXXXXX", cbm_tmpdir(), tag); + return n > 0 && n < TEST_PATH_CAP && cbm_mkdtemp(out) != NULL; +} + +static void ipc_test_copy_path(char out[TEST_PATH_CAP], const char *path) { + if (!path) { + out[0] = '\0'; + return; + } + (void)snprintf(out, TEST_PATH_CAP, "%s", path); +} + +static bool ipc_test_full_path(char out[TEST_PATH_CAP], const char *path) { +#ifdef _WIN32 + return path && _fullpath(out, path, TEST_PATH_CAP) != NULL; +#else + return path && realpath(path, out) != NULL; +#endif +} + +static bool ipc_test_path_is_absolute(const char *path) { + if (!path || path[0] == '\0') { + return false; + } +#ifdef _WIN32 + return (path[0] == '\\' && path[1] == '\\') || + (((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) && + path[1] == ':' && (path[2] == '/' || path[2] == '\\')); +#else + return path[0] == '/'; +#endif +} + +static bool ipc_test_path_has_parent(const char *path, const char *parent) { + if (!path || !parent) { + return false; + } + size_t parent_length = strlen(parent); + if (parent_length == 0) { + return false; + } + for (size_t i = 0; i < parent_length; i++) { + char actual = path[i]; + char expected = parent[i]; +#ifdef _WIN32 + if (actual == '/') { + actual = '\\'; + } + if (expected == '/') { + expected = '\\'; + } + if (actual >= 'A' && actual <= 'Z') { + actual = (char)(actual - 'A' + 'a'); + } + if (expected >= 'A' && expected <= 'Z') { + expected = (char)(expected - 'A' + 'a'); + } +#endif + if (actual == '\0' || actual != expected) { + return false; + } + } + if (parent[parent_length - 1] == '/' || parent[parent_length - 1] == '\\') { + return true; + } + return path[parent_length] == '/' || path[parent_length] == '\\'; +} + +/* Endpoint state is intentionally persistent while clients come and go. Tests + * use isolated parents, so remove any lock/socket artifacts after all handles + * have closed without following symlinks or spawning a cleanup shell. */ +static void ipc_test_remove_flat_dir(const char *path) { + if (!path || path[0] == '\0') { + return; + } +#ifdef _WIN32 + char pattern[TEST_PATH_CAP]; + char child[TEST_PATH_CAP]; + WIN32_FIND_DATAA entry; + (void)snprintf(pattern, sizeof(pattern), "%s/*", path); + HANDLE find = FindFirstFileA(pattern, &entry); + if (find != INVALID_HANDLE_VALUE) { + do { + if (strcmp(entry.cFileName, ".") == 0 || strcmp(entry.cFileName, "..") == 0) { + continue; + } + (void)snprintf(child, sizeof(child), "%s/%s", path, entry.cFileName); + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { + (void)RemoveDirectoryA(child); + } else { + (void)DeleteFileA(child); + } + } while (FindNextFileA(find, &entry)); + (void)FindClose(find); + } + (void)RemoveDirectoryA(path); +#else + DIR *dir = opendir(path); + if (dir) { + struct dirent *entry; + while ((entry = readdir(dir)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + char child[TEST_PATH_CAP]; + struct stat st; + (void)snprintf(child, sizeof(child), "%s/%s", path, entry->d_name); + if (lstat(child, &st) == 0 && S_ISDIR(st.st_mode)) { + (void)rmdir(child); + } else { + (void)unlink(child); + } + } + (void)closedir(dir); + } + (void)rmdir(path); +#endif +} + +static void ipc_test_remove_tree(const char *runtime_dir, const char *parent) { + if (runtime_dir && parent && strcmp(runtime_dir, parent) != 0) { + ipc_test_remove_flat_dir(runtime_dir); + } + ipc_test_remove_flat_dir(parent); +} + +#ifdef __APPLE__ +/* Return 1 when installed, 0 only when the backing filesystem does not support + * Darwin extended ACLs, and -1 for every other fixture failure. */ +static int ipc_test_macos_set_mutating_acl(const char *path, bool inheritable) { + if (!path || path[0] == '\0') { + errno = EINVAL; + return -1; + } + + acl_t acl = acl_init(1); + acl_entry_t entry = NULL; + acl_permset_t permissions = NULL; + acl_flagset_t flags = NULL; + bool built = acl && acl_create_entry(&acl, &entry) == 0 && entry && + acl_set_tag_type(entry, ACL_EXTENDED_ALLOW) == 0; + + /* wheel (0) and daemon (1) are stable real macOS system groups. Use the + * one that is not the process's effective group so the fixture grants a + * distinct principal directory-mutation rights. */ + gid_t foreign_group = getegid() == (gid_t)0 ? (gid_t)1 : (gid_t)0; + uuid_t foreign_group_uuid = {0}; + int membership_result = + built ? mbr_gid_to_uuid(foreign_group, foreign_group_uuid) : 0; + if (built && membership_result != 0) { + errno = membership_result; + built = false; + } + built = built && acl_set_qualifier(entry, foreign_group_uuid) == 0 && + acl_get_permset(entry, &permissions) == 0 && permissions && + acl_clear_perms(permissions) == 0 && + acl_add_perm(permissions, ACL_ADD_FILE) == 0 && + acl_add_perm(permissions, ACL_ADD_SUBDIRECTORY) == 0 && + acl_add_perm(permissions, ACL_DELETE_CHILD) == 0 && + acl_set_permset(entry, permissions) == 0; + + if (built && inheritable) { + built = acl_get_flagset_np(entry, &flags) == 0 && flags && + acl_clear_flags_np(flags) == 0 && + acl_add_flag_np(flags, ACL_ENTRY_FILE_INHERIT) == 0 && + acl_add_flag_np(flags, ACL_ENTRY_DIRECTORY_INHERIT) == 0 && + acl_set_flagset_np(entry, flags) == 0; + } + built = built && acl_valid(acl) == 0; + + errno = built ? 0 : errno; + int result = built ? acl_set_file(path, ACL_TYPE_EXTENDED, acl) : -1; + int saved_error = errno; + if (acl && acl_free(acl) != 0 && result == 0) { + result = -1; + saved_error = errno; + } + errno = saved_error; + if (result == 0) { + return 1; + } + return saved_error == ENOTSUP || saved_error == EOPNOTSUPP ? 0 : -1; +} + +/* An ACL-less existing path is reported by Darwin either as an empty ACL or, + * on some filesystems, ENOENT. Validate the path first so ENOENT cannot hide + * a missing runtime directory. */ +static int ipc_test_macos_extended_acl_entry_count(const char *path) { + struct stat status; + if (!path || lstat(path, &status) != 0) { + return -1; + } + + errno = 0; + acl_t acl = acl_get_file(path, ACL_TYPE_EXTENDED); + if (!acl) { + return errno == ENOENT ? 0 : -1; + } + + int count = 0; + acl_entry_t entry = NULL; + int entry_result = acl_get_entry(acl, ACL_FIRST_ENTRY, &entry); + /* Darwin's acl_get_entry returns 0 for each entry and -1/EINVAL after + * ACL_NEXT_ENTRY advances beyond the final entry (unlike the 1/0 return + * convention used by several other POSIX ACL implementations). */ + while (entry_result == 0) { + count++; + errno = 0; + entry_result = acl_get_entry(acl, ACL_NEXT_ENTRY, &entry); + } + int entry_error = errno; + int free_result = acl_free(acl); + return entry_result == -1 && entry_error == EINVAL && free_result == 0 + ? count + : -1; +} +#endif + +#ifdef _WIN32 +typedef struct { + wchar_t *value; + bool present; +} ipc_test_win_env_t; + +static bool ipc_test_win_env_capture(const wchar_t *name, ipc_test_win_env_t *saved) { + if (!name || !saved) { + return false; + } + memset(saved, 0, sizeof(*saved)); + SetLastError(ERROR_SUCCESS); + DWORD needed = GetEnvironmentVariableW(name, NULL, 0); + if (needed == 0) { + DWORD error = GetLastError(); + return error == ERROR_ENVVAR_NOT_FOUND || error == ERROR_SUCCESS; + } + saved->value = malloc((size_t)needed * sizeof(*saved->value)); + if (!saved->value || GetEnvironmentVariableW(name, saved->value, needed) + 1U != needed) { + free(saved->value); + saved->value = NULL; + return false; + } + saved->present = true; + return true; +} + +static bool ipc_test_win_env_restore(const wchar_t *name, ipc_test_win_env_t *saved) { + if (!name || !saved) { + return false; + } + bool restored = SetEnvironmentVariableW(name, saved->present ? saved->value : NULL) != 0; + free(saved->value); + memset(saved, 0, sizeof(*saved)); + return restored; +} + +static bool ipc_test_win_temp_set(const char *path) { + wchar_t *wide = cbm_utf8_to_wide(path); + bool changed = wide && SetEnvironmentVariableW(L"TMP", wide) != 0 && + SetEnvironmentVariableW(L"TEMP", wide) != 0; + free(wide); + return changed; +} + +static int ipc_test_win_lock_child(const char *kind, const char *key, + const char *parent) { + char self[MAX_PATH]; + DWORD self_length = GetModuleFileNameA(NULL, self, sizeof(self)); + if (!kind || !key || !parent || self_length == 0 || + self_length >= sizeof(self)) { + return -1; + } + const char *const argv[] = { + self, "__cbm_daemon_ipc_lock_probe", kind, key, parent, NULL, + }; + cbm_proc_opts_t options = { + .bin = self, + .argv = argv, + .quiet_timeout_ms = 5000, + }; + cbm_proc_result_t result; + return cbm_subprocess_run(&options, &result) == 0 ? result.exit_code : -1; +} + +typedef struct { + const cbm_daemon_ipc_endpoint_t *endpoint; + int result; +} ipc_test_win_startup_call_t; + +static void *ipc_test_win_startup_call(void *opaque) { + ipc_test_win_startup_call_t *call = opaque; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + call->result = cbm_daemon_ipc_startup_lock_try_acquire( + call->endpoint, &startup); + if (call->result == 1 && + !cbm_daemon_ipc_startup_lock_prepare_handoff(startup)) { + call->result = 0; + } + cbm_daemon_ipc_startup_lock_release(&startup); + return NULL; +} + +static void ipc_test_win_lock_release(cbm_private_file_lock_t **lock_io) { + while (lock_io && *lock_io && + cbm_private_file_lock_release(lock_io) != + CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } +} + +static bool ipc_test_win_lock_busy(cbm_private_lock_directory_t *directory, + const char *base_name) { + cbm_private_file_lock_t *probe = NULL; + cbm_private_file_lock_status_t status = + cbm_private_file_lock_try_acquire( + directory, base_name, CBM_PRIVATE_FILE_LOCK_SH, &probe); + ipc_test_win_lock_release(&probe); + return status == CBM_PRIVATE_FILE_LOCK_BUSY; +} +#endif + +typedef struct { + cbm_ipc_pending_wait_status_t wait_status; + cbm_ipc_pending_finish_status_t finish_status; + uint32_t finish_bytes; + char calls[8]; + size_t call_count; + bool finish_was_blocking; +} ipc_pending_fake_t; + +static void ipc_pending_fake_record(ipc_pending_fake_t *fake, char call) { + if (fake->call_count + 1 < sizeof(fake->calls)) { + fake->calls[fake->call_count++] = call; + fake->calls[fake->call_count] = '\0'; + } +} + +static cbm_ipc_pending_wait_status_t ipc_pending_fake_wait(void *opaque, uint32_t timeout_ms) { + ipc_pending_fake_t *fake = (ipc_pending_fake_t *)opaque; + (void)timeout_ms; + ipc_pending_fake_record(fake, 'W'); + return fake->wait_status; +} + +static void ipc_pending_fake_cancel(void *opaque) { + ipc_pending_fake_t *fake = (ipc_pending_fake_t *)opaque; + ipc_pending_fake_record(fake, 'C'); +} + +static cbm_ipc_pending_finish_status_t ipc_pending_fake_finish(void *opaque, bool blocking, + uint32_t *transferred_out) { + ipc_pending_fake_t *fake = (ipc_pending_fake_t *)opaque; + ipc_pending_fake_record(fake, 'D'); + fake->finish_was_blocking = blocking; + if (fake->finish_status == CBM_IPC_PENDING_FINISH_COMPLETED) { + *transferred_out = fake->finish_bytes; + } + return fake->finish_status; +} + +static int ipc_pending_fake_run(ipc_pending_fake_t *fake, uint32_t *transferred_out) { + cbm_ipc_pending_ops_t ops = { + .context = fake, + .wait = ipc_pending_fake_wait, + .cancel = ipc_pending_fake_cancel, + .finish = ipc_pending_fake_finish, + }; + return cbm_daemon_ipc_wait_pending(&ops, 17, transferred_out); +} + +TEST(daemon_ipc_pending_timeout_race_returns_completed_io) { + ipc_pending_fake_t fake = { + .wait_status = CBM_IPC_PENDING_WAIT_TIMEOUT, + .finish_status = CBM_IPC_PENDING_FINISH_COMPLETED, + .finish_bytes = 6, + }; + uint32_t transferred = 0; + int result = ipc_pending_fake_run(&fake, &transferred); + + ASSERT_EQ(result, 1); + ASSERT_EQ(transferred, 6); + ASSERT_STR_EQ(fake.calls, "WCD"); + ASSERT_TRUE(fake.finish_was_blocking); + PASS(); +} + +TEST(daemon_ipc_pending_wait_failure_cancels_and_drains) { + ipc_pending_fake_t fake = { + .wait_status = CBM_IPC_PENDING_WAIT_FAILED, + .finish_status = CBM_IPC_PENDING_FINISH_CANCELLED, + }; + uint32_t transferred = 0; + int result = ipc_pending_fake_run(&fake, &transferred); + + ASSERT_EQ(result, -1); + ASSERT_STR_EQ(fake.calls, "WCD"); + ASSERT_TRUE(fake.finish_was_blocking); + PASS(); +} + +TEST(daemon_ipc_pending_timeout_cancelled_returns_timeout) { + ipc_pending_fake_t fake = { + .wait_status = CBM_IPC_PENDING_WAIT_TIMEOUT, + .finish_status = CBM_IPC_PENDING_FINISH_CANCELLED, + }; + uint32_t transferred = 0; + int result = ipc_pending_fake_run(&fake, &transferred); + + ASSERT_EQ(result, 0); + ASSERT_STR_EQ(fake.calls, "WCD"); + ASSERT_TRUE(fake.finish_was_blocking); + PASS(); +} + +TEST(daemon_ipc_windows_generation_address_binds_account_key_and_nonce) { + /* S-1-5-21-305419896-2596069104-267242409 */ + static const uint8_t sid_a[] = { + 1, 4, 0, 0, 0, 0, 0, 5, 21, 0, 0, 0, + 0x78, 0x56, 0x34, 0x12, 0xf0, 0xde, 0xbc, 0x9a, 0xa9, 0xcb, 0xed, 0x0f, + }; + /* Same account shape, different final sub-authority. */ + static const uint8_t sid_b[] = { + 1, 4, 0, 0, 0, 0, 0, 5, 21, 0, 0, 0, + 0x78, 0x56, 0x34, 0x12, 0xf0, 0xde, 0xbc, 0x9a, 0xaa, 0xcb, 0xed, 0x0f, + }; + static const char key[] = "0123456789abcdef"; + static const char other_key[] = "fedcba9876543210"; + static const uint8_t nonce_a[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE] = { + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, + 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + }; + static const uint8_t nonce_b[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE] = { + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, + 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, + 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, + 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, + }; + char address_a[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char address_same[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char address_b[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char address_other_key[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char address_other_nonce[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + + bool first = cbm_daemon_ipc_windows_generation_address( + sid_a, sizeof(sid_a), key, nonce_a, address_a); + bool repeated = cbm_daemon_ipc_windows_generation_address( + sid_a, sizeof(sid_a), key, nonce_a, address_same); + bool other_user = cbm_daemon_ipc_windows_generation_address( + sid_b, sizeof(sid_b), key, nonce_a, address_b); + bool changed_key = cbm_daemon_ipc_windows_generation_address( + sid_a, sizeof(sid_a), other_key, nonce_a, address_other_key); + bool changed_nonce = cbm_daemon_ipc_windows_generation_address( + sid_a, sizeof(sid_a), key, nonce_b, address_other_nonce); + + ASSERT_TRUE(first); + ASSERT_TRUE(repeated); + ASSERT_TRUE(other_user); + ASSERT_TRUE(changed_key); + ASSERT_TRUE(changed_nonce); + ASSERT_STR_EQ(address_a, address_same); + ASSERT_TRUE(strcmp(address_a, address_b) != 0); + ASSERT_TRUE(strcmp(address_a, address_other_key) != 0); + ASSERT_TRUE(strcmp(address_a, address_other_nonce) != 0); + ASSERT_STR_EQ(address_a, + "\\\\.\\pipe\\cbm-daemon-" + "e861648d9f8bc786dce31bbb16eda2ab" + "ffa330a770752832ab5f2e4feaa506f1"); + ASSERT_TRUE(strstr(address_a, "S-1-") == NULL); + ASSERT_TRUE(strstr(address_a, key) == NULL); + PASS(); +} + +TEST(daemon_ipc_windows_legacy_names_are_frozen_for_migration) { + char pipe_name[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char startup_name[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + bool derived = cbm_daemon_ipc_windows_legacy_names( + "C:\\Users\\Alice\\AppData\\Local", "0123456789abcdef", + pipe_name, startup_name); + + ASSERT_TRUE(derived); + ASSERT_STR_EQ(pipe_name, + "\\\\.\\pipe\\cbm-daemon-72380d6ef7f19c0c-" + "0123456789abcdef"); + ASSERT_STR_EQ(startup_name, + "Local\\cbm-daemon-72380d6ef7f19c0c-" + "0123456789abcdef-startup"); + PASS(); +} + +TEST(daemon_ipc_windows_rendezvous_record_is_exact_and_canonical) { + static const uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE] = { + 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, + 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, + 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, + 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, + }; + static const char address[] = + "\\\\.\\pipe\\cbm-daemon-" + "0123456789abcdef0123456789abcdef" + "0123456789abcdef0123456789abcdef"; + uint8_t record[CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE]; + uint8_t decoded_nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE] = {0}; + char decoded_address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + + bool encoded = cbm_daemon_ipc_windows_rendezvous_record_encode( + nonce, address, record); + bool decoded = encoded && cbm_daemon_ipc_windows_rendezvous_record_decode( + record, sizeof(record), decoded_nonce, + decoded_address); + ASSERT_TRUE(encoded); + ASSERT_TRUE(decoded); + ASSERT_TRUE(memcmp(decoded_nonce, nonce, sizeof(nonce)) == 0); + ASSERT_STR_EQ(decoded_address, address); + + uint8_t corrupt[sizeof(record)]; + memcpy(corrupt, record, sizeof(corrupt)); + corrupt[0] ^= 0xffU; + ASSERT_FALSE(cbm_daemon_ipc_windows_rendezvous_record_decode( + corrupt, sizeof(corrupt), decoded_nonce, decoded_address)); + ASSERT_FALSE(cbm_daemon_ipc_windows_rendezvous_record_decode( + record, sizeof(record) - 1U, decoded_nonce, decoded_address)); + + memcpy(corrupt, record, sizeof(corrupt)); + corrupt[sizeof(corrupt) - 1U] = 1U; + ASSERT_FALSE(cbm_daemon_ipc_windows_rendezvous_record_decode( + corrupt, sizeof(corrupt), decoded_nonce, decoded_address)); + + memcpy(corrupt, record, sizeof(corrupt)); + const size_t address_offset = + 8U + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE; + corrupt[address_offset + strlen("\\\\.\\pipe\\cbm-daemon-")] = 'A'; + ASSERT_FALSE(cbm_daemon_ipc_windows_rendezvous_record_decode( + corrupt, sizeof(corrupt), decoded_nonce, decoded_address)); + PASS(); +} + +#ifdef _WIN32 +TEST(daemon_ipc_windows_default_endpoint_ignores_temp_environment) { + char key[17]; + char parent_a[TEST_PATH_CAP] = {0}; + char parent_b[TEST_PATH_CAP] = {0}; + char runtime_a[TEST_PATH_CAP] = {0}; + char runtime_b[TEST_PATH_CAP] = {0}; + char address_a[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char address_b[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + ipc_test_win_env_t saved_tmp = {0}; + ipc_test_win_env_t saved_temp = {0}; + cbm_daemon_ipc_endpoint_t *endpoint_a = NULL; + cbm_daemon_ipc_endpoint_t *endpoint_b = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + unsigned long long unique = + ((unsigned long long)GetCurrentProcessId() << 32U) ^ GetTickCount64(); + bool key_ok = snprintf(key, sizeof(key), "%016llx", unique) == 16; + bool parents_ok = + ipc_test_parent_new(parent_a, "win-temp-a") && ipc_test_parent_new(parent_b, "win-temp-b"); + bool tmp_saved = ipc_test_win_env_capture(L"TMP", &saved_tmp); + bool temp_saved = ipc_test_win_env_capture(L"TEMP", &saved_temp); + bool saved = tmp_saved && temp_saved; + bool first_temp = parents_ok && saved && ipc_test_win_temp_set(parent_a); + if (key_ok && first_temp) { + endpoint_a = cbm_daemon_ipc_endpoint_new(key, NULL); + } + if (endpoint_a) { + int startup_status = cbm_daemon_ipc_startup_lock_try_acquire( + endpoint_a, &startup); + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + if (startup_status != 1) { + cbm_daemon_ipc_endpoint_free(endpoint_a); + endpoint_a = NULL; + } + } + if (endpoint_a) { + ipc_test_copy_path(runtime_a, cbm_daemon_ipc_endpoint_runtime_dir(endpoint_a)); + ipc_test_copy_path(address_a, cbm_daemon_ipc_endpoint_address(endpoint_a)); + } + bool second_temp = endpoint_a && ipc_test_win_temp_set(parent_b); + if (second_temp) { + endpoint_b = cbm_daemon_ipc_endpoint_new(key, NULL); + } + if (endpoint_b) { + ipc_test_copy_path(runtime_b, cbm_daemon_ipc_endpoint_runtime_dir(endpoint_b)); + ipc_test_copy_path(address_b, cbm_daemon_ipc_endpoint_address(endpoint_b)); + } + bool stable = endpoint_a && endpoint_b && strcmp(address_a, address_b) == 0 && + address_a[0] != '\0' && + strcmp(runtime_a, runtime_b) == 0 && + !ipc_test_path_has_parent(runtime_a, parent_a) && + !ipc_test_path_has_parent(runtime_a, parent_b); + + bool restored_tmp = tmp_saved && ipc_test_win_env_restore(L"TMP", &saved_tmp); + bool restored_temp = temp_saved && ipc_test_win_env_restore(L"TEMP", &saved_temp); + cbm_daemon_ipc_endpoint_free(endpoint_b); + cbm_daemon_ipc_endpoint_free(endpoint_a); + if (runtime_b[0] != '\0' && strcmp(runtime_b, runtime_a) != 0) { + ipc_test_remove_flat_dir(runtime_b); + } + ipc_test_remove_flat_dir(runtime_a); + ipc_test_remove_flat_dir(parent_b); + ipc_test_remove_flat_dir(parent_a); + + ASSERT_TRUE(key_ok); + ASSERT_TRUE(parents_ok); + ASSERT_TRUE(saved); + ASSERT_TRUE(first_temp); + ASSERT_TRUE(second_temp); + ASSERT_TRUE(restored_tmp); + ASSERT_TRUE(restored_temp); + ASSERT_TRUE(stable); + PASS(); +} + +TEST(daemon_ipc_windows_legacy_bridge_covers_handoff_and_lifetime) { + static const char key[] = "91a2b3c4d5e6f708"; + char parent[TEST_PATH_CAP] = {0}; + char canonical_parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char legacy_pipe[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char legacy_startup[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_daemon_ipc_participant_guard_t *participant = NULL; + cbm_daemon_ipc_lifetime_reservation_t *lifetime = NULL; + HANDLE old_pipe = INVALID_HANDLE_VALUE; + + bool parent_ok = ipc_test_parent_new(parent, "win-legacy-bridge") && + ipc_test_full_path(canonical_parent, parent); + bool names_ok = parent_ok && cbm_daemon_ipc_windows_legacy_names( + canonical_parent, key, legacy_pipe, + legacy_startup); + if (names_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int absent_before = + endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + int startup_status = + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) + : -1; + int visible_during_startup = + startup ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + bool handoff = startup && + cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + int visible_during_handoff = + handoff ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + int participant_status = + handoff ? cbm_daemon_ipc_participant_guard_try_join( + endpoint, &participant) + : -1; + int lifetime_status = + participant_status == 1 + ? cbm_daemon_ipc_lifetime_reservation_try_acquire( + endpoint, &lifetime) + : -1; + int visible_during_lifetime = + lifetime ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + cbm_daemon_ipc_lifetime_reservation_release(lifetime); + lifetime = NULL; + bool participant_released = + cbm_daemon_ipc_participant_guard_release(&participant); + int absent_after_lifetime = + endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + + typedef BOOL(WINAPI *ipc_test_initialize_sd_fn)(PSECURITY_DESCRIPTOR, + DWORD); + typedef BOOL(WINAPI *ipc_test_set_dacl_fn)(PSECURITY_DESCRIPTOR, BOOL, + PACL, BOOL); + HMODULE advapi = LoadLibraryW(L"advapi32.dll"); + ipc_test_initialize_sd_fn initialize_sd = + advapi ? (ipc_test_initialize_sd_fn)GetProcAddress( + advapi, "InitializeSecurityDescriptor") + : NULL; + ipc_test_set_dacl_fn set_dacl = + advapi ? (ipc_test_set_dacl_fn)GetProcAddress( + advapi, "SetSecurityDescriptorDacl") + : NULL; + SECURITY_DESCRIPTOR unsafe_descriptor; + bool unsafe_descriptor_ok = + initialize_sd && set_dacl && + initialize_sd(&unsafe_descriptor, SECURITY_DESCRIPTOR_REVISION) && + set_dacl(&unsafe_descriptor, TRUE, NULL, FALSE); + SECURITY_ATTRIBUTES unsafe_attributes = { + .nLength = sizeof(unsafe_attributes), + .lpSecurityDescriptor = unsafe_descriptor_ok ? &unsafe_descriptor + : NULL, + .bInheritHandle = FALSE, + }; + wchar_t *legacy_startup_wide = + names_ok ? cbm_utf8_to_wide(legacy_startup) : NULL; + HANDLE unsafe_mutex = + unsafe_descriptor_ok && legacy_startup_wide + ? CreateMutexW(&unsafe_attributes, FALSE, legacy_startup_wide) + : NULL; + free(legacy_startup_wide); + int unsafe_probe = unsafe_mutex + ? cbm_daemon_ipc_legacy_generation_probe(endpoint) + : 0; + cbm_daemon_ipc_startup_lock_t *unsafe_startup = NULL; + int unsafe_startup_status = + unsafe_mutex ? cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &unsafe_startup) + : 0; + cbm_daemon_ipc_startup_lock_release(&unsafe_startup); + if (unsafe_mutex) { + (void)CloseHandle(unsafe_mutex); + } + if (advapi) { + (void)FreeLibrary(advapi); + } + int absent_after_unsafe = + endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + + wchar_t *old_name = names_ok ? cbm_utf8_to_wide(legacy_pipe) : NULL; + if (old_name) { + old_pipe = CreateNamedPipeW( + old_name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | + PIPE_REJECT_REMOTE_CLIENTS, + 1, 4096, 4096, 0, NULL); + } + free(old_name); + int visible_legacy_pipe = + old_pipe != INVALID_HANDLE_VALUE + ? cbm_daemon_ipc_legacy_generation_probe(endpoint) + : -1; + if (old_pipe != INVALID_HANDLE_VALUE) { + (void)CloseHandle(old_pipe); + } + int absent_after_pipe = + endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + + cbm_daemon_ipc_lifetime_reservation_release(lifetime); + (void)cbm_daemon_ipc_participant_guard_release(&participant); + cbm_daemon_ipc_startup_lock_release(&startup); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(names_ok); + ASSERT_EQ(absent_before, 0); + ASSERT_EQ(startup_status, 1); + ASSERT_EQ(visible_during_startup, 1); + ASSERT_TRUE(handoff); + ASSERT_EQ(visible_during_handoff, 1); + ASSERT_EQ(participant_status, 1); + ASSERT_EQ(lifetime_status, 1); + ASSERT_EQ(visible_during_lifetime, 1); + ASSERT_EQ(absent_after_lifetime, 0); + ASSERT_TRUE(participant_released); + ASSERT_NULL(participant); + ASSERT_TRUE(unsafe_descriptor_ok); + ASSERT_TRUE(unsafe_mutex != NULL); + ASSERT_EQ(unsafe_probe, -1); + ASSERT_EQ(unsafe_startup_status, -1); + ASSERT_EQ(absent_after_unsafe, 0); + ASSERT_TRUE(old_pipe != INVALID_HANDLE_VALUE); + ASSERT_EQ(visible_legacy_pipe, 1); + ASSERT_EQ(absent_after_pipe, 0); + PASS(); +} + +TEST(daemon_ipc_windows_local_transition_atomically_reserves_legacy_pipe) { + static const char key[] = "71a2b3c4d5e6f809"; + char parent[TEST_PATH_CAP] = {0}; + char canonical_parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char legacy_pipe[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char legacy_startup[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_local_transition_t *transition_wins = NULL; + cbm_daemon_ipc_local_transition_t *legacy_wins = NULL; + HANDLE old_after_sentinel = INVALID_HANDLE_VALUE; + HANDLE old_first = INVALID_HANDLE_VALUE; + + bool parent_ok = ipc_test_parent_new(parent, "win-local-transition") && + ipc_test_full_path(canonical_parent, parent); + bool names_ok = + parent_ok && cbm_daemon_ipc_windows_legacy_names( + canonical_parent, key, legacy_pipe, legacy_startup); + if (names_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int transition_wins_result = + endpoint ? cbm_daemon_ipc_local_transition_try_acquire( + endpoint, &transition_wins) + : -1; + int sentinel_result = + transition_wins_result == 1 + ? cbm_daemon_ipc_local_transition_seal_legacy(transition_wins) + : -1; + int sentinel_visible = + sentinel_result == 1 + ? cbm_daemon_ipc_legacy_generation_probe(endpoint) + : -1; + wchar_t *legacy_name = names_ok ? cbm_utf8_to_wide(legacy_pipe) : NULL; + if (legacy_name && sentinel_result == 1) { + old_after_sentinel = CreateNamedPipeW( + legacy_name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | + PIPE_REJECT_REMOTE_CLIENTS, + 1, 4096, 4096, 0, NULL); + } + bool work_begun = + sentinel_result == 1 && + cbm_daemon_ipc_local_transition_begin_work(transition_wins); + bool transition_wins_released = + cbm_daemon_ipc_local_transition_release(&transition_wins); + int absent_after_sentinel = + endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + + if (legacy_name) { + old_first = CreateNamedPipeW( + legacy_name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | + PIPE_REJECT_REMOTE_CLIENTS, + 1, 4096, 4096, 0, NULL); + } + int legacy_wins_result = + old_first != INVALID_HANDLE_VALUE + ? cbm_daemon_ipc_local_transition_try_acquire(endpoint, + &legacy_wins) + : -1; + int rejected_after_legacy_win = + legacy_wins_result == 1 + ? cbm_daemon_ipc_local_transition_seal_legacy(legacy_wins) + : -1; + bool legacy_wins_released = + cbm_daemon_ipc_local_transition_release(&legacy_wins); + if (old_after_sentinel != INVALID_HANDLE_VALUE) { + (void)CloseHandle(old_after_sentinel); + } + if (old_first != INVALID_HANDLE_VALUE) { + (void)CloseHandle(old_first); + } + free(legacy_name); + int absent_after_old = + endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + + (void)cbm_daemon_ipc_local_transition_release(&legacy_wins); + (void)cbm_daemon_ipc_local_transition_release(&transition_wins); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(names_ok); + ASSERT_EQ(transition_wins_result, 1); + ASSERT_EQ(sentinel_result, 1); + ASSERT_EQ(sentinel_visible, 1); + ASSERT_TRUE(old_after_sentinel == INVALID_HANDLE_VALUE); + ASSERT_TRUE(work_begun); + ASSERT_TRUE(transition_wins_released); + ASSERT_NULL(transition_wins); + ASSERT_EQ(absent_after_sentinel, 0); + ASSERT_TRUE(old_first != INVALID_HANDLE_VALUE); + ASSERT_EQ(legacy_wins_result, 1); + ASSERT_EQ(rejected_after_legacy_win, 0); + ASSERT_TRUE(legacy_wins_released); + ASSERT_NULL(legacy_wins); + ASSERT_EQ(absent_after_old, 0); + PASS(); +} + +TEST(daemon_ipc_windows_startup_retries_transient_rendezvous_reader) { + static const char key[] = "81b2c3d4e5f60719"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_private_lock_directory_t *directory = NULL; + cbm_private_file_lock_t *record_reader = NULL; + cbm_thread_t thread; + bool thread_started = false; + bool startup_observed = false; + int join_status = -1; + ipc_test_win_startup_call_t call = {.result = -1}; + + bool parent_ok = ipc_test_parent_new(parent, "win-record-reader"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + call.endpoint = endpoint; + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + cbm_private_file_lock_status_t directory_status = + endpoint ? cbm_daemon_ipc_private_lock_directory_new(endpoint, + &directory) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t record_status = + directory_status == CBM_PRIVATE_FILE_LOCK_OK + ? cbm_private_file_lock_try_acquire( + directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_SH, &record_reader) + : CBM_PRIVATE_FILE_LOCK_IO; + if (record_status == CBM_PRIVATE_FILE_LOCK_OK) { + thread_started = cbm_thread_create(&thread, 0, + ipc_test_win_startup_call, + &call) == 0; + } + for (size_t attempt = 0; thread_started && attempt < 200U; attempt++) { + if (ipc_test_win_lock_busy(directory, "cbm-startup-v2.lock")) { + startup_observed = true; + break; + } + cbm_usleep(1000); + } + ipc_test_win_lock_release(&record_reader); + if (thread_started) { + join_status = cbm_thread_join(&thread); + } + if (endpoint) { + ipc_test_copy_path(address, + cbm_daemon_ipc_endpoint_address(endpoint)); + } + + ipc_test_win_lock_release(&record_reader); + cbm_private_lock_directory_close(directory); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(directory_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(record_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(startup_observed); + ASSERT_EQ(join_status, 0); + ASSERT_EQ(call.result, 1); + ASSERT_TRUE(address[0] != '\0'); + PASS(); +} + +TEST(daemon_ipc_windows_rendezvous_bridges_concurrent_lifetime_owner) { + static const char key[] = "71b2c3d4e5f60729"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char before[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char after[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *initial_startup = NULL; + cbm_private_lock_directory_t *directory = NULL; + cbm_private_file_lock_t *record_reader = NULL; + cbm_private_file_lock_t *lifetime_owner = NULL; + cbm_thread_t thread; + bool thread_started = false; + bool startup_observed = false; + int join_status = -1; + ipc_test_win_startup_call_t call = {.result = -1}; + + bool parent_ok = ipc_test_parent_new(parent, "win-lifetime-bridge"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + call.endpoint = endpoint; + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int initial_status = + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &initial_startup) + : -1; + if (initial_status == 1 && + !cbm_daemon_ipc_startup_lock_prepare_handoff(initial_startup)) { + initial_status = -1; + } + if (initial_status == 1) { + ipc_test_copy_path(before, + cbm_daemon_ipc_endpoint_address(endpoint)); + } + cbm_daemon_ipc_startup_lock_release(&initial_startup); + initial_startup = NULL; + + cbm_private_file_lock_status_t directory_status = + endpoint ? cbm_daemon_ipc_private_lock_directory_new(endpoint, + &directory) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t record_status = + directory_status == CBM_PRIVATE_FILE_LOCK_OK + ? cbm_private_file_lock_try_acquire( + directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_SH, &record_reader) + : CBM_PRIVATE_FILE_LOCK_IO; + if (record_status == CBM_PRIVATE_FILE_LOCK_OK) { + thread_started = cbm_thread_create(&thread, 0, + ipc_test_win_startup_call, + &call) == 0; + } + for (size_t attempt = 0; thread_started && attempt < 200U; attempt++) { + if (ipc_test_win_lock_busy(directory, "cbm-startup-v2.lock")) { + startup_observed = true; + break; + } + cbm_usleep(1000); + } + cbm_private_file_lock_status_t lifetime_status = + startup_observed + ? cbm_private_file_lock_try_acquire( + directory, "cbm-lifetime.lock", CBM_PRIVATE_FILE_LOCK_EX, + &lifetime_owner) + : CBM_PRIVATE_FILE_LOCK_IO; + ipc_test_win_lock_release(&record_reader); + if (thread_started) { + join_status = cbm_thread_join(&thread); + } + if (endpoint) { + ipc_test_copy_path(after, + cbm_daemon_ipc_endpoint_address(endpoint)); + } + ipc_test_win_lock_release(&lifetime_owner); + + ipc_test_win_lock_release(&record_reader); + ipc_test_win_lock_release(&lifetime_owner); + cbm_private_lock_directory_close(directory); + cbm_daemon_ipc_startup_lock_release(&initial_startup); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(initial_status, 1); + ASSERT_TRUE(before[0] != '\0'); + ASSERT_EQ(directory_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(record_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(startup_observed); + ASSERT_EQ(lifetime_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(join_status, 0); + ASSERT_EQ(call.result, 0); + ASSERT_STR_EQ(after, before); + PASS(); +} + +TEST(daemon_ipc_windows_generation_rotates_and_escapes_occupied_old_pipe) { + static const char key[] = "a1b2c3d4e5f60718"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char first_address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char first_peer_address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char second_address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char second_peer_address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_endpoint_t *peer = NULL; + cbm_daemon_ipc_startup_lock_t *first_startup = NULL; + cbm_daemon_ipc_startup_lock_t *second_startup = NULL; + cbm_daemon_ipc_participant_guard_t *participant = NULL; + cbm_daemon_ipc_lifetime_reservation_t *lifetime = NULL; + cbm_daemon_ipc_listener_t *listener = NULL; + HANDLE old_pipe = INVALID_HANDLE_VALUE; + + bool parent_ok = ipc_test_parent_new(parent, "win-generation"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + peer = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int first_status = + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &first_startup) + : -1; + if (first_status == 1 && + !cbm_daemon_ipc_startup_lock_prepare_handoff(first_startup)) { + first_status = -1; + } + if (first_status == 1) { + ipc_test_copy_path(first_address, + cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(first_peer_address, + cbm_daemon_ipc_endpoint_address(peer)); + } + cbm_daemon_ipc_startup_lock_release(&first_startup); + first_startup = NULL; + + wchar_t *old_name = first_address[0] ? cbm_utf8_to_wide(first_address) : NULL; + if (old_name) { + old_pipe = CreateNamedPipeW( + old_name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | + PIPE_REJECT_REMOTE_CLIENTS, + 1, 4096, 4096, 0, NULL); + } + free(old_name); + int second_status = + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &second_startup) + : -1; + bool handoff_prepared = + second_status == 1 && + cbm_daemon_ipc_startup_lock_prepare_handoff(second_startup); + int participant_status = + handoff_prepared + ? cbm_daemon_ipc_participant_guard_try_join(endpoint, + &participant) + : -1; + int lifetime_status = + participant_status == 1 + ? cbm_daemon_ipc_lifetime_reservation_try_acquire( + endpoint, &lifetime) + : -1; + if (handoff_prepared) { + ipc_test_copy_path(second_address, + cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(second_peer_address, + cbm_daemon_ipc_endpoint_address(peer)); + listener = lifetime_status == 1 + ? cbm_daemon_ipc_listen_reserved(endpoint, &lifetime) + : NULL; + } + cbm_daemon_ipc_startup_lock_release(&second_startup); + second_startup = NULL; + + bool first_shared = first_address[0] && + strcmp(first_address, first_peer_address) == 0; + bool rotated = second_address[0] && + strcmp(first_address, second_address) != 0; + bool second_shared = strcmp(second_address, second_peer_address) == 0; + bool old_occupied = old_pipe != INVALID_HANDLE_VALUE; + bool new_listened = listener != NULL; + + cbm_daemon_ipc_listener_close(listener); + cbm_daemon_ipc_lifetime_reservation_release(lifetime); + bool participant_released = + cbm_daemon_ipc_participant_guard_release(&participant); + if (old_pipe != INVALID_HANDLE_VALUE) { + (void)CloseHandle(old_pipe); + } + cbm_daemon_ipc_startup_lock_release(&second_startup); + cbm_daemon_ipc_startup_lock_release(&first_startup); + cbm_daemon_ipc_endpoint_free(peer); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(first_status, 1); + ASSERT_TRUE(first_shared); + ASSERT_TRUE(old_occupied); + ASSERT_EQ(second_status, 1); + ASSERT_TRUE(handoff_prepared); + ASSERT_EQ(participant_status, 1); + ASSERT_EQ(lifetime_status, 1); + ASSERT_TRUE(rotated); + ASSERT_TRUE(second_shared); + ASSERT_TRUE(new_listened); + ASSERT_TRUE(participant_released); + PASS(); +} + +TEST(daemon_ipc_windows_corrupt_rendezvous_fails_closed_until_startup_repairs) { + static const char key[] = "b1c2d3e4f5061728"; + static const uint8_t partial[] = {'C', 'B', 'M', 'R', 'D', 'V', '1'}; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char original_address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char repaired_address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + char rebound_address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_endpoint_t *reader = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_private_lock_directory_t *directory = NULL; + cbm_private_file_lock_t *record_lock = NULL; + cbm_private_file_lock_status_t directory_status = + CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t record_status = + CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t write_status = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t read_status = CBM_PRIVATE_FILE_LOCK_IO; + uint8_t readback[CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE] = {0}; + size_t readback_length = 0; + + bool parent_ok = ipc_test_parent_new(parent, "win-corrupt-record"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int initial_status = + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) + : -1; + if (initial_status == 1 && + !cbm_daemon_ipc_startup_lock_prepare_handoff(startup)) { + initial_status = -1; + } + if (initial_status == 1) { + ipc_test_copy_path(original_address, + cbm_daemon_ipc_endpoint_address(endpoint)); + } + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + + if (endpoint) { + directory_status = cbm_daemon_ipc_private_lock_directory_new( + endpoint, &directory); + } + if (directory_status == CBM_PRIVATE_FILE_LOCK_OK) { + record_status = cbm_private_file_lock_try_acquire( + directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &record_lock); + } + if (record_status == CBM_PRIVATE_FILE_LOCK_OK) { + write_status = cbm_private_file_lock_payload_write( + record_lock, partial, sizeof(partial)); + } + if (record_lock) { + (void)cbm_private_file_lock_release(&record_lock); + } + cbm_private_lock_directory_close(directory); + directory = NULL; + + if (write_status == CBM_PRIVATE_FILE_LOCK_OK) { + reader = cbm_daemon_ipc_endpoint_new(key, parent); + } + cbm_daemon_ipc_connection_t *corrupt_connection = + reader ? cbm_daemon_ipc_connect(reader, 1) : NULL; + bool failed_closed = + reader && cbm_daemon_ipc_endpoint_address(reader) == NULL && + corrupt_connection == NULL; + cbm_daemon_ipc_connection_close(corrupt_connection); + + if (reader && cbm_daemon_ipc_private_lock_directory_new( + reader, &directory) == CBM_PRIVATE_FILE_LOCK_OK && + cbm_private_file_lock_try_acquire( + directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_SH, &record_lock) == + CBM_PRIVATE_FILE_LOCK_OK) { + read_status = cbm_private_file_lock_payload_read( + record_lock, readback, sizeof(readback), &readback_length); + } + if (record_lock) { + (void)cbm_private_file_lock_release(&record_lock); + } + cbm_private_lock_directory_close(directory); + directory = NULL; + bool reader_did_not_repair = + read_status == CBM_PRIVATE_FILE_LOCK_OK && + readback_length == sizeof(partial) && + memcmp(readback, partial, sizeof(partial)) == 0; + + int repair_status = + reader ? cbm_daemon_ipc_startup_lock_try_acquire(reader, &startup) + : -1; + if (repair_status == 1 && + !cbm_daemon_ipc_startup_lock_prepare_handoff(startup)) { + repair_status = -1; + } + if (repair_status == 1) { + ipc_test_copy_path(repaired_address, + cbm_daemon_ipc_endpoint_address(reader)); + } + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + + bool repaired = repaired_address[0] && + strcmp(original_address, repaired_address) != 0; + + uint8_t mismatched_nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE] = {0}; + char mismatched_address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; + size_t valid_record_length = 0; + cbm_private_file_lock_status_t mismatch_read = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t mismatch_write = CBM_PRIVATE_FILE_LOCK_IO; + bool mismatch_encoded = false; + if (reader && cbm_daemon_ipc_private_lock_directory_new( + reader, &directory) == CBM_PRIVATE_FILE_LOCK_OK && + cbm_private_file_lock_try_acquire( + directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &record_lock) == + CBM_PRIVATE_FILE_LOCK_OK) { + mismatch_read = cbm_private_file_lock_payload_read( + record_lock, readback, sizeof(readback), &valid_record_length); + bool decoded = mismatch_read == CBM_PRIVATE_FILE_LOCK_OK && + cbm_daemon_ipc_windows_rendezvous_record_decode( + readback, valid_record_length, mismatched_nonce, + mismatched_address); + if (decoded) { + mismatched_nonce[0] ^= 0x80U; + mismatch_encoded = + cbm_daemon_ipc_windows_rendezvous_record_encode( + mismatched_nonce, mismatched_address, readback); + } + if (mismatch_encoded) { + mismatch_write = cbm_private_file_lock_payload_write( + record_lock, readback, sizeof(readback)); + } + } + ipc_test_win_lock_release(&record_lock); + cbm_private_lock_directory_close(directory); + directory = NULL; + bool mismatch_failed_closed = + mismatch_write == CBM_PRIVATE_FILE_LOCK_OK && + cbm_daemon_ipc_endpoint_address(reader) == NULL; + int rebound_status = + reader ? cbm_daemon_ipc_startup_lock_try_acquire(reader, &startup) + : -1; + if (rebound_status == 1 && + !cbm_daemon_ipc_startup_lock_prepare_handoff(startup)) { + rebound_status = -1; + } + if (rebound_status == 1) { + ipc_test_copy_path(rebound_address, + cbm_daemon_ipc_endpoint_address(reader)); + } + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + bool rebound = rebound_address[0] && + strcmp(rebound_address, repaired_address) != 0; + + cbm_daemon_ipc_startup_lock_release(&startup); + if (record_lock) { + (void)cbm_private_file_lock_release(&record_lock); + } + cbm_private_lock_directory_close(directory); + cbm_daemon_ipc_endpoint_free(reader); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(initial_status, 1); + ASSERT_TRUE(original_address[0] != '\0'); + ASSERT_EQ(directory_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(record_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(write_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(failed_closed); + ASSERT_TRUE(reader_did_not_repair); + ASSERT_EQ(repair_status, 1); + ASSERT_TRUE(repaired); + ASSERT_EQ(mismatch_read, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(mismatch_encoded); + ASSERT_EQ(mismatch_write, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(mismatch_failed_closed); + ASSERT_EQ(rebound_status, 1); + ASSERT_TRUE(rebound); + PASS(); +} + +TEST(daemon_ipc_windows_startup_and_lifetime_locks_are_cross_process) { + static const char key[] = "c1d2e3f405162738"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_daemon_ipc_startup_lock_t *lifetime_startup = NULL; + cbm_daemon_ipc_participant_guard_t *participant = NULL; + cbm_daemon_ipc_lifetime_reservation_t *lifetime = NULL; + + bool parent_ok = ipc_test_parent_new(parent, "win-process-locks"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int startup_status = + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) + : -1; + int child_startup_while_held = + startup_status == 1 + ? ipc_test_win_lock_child("startup", key, parent) + : -1; + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + int child_startup_after_release = + endpoint ? ipc_test_win_lock_child("startup", key, parent) : -1; + + int lifetime_startup_status = + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &lifetime_startup) + : -1; + bool lifetime_prepared = lifetime_startup_status == 1 && + cbm_daemon_ipc_startup_lock_prepare_handoff( + lifetime_startup); + int participant_status = + lifetime_prepared + ? cbm_daemon_ipc_participant_guard_try_join(endpoint, + &participant) + : -1; + int lifetime_status = + participant_status == 1 + ? cbm_daemon_ipc_lifetime_reservation_try_acquire( + endpoint, &lifetime) + : -1; + cbm_daemon_ipc_startup_lock_release(&lifetime_startup); + lifetime_startup = NULL; + int child_lifetime_while_held = + lifetime_status == 1 + ? ipc_test_win_lock_child("lifetime", key, parent) + : -1; + cbm_daemon_ipc_lifetime_reservation_release(lifetime); + lifetime = NULL; + int child_lifetime_after_release = + endpoint ? ipc_test_win_lock_child("lifetime", key, parent) : -1; + bool participant_released = + cbm_daemon_ipc_participant_guard_release(&participant); + + cbm_daemon_ipc_lifetime_reservation_release(lifetime); + cbm_daemon_ipc_startup_lock_release(&startup); + cbm_daemon_ipc_startup_lock_release(&lifetime_startup); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(startup_status, 1); + ASSERT_EQ(child_startup_while_held, 20); + ASSERT_EQ(child_startup_after_release, 0); + ASSERT_EQ(lifetime_startup_status, 1); + ASSERT_TRUE(lifetime_prepared); + ASSERT_EQ(participant_status, 1); + ASSERT_EQ(lifetime_status, 1); + ASSERT_EQ(child_lifetime_while_held, 20); + ASSERT_EQ(child_lifetime_after_release, 0); + ASSERT_TRUE(participant_released); + ASSERT_NULL(participant); + PASS(); +} +#endif + +TEST(daemon_ipc_endpoint_is_namespaced_by_instance_key) { + static const char key_a[] = "0123456789abcdef"; + static const char key_b[] = "fedcba9876543210"; + char parent[TEST_PATH_CAP] = {0}; + char canonical_parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char same_runtime_dir[TEST_PATH_CAP] = {0}; + char other_runtime_dir[TEST_PATH_CAP] = {0}; + bool same_key_same_address = false; + bool other_key_other_address = false; + bool address_contains_key = false; + bool runtime_is_private_child = false; + bool invalid_key_rejected = false; + + cbm_daemon_ipc_endpoint_t *a = NULL; + cbm_daemon_ipc_endpoint_t *same = NULL; + cbm_daemon_ipc_endpoint_t *other = NULL; + cbm_daemon_ipc_endpoint_t *invalid = NULL; + cbm_daemon_ipc_startup_lock_t *a_startup = NULL; + cbm_daemon_ipc_startup_lock_t *other_startup = NULL; + + bool parent_ok = + ipc_test_parent_new(parent, "namespace") && ipc_test_full_path(canonical_parent, parent); + if (parent_ok) { + a = cbm_daemon_ipc_endpoint_new(key_a, parent); + same = cbm_daemon_ipc_endpoint_new(key_a, parent); + other = cbm_daemon_ipc_endpoint_new(key_b, parent); + invalid = cbm_daemon_ipc_endpoint_new("../../not-a-key", parent); + invalid_key_rejected = invalid == NULL; + } + +#ifdef _WIN32 + int a_startup_status = + a ? cbm_daemon_ipc_startup_lock_try_acquire(a, &a_startup) : -1; + int other_startup_status = + other ? cbm_daemon_ipc_startup_lock_try_acquire(other, &other_startup) + : -1; + cbm_daemon_ipc_startup_lock_release(&a_startup); + cbm_daemon_ipc_startup_lock_release(&other_startup); + a_startup = NULL; + other_startup = NULL; + if (a_startup_status != 1 || other_startup_status != 1) { + cbm_daemon_ipc_endpoint_free(other); + cbm_daemon_ipc_endpoint_free(same); + other = NULL; + same = NULL; + } +#endif + + if (a && same && other) { + const char *a_address = cbm_daemon_ipc_endpoint_address(a); + const char *same_address = cbm_daemon_ipc_endpoint_address(same); + const char *other_address = cbm_daemon_ipc_endpoint_address(other); + const char *runtime = cbm_daemon_ipc_endpoint_runtime_dir(a); + same_key_same_address = a_address && same_address && strcmp(a_address, same_address) == 0; + other_key_other_address = + a_address && other_address && strcmp(a_address, other_address) != 0; +#ifdef _WIN32 + address_contains_key = + a_address && + strncmp(a_address, "\\\\.\\pipe\\cbm-daemon-", + strlen("\\\\.\\pipe\\cbm-daemon-")) == 0 && + strstr(a_address, key_a) == NULL && other_address && + strstr(other_address, key_b) == NULL; +#else + address_contains_key = a_address && strstr(a_address, key_a) != NULL && other_address && + strstr(other_address, key_b) != NULL; +#endif + runtime_is_private_child = runtime && strcmp(runtime, canonical_parent) != 0 && + ipc_test_path_has_parent(runtime, canonical_parent); + ipc_test_copy_path(runtime_dir, runtime); + ipc_test_copy_path(same_runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(same)); + ipc_test_copy_path(other_runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(other)); + } + + cbm_daemon_ipc_endpoint_free(invalid); + cbm_daemon_ipc_startup_lock_release(&other_startup); + cbm_daemon_ipc_startup_lock_release(&a_startup); + cbm_daemon_ipc_endpoint_free(other); + cbm_daemon_ipc_endpoint_free(same); + cbm_daemon_ipc_endpoint_free(a); + if (same_runtime_dir[0] != '\0' && strcmp(same_runtime_dir, runtime_dir) != 0) { + ipc_test_remove_flat_dir(same_runtime_dir); + } + if (other_runtime_dir[0] != '\0' && strcmp(other_runtime_dir, runtime_dir) != 0 && + strcmp(other_runtime_dir, same_runtime_dir) != 0) { + ipc_test_remove_flat_dir(other_runtime_dir); + } + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(same_key_same_address); + ASSERT_TRUE(other_key_other_address); + ASSERT_TRUE(address_contains_key); + ASSERT_TRUE(runtime_is_private_child); + ASSERT_TRUE(invalid_key_rejected); /* only exact 16-hex keys enter endpoint names */ + PASS(); +} + +TEST(daemon_ipc_relative_runtime_parent_is_canonical_and_stable) { + static const char key[] = "0a1b2c3d4e5f6071"; + char parent[TEST_PATH_CAP] = {0}; + char canonical_parent[TEST_PATH_CAP] = {0}; + char saved_cwd[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char cleanup_runtime_dir[TEST_PATH_CAP] = {0}; + char address[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_listener_t *listener = NULL; + cbm_daemon_ipc_connection_t *client = NULL; + bool parent_ok = false; + bool cwd_saved = false; + bool entered_parent = false; + bool cwd_restored = false; + bool runtime_canonical = false; + bool address_absolute = false; + bool usable_after_chdir = false; + + parent_ok = ipc_test_parent_new(parent, "relative-parent") && + ipc_test_full_path(canonical_parent, parent); + cwd_saved = parent_ok && ipc_test_getcwd(saved_cwd, sizeof(saved_cwd)) != NULL; + entered_parent = cwd_saved && ipc_test_chdir(parent) == 0; + if (entered_parent) { + endpoint = cbm_daemon_ipc_endpoint_new(key, "."); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + (void)ipc_test_full_path(cleanup_runtime_dir, runtime_dir); + runtime_canonical = ipc_test_path_is_absolute(runtime_dir) && + ipc_test_path_has_parent(runtime_dir, canonical_parent); + } + if (entered_parent) { + cwd_restored = ipc_test_chdir(saved_cwd) == 0; + } + if (endpoint && cwd_restored) { + listener = cbm_daemon_ipc_listen(endpoint); + ipc_test_copy_path(address, cbm_daemon_ipc_endpoint_address(endpoint)); +#ifdef _WIN32 + address_absolute = strncmp(address, "\\\\.\\pipe\\", 9) == 0; +#else + address_absolute = ipc_test_path_is_absolute(address); +#endif + if (listener) { + client = cbm_daemon_ipc_connect(endpoint, 500); + } + usable_after_chdir = listener != NULL && client != NULL; + } + + cbm_daemon_ipc_connection_close(client); + cbm_daemon_ipc_listener_close(listener); + cbm_daemon_ipc_endpoint_free(endpoint); + if (entered_parent && !cwd_restored) { + cwd_restored = ipc_test_chdir(saved_cwd) == 0; + } + ipc_test_remove_tree(cleanup_runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(cwd_saved); + ASSERT_TRUE(entered_parent); + ASSERT_TRUE(cwd_restored); + ASSERT_TRUE(runtime_canonical); + ASSERT_TRUE(address_absolute); + ASSERT_TRUE(usable_after_chdir); + PASS(); +} + +TEST(daemon_ipc_rejects_uppercase_instance_key) { + static const char uppercase_key[] = "ABCDEF0123456789"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + + bool parent_ok = ipc_test_parent_new(parent, "uppercase-key"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(uppercase_key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + bool rejected = endpoint == NULL; + + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(rejected); + PASS(); +} + +TEST(daemon_ipc_no_spawn_probe_distinguishes_absent_active_and_busy) { + static const char key[] = "2468ace02468ace0"; + enum { PROBE_CLIENT_CAP = 64 }; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_listener_t *listener = NULL; + cbm_daemon_ipc_connection_t *clients[PROBE_CLIENT_CAP] = {0}; + size_t client_count = 0; + int absent_before = -1; + int active_or_busy = -1; + int absent_after = -1; + + if (ipc_test_parent_new(parent, "no-spawn-probe")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + absent_before = cbm_daemon_ipc_endpoint_probe(endpoint, 1); + listener = cbm_daemon_ipc_listen(endpoint); + } + /* Do not accept: on Windows the sole pipe instance becomes BUSY; on + * POSIX this fills the listen backlog. Probe must still report ACTIVE. */ + while (listener && client_count < PROBE_CLIENT_CAP) { + cbm_daemon_ipc_connection_t *client = cbm_daemon_ipc_connect(endpoint, 1); + if (!client) { + break; + } + clients[client_count++] = client; + } + if (listener) { + active_or_busy = cbm_daemon_ipc_endpoint_probe(endpoint, 1); + } + for (size_t i = 0; i < client_count; i++) { + cbm_daemon_ipc_connection_close(clients[i]); + } + cbm_daemon_ipc_listener_close(listener); + listener = NULL; + if (endpoint) { + absent_after = cbm_daemon_ipc_endpoint_probe(endpoint, 1); + } + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_EQ(absent_before, 0); + ASSERT(client_count > 0); + ASSERT_EQ(active_or_busy, 1); + ASSERT_EQ(absent_after, 0); + PASS(); +} + +TEST(daemon_ipc_lifetime_reservation_survives_saturated_second_listen) { + static const char key[] = "13579bdf13579bdf"; + enum { PROBE_CLIENT_CAP = 64 }; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_listener_t *first = NULL; + cbm_daemon_ipc_listener_t *second = NULL; + cbm_daemon_ipc_listener_t *after_close = NULL; + cbm_daemon_ipc_connection_t *clients[PROBE_CLIENT_CAP] = {0}; + size_t client_count = 0; + bool first_started = false; + bool after_close_started = false; + int free_before = -1; + int held_while_listening = -1; + int held_after_second_attempt = -1; + int endpoint_after_second_attempt = -1; + int free_after_close = -1; + + if (ipc_test_parent_new(parent, "lifetime-reservation")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + free_before = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + first = cbm_daemon_ipc_listen(endpoint); + first_started = first != NULL; + } + if (first) { + held_while_listening = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + } + /* Keep the first listener from accepting and fill as much transport + * capacity as the platform exposes. On BSD-derived kernels a subsequent + * connect can report ECONNREFUSED even though this listener is live. */ + while (first && client_count < PROBE_CLIENT_CAP) { + cbm_daemon_ipc_connection_t *client = cbm_daemon_ipc_connect(endpoint, 1); + if (!client) { + break; + } + clients[client_count++] = client; + } + if (first) { + second = cbm_daemon_ipc_listen(endpoint); + held_after_second_attempt = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + endpoint_after_second_attempt = cbm_daemon_ipc_endpoint_probe(endpoint, 1); + } + for (size_t i = 0; i < client_count; i++) { + cbm_daemon_ipc_connection_close(clients[i]); + } + cbm_daemon_ipc_listener_close(second); + cbm_daemon_ipc_listener_close(first); + first = NULL; + if (endpoint) { + free_after_close = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + after_close = cbm_daemon_ipc_listen(endpoint); + after_close_started = after_close != NULL; + } + cbm_daemon_ipc_listener_close(after_close); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_EQ(free_before, 0); + ASSERT_TRUE(first_started); + ASSERT_TRUE(client_count > 0); + ASSERT_EQ(held_while_listening, 1); + ASSERT_TRUE(second == NULL); + ASSERT_EQ(held_after_second_attempt, 1); + ASSERT_EQ(endpoint_after_second_attempt, 1); + ASSERT_EQ(free_after_close, 0); + ASSERT_TRUE(after_close_started); + PASS(); +} + +TEST(daemon_ipc_lifetime_reservation_transfers_without_unlock_window) { + static const char key[] = "1029384756abcdef"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_daemon_ipc_participant_guard_t *participant = NULL; + cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL; + cbm_daemon_ipc_listener_t *listener = NULL; + cbm_daemon_ipc_listener_t *contender = NULL; + cbm_daemon_ipc_connection_t *client = NULL; + int acquired = -1; + int held_before_transfer = -1; + int held_after_transfer = -1; + int free_after_close = -1; + bool transfer_consumed = false; + bool connected = false; + + if (ipc_test_parent_new(parent, "lifetime-transfer")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + int startup_result = cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &startup); + bool prepared = startup_result == 1 && + cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + int participant_result = + prepared ? cbm_daemon_ipc_participant_guard_try_join( + endpoint, &participant) + : -1; + acquired = participant_result == 1 + ? cbm_daemon_ipc_lifetime_reservation_try_acquire( + endpoint, &reservation) + : -1; + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + } + if (reservation) { + held_before_transfer = + cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + contender = cbm_daemon_ipc_listen(endpoint); + listener = cbm_daemon_ipc_listen_reserved(endpoint, &reservation); + transfer_consumed = listener != NULL && reservation == NULL; + } + if (listener) { + held_after_transfer = + cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + client = cbm_daemon_ipc_connect(endpoint, 500); + connected = client != NULL; + } + + cbm_daemon_ipc_connection_close(client); + cbm_daemon_ipc_listener_close(contender); + cbm_daemon_ipc_listener_close(listener); + listener = NULL; + cbm_daemon_ipc_lifetime_reservation_release(reservation); + reservation = NULL; + bool participant_released = + cbm_daemon_ipc_participant_guard_release(&participant); + if (endpoint) { + free_after_close = + cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + } + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_EQ(acquired, 1); + ASSERT_EQ(held_before_transfer, 1); + ASSERT_TRUE(contender == NULL); + ASSERT_TRUE(transfer_consumed); + ASSERT_TRUE(connected); + ASSERT_EQ(held_after_transfer, 1); + ASSERT_EQ(free_after_close, 0); + ASSERT_TRUE(participant_released); + ASSERT_NULL(participant); + PASS(); +} + +typedef struct { + cbm_daemon_ipc_listener_t *listener; + int result; + uint64_t peer_pid; +} ipc_roundtrip_server_t; + +static uint64_t ipc_test_process_id(void) { +#ifdef _WIN32 + return (uint64_t)GetCurrentProcessId(); +#else + return (uint64_t)getpid(); +#endif +} + +static void *ipc_roundtrip_server(void *opaque) { + static const uint8_t expected_request[] = {0x00, 'r', 'e', 'q', 0xff}; + static const uint8_t response[] = {'o', 'k', 0x00, 0x7f}; + ipc_roundtrip_server_t *server = (ipc_roundtrip_server_t *)opaque; + cbm_daemon_ipc_connection_t *connection = NULL; + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + + server->result = 1; + if (cbm_daemon_ipc_accept(server->listener, 2000, &connection) != 1 || !connection) { + goto done; + } + server->peer_pid = cbm_daemon_ipc_connection_peer_pid(connection); + if (server->peer_pid == 0) { + goto done; + } + if (cbm_daemon_ipc_receive_frame(connection, 2000, &frame, &payload) != 1) { + goto done; + } + if (frame.type != CBM_DAEMON_FRAME_REQUEST || frame.flags != 0x1234 || + frame.length != sizeof(expected_request) || !payload || + memcmp(payload, expected_request, sizeof(expected_request)) != 0) { + goto done; + } + if (!cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_RESPONSE, 0x00a5, response, + (uint32_t)sizeof(response))) { + goto done; + } + server->result = 0; + +done: + free(payload); /* receive_frame returns a malloc-owned payload */ + cbm_daemon_ipc_connection_close(connection); + return NULL; +} + +TEST(daemon_ipc_local_frame_roundtrip) { + static const char key[] = "1111222233334444"; + static const uint8_t request[] = {0x00, 'r', 'e', 'q', 0xff}; + static const uint8_t expected_response[] = {'o', 'k', 0x00, 0x7f}; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_listener_t *listener = NULL; + cbm_daemon_ipc_connection_t *client = NULL; + cbm_thread_t thread; + bool thread_started = false; + ipc_roundtrip_server_t server = {0}; + cbm_daemon_frame_t response_frame = {0}; + uint8_t *response_payload = NULL; + uint64_t client_peer_pid = 0; + int result = 1; + + if (!ipc_test_parent_new(parent, "roundtrip")) { + goto cleanup; + } + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + if (!endpoint) { + goto cleanup; + } + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + listener = cbm_daemon_ipc_listen(endpoint); + if (!listener) { + goto cleanup; + } + server.listener = listener; + server.result = 1; + if (cbm_thread_create(&thread, 0, ipc_roundtrip_server, &server) != 0) { + goto cleanup; + } + thread_started = true; + client = cbm_daemon_ipc_connect(endpoint, 2000); + client_peer_pid = cbm_daemon_ipc_connection_peer_pid(client); + if (!client || client_peer_pid == 0 || + !cbm_daemon_ipc_send_frame(client, CBM_DAEMON_FRAME_REQUEST, 0x1234, request, + (uint32_t)sizeof(request)) || + cbm_daemon_ipc_receive_frame(client, 2000, &response_frame, &response_payload) != 1) { + goto cleanup; + } + if (response_frame.type != CBM_DAEMON_FRAME_RESPONSE || response_frame.flags != 0x00a5 || + response_frame.length != sizeof(expected_response) || !response_payload || + memcmp(response_payload, expected_response, sizeof(expected_response)) != 0) { + goto cleanup; + } + result = 0; + +cleanup: + free(response_payload); + cbm_daemon_ipc_connection_close(client); + if (thread_started) { + int join_result = cbm_thread_join(&thread); + if (join_result != 0 || server.result != 0) { + result = 1; + } + } + cbm_daemon_ipc_listener_close(listener); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_EQ(result, 0); + ASSERT_EQ(client_peer_pid, ipc_test_process_id()); + ASSERT_EQ(server.peer_pid, ipc_test_process_id()); + PASS(); +} + +TEST(daemon_ipc_bounded_receive_rejects_oversize_before_payload) { + static const char key[] = "2021222324252627"; + static const uint8_t oversized_payload[] = {'o', 'v', 'e', 'r', '!'}; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_listener_t *listener = NULL; + cbm_daemon_ipc_connection_t *client = NULL; + cbm_daemon_ipc_connection_t *server = NULL; + cbm_daemon_frame_t frame = {0}; + uint8_t *received_payload = NULL; + bool parent_ok = ipc_test_parent_new(parent, "bounded-frame"); + int accepted = -1; + bool sent = false; + int bounded_result = 1; + int reuse_result = 1; + bool bounded_payload_absent = false; + bool bounded_frame_empty = false; + + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + listener = cbm_daemon_ipc_listen(endpoint); + } + if (listener) { + client = cbm_daemon_ipc_connect(endpoint, 500); + } + if (client) { + accepted = cbm_daemon_ipc_accept(listener, 500, &server); + } + if (accepted == 1 && server) { + sent = cbm_daemon_ipc_send_frame( + client, CBM_DAEMON_FRAME_REQUEST, 0x2021, oversized_payload, + (uint32_t)sizeof(oversized_payload)); + } + if (sent) { + bounded_result = cbm_daemon_ipc_receive_frame_bounded( + server, 500, (uint32_t)sizeof(oversized_payload) - 1, &frame, + &received_payload); + bounded_payload_absent = received_payload == NULL; + bounded_frame_empty = frame.length == 0; + free(received_payload); + received_payload = NULL; + reuse_result = cbm_daemon_ipc_receive_frame( + server, 1, &frame, &received_payload); + } + + free(received_payload); + cbm_daemon_ipc_connection_close(server); + cbm_daemon_ipc_connection_close(client); + cbm_daemon_ipc_listener_close(listener); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(accepted, 1); + ASSERT_TRUE(sent); + ASSERT_EQ(bounded_result, -1); + ASSERT_TRUE(bounded_payload_absent); + ASSERT_TRUE(bounded_frame_empty); + ASSERT_EQ(reuse_result, -1); + PASS(); +} + +typedef struct { + cbm_daemon_ipc_listener_t *listener; + cbm_daemon_ipc_connection_t *connection; + atomic_bool waiting; + int receive_result; +} ipc_forever_wait_server_t; + +static void *ipc_forever_wait_server(void *opaque) { + ipc_forever_wait_server_t *server = (ipc_forever_wait_server_t *)opaque; + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + server->receive_result = -2; + if (cbm_daemon_ipc_accept(server->listener, 2000, &server->connection) == 1 && + server->connection) { + atomic_store_explicit(&server->waiting, true, memory_order_release); + server->receive_result = cbm_daemon_ipc_receive_frame( + server->connection, CBM_DAEMON_IPC_WAIT_FOREVER, &frame, &payload); + } + free(payload); + cbm_daemon_ipc_connection_close(server->connection); + server->connection = NULL; + return NULL; +} + +TEST(daemon_ipc_wait_forever_is_interruptible) { + static const char key[] = "1212121212121212"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_listener_t *listener = NULL; + cbm_daemon_ipc_connection_t *client = NULL; + cbm_thread_t thread; + bool thread_started = false; + bool reached_wait = false; + int join_result = -1; + ipc_forever_wait_server_t server = {0}; + atomic_init(&server.waiting, false); + + if (ipc_test_parent_new(parent, "wait-forever")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + listener = cbm_daemon_ipc_listen(endpoint); + } + if (listener) { + server.listener = listener; + thread_started = cbm_thread_create(&thread, 0, ipc_forever_wait_server, &server) == 0; + } + if (thread_started) { + client = cbm_daemon_ipc_connect(endpoint, 2000); + } + uint64_t deadline = cbm_now_ms() + 2000; + while (client && cbm_now_ms() < deadline && + !atomic_load_explicit(&server.waiting, memory_order_acquire)) { + cbm_usleep(1000); + } + reached_wait = atomic_load_explicit(&server.waiting, memory_order_acquire); + if (reached_wait) { + cbm_daemon_ipc_connection_interrupt(server.connection); + } + if (thread_started) { + join_result = cbm_thread_join(&thread); + } + cbm_daemon_ipc_connection_close(client); + cbm_daemon_ipc_listener_close(listener); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(thread_started); + ASSERT_TRUE(client != NULL); + ASSERT_TRUE(reached_wait); + ASSERT_EQ(join_result, 0); + ASSERT_EQ(server.receive_result, -1); + PASS(); +} + +typedef struct { + const cbm_daemon_ipc_endpoint_t *endpoint; + cbm_daemon_ipc_listener_t *listener; + atomic_bool delay_started; +} ipc_delayed_listener_t; + +static void *ipc_delayed_listener_start(void *opaque) { + ipc_delayed_listener_t *delayed = (ipc_delayed_listener_t *)opaque; + struct timespec delay = {.tv_sec = 0, .tv_nsec = 150000000}; + atomic_store_explicit(&delayed->delay_started, true, memory_order_release); + (void)cbm_nanosleep(&delay, NULL); + delayed->listener = cbm_daemon_ipc_listen(delayed->endpoint); + return NULL; +} + +TEST(daemon_ipc_connect_waits_for_delayed_listener) { + static const char key[] = "2222333344445555"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_connection_t *client = NULL; + cbm_thread_t thread; + bool thread_started = false; + bool delay_observed = false; + int join_result = -1; + ipc_delayed_listener_t delayed = {0}; + atomic_init(&delayed.delay_started, false); + + bool parent_ok = ipc_test_parent_new(parent, "delayed-listener"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + delayed.endpoint = endpoint; + thread_started = cbm_thread_create(&thread, 0, ipc_delayed_listener_start, &delayed) == 0; + } + if (thread_started) { + struct timespec poll_delay = {.tv_sec = 0, .tv_nsec = 1000000}; + for (size_t i = 0; i < 2000; i++) { + if (atomic_load_explicit(&delayed.delay_started, memory_order_acquire)) { + delay_observed = true; + break; + } + (void)cbm_nanosleep(&poll_delay, NULL); + } + } + if (delay_observed) { + client = cbm_daemon_ipc_connect(endpoint, 1500); + } + if (thread_started) { + join_result = cbm_thread_join(&thread); + } + bool connected_after_wait = client != NULL && delayed.listener != NULL; + + cbm_daemon_ipc_connection_close(client); + cbm_daemon_ipc_listener_close(delayed.listener); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(delay_observed); + ASSERT_EQ(join_result, 0); + ASSERT_TRUE(connected_after_wait); + PASS(); +} + +typedef struct { + const cbm_daemon_ipc_endpoint_t *endpoint; + int result; +} ipc_lock_contender_t; + +static void *ipc_lock_contender(void *opaque) { + ipc_lock_contender_t *contender = (ipc_lock_contender_t *)opaque; + cbm_daemon_ipc_startup_lock_t *lock = NULL; + contender->result = cbm_daemon_ipc_startup_lock_try_acquire(contender->endpoint, &lock); + cbm_daemon_ipc_startup_lock_release(&lock); + return NULL; +} + +TEST(daemon_ipc_startup_lock_has_one_winner) { + static const char key[] = "5555666677778888"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *first = NULL; + cbm_daemon_ipc_startup_lock_t *after_release = NULL; + cbm_thread_t thread; + bool thread_started = false; + ipc_lock_contender_t contender = {0}; + int first_result = -1; + int reacquire_result = -1; + int join_result = -1; + + if (ipc_test_parent_new(parent, "startup-lock")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + first_result = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &first); + } + if (first_result == 1 && first) { + contender.endpoint = endpoint; + contender.result = -1; + if (cbm_thread_create(&thread, 0, ipc_lock_contender, &contender) == 0) { + thread_started = true; + join_result = cbm_thread_join(&thread); + thread_started = false; + } + } + cbm_daemon_ipc_startup_lock_release(&first); + first = NULL; + if (endpoint) { + reacquire_result = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &after_release); + } + cbm_daemon_ipc_startup_lock_release(&after_release); + if (thread_started) { + (void)cbm_thread_join(&thread); + } + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_EQ(first_result, 1); + ASSERT_EQ(join_result, 0); + ASSERT_EQ(contender.result, 0); /* held, not an OS/error failure */ + ASSERT_EQ(reacquire_result, 1); + PASS(); +} + +TEST(daemon_ipc_activation_probe_ignores_matching_startup_claim_only) { + static const char key[] = "5c5c6d6d7e7e8f8f"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_endpoint_t *wrong_endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_daemon_ipc_listener_t *listener = NULL; + + bool parent_ok = ipc_test_parent_new(parent, "activation-probe"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + wrong_endpoint = cbm_daemon_ipc_endpoint_new( + "5c5c6d6d7e7e8f90", parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int without_lock = endpoint + ? cbm_daemon_ipc_generation_probe_under_startup_lock( + endpoint, NULL) + : 0; + int startup_result = + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, + &startup) + : -1; + int generic_self = + startup ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + int matching = + startup ? cbm_daemon_ipc_generation_probe_under_startup_lock( + endpoint, startup) + : -2; + int mismatched = + startup && wrong_endpoint + ? cbm_daemon_ipc_generation_probe_under_startup_lock( + wrong_endpoint, startup) + : -2; + bool prepared = startup && + cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + int after_prepare = + prepared ? cbm_daemon_ipc_generation_probe_under_startup_lock( + endpoint, startup) + : -2; + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + int after_release = + endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + listener = endpoint ? cbm_daemon_ipc_listen(endpoint) : NULL; + bool listener_started = listener != NULL; + int active_startup = + listener ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, + &startup) + : -1; + int active_generation = + active_startup == 1 + ? cbm_daemon_ipc_generation_probe_under_startup_lock(endpoint, + startup) + : -1; + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + cbm_daemon_ipc_listener_close(listener); + + cbm_daemon_ipc_endpoint_free(wrong_endpoint); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(without_lock, -1); + ASSERT_EQ(startup_result, 1); + ASSERT_EQ(generic_self, 1); + ASSERT_EQ(matching, 0); + ASSERT_EQ(mismatched, -1); + ASSERT_TRUE(prepared); + ASSERT_EQ(after_prepare, -1); + ASSERT_EQ(after_release, 0); + ASSERT_TRUE(listener_started); + ASSERT_EQ(active_startup, 1); + ASSERT_EQ(active_generation, 1); + PASS(); +} + +TEST(daemon_ipc_local_transition_coexists_with_active_daemon_lifetime) { + static const char key[] = "5d5d6e6e7f7f8080"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_daemon_ipc_startup_lock_t *startup_during_local = NULL; + cbm_daemon_ipc_startup_lock_t *startup_after = NULL; + cbm_daemon_ipc_lifetime_reservation_t *lifetime = NULL; + cbm_daemon_ipc_participant_guard_t *daemon_participant = NULL; + cbm_daemon_ipc_local_transition_t *transition = NULL; + + bool parent_ok = ipc_test_parent_new(parent, "local-transition"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int startup_result = + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) + : -1; + bool handoff = startup_result == 1 && + cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + int participant_result = + handoff ? cbm_daemon_ipc_participant_guard_try_join( + endpoint, &daemon_participant) + : -1; + int lifetime_result = + participant_result == 1 + ? cbm_daemon_ipc_lifetime_reservation_try_acquire( + endpoint, &lifetime) + : -1; + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + + int transition_result = + lifetime_result == 1 + ? cbm_daemon_ipc_local_transition_try_acquire(endpoint, + &transition) + : -1; + int unsealed_lifetime = + transition_result == 1 + ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, + transition) + : 0; + int seal_result = + transition_result == 1 + ? cbm_daemon_ipc_local_transition_seal_legacy(transition) + : -1; + int sealed_lifetime = + seal_result == 1 + ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, + transition) + : -1; + bool work_begun = + sealed_lifetime == 1 && + cbm_daemon_ipc_local_transition_begin_work(transition); + int startup_during_result = + work_begun + ? cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &startup_during_local) + : -1; + cbm_daemon_ipc_startup_lock_release(&startup_during_local); + startup_during_local = NULL; + bool transition_released = + cbm_daemon_ipc_local_transition_release(&transition); + int lifetime_after_transition = + endpoint ? cbm_daemon_ipc_lifetime_reservation_probe(endpoint) : -1; + + cbm_daemon_ipc_lifetime_reservation_release(lifetime); + lifetime = NULL; + bool participant_released = + cbm_daemon_ipc_participant_guard_release(&daemon_participant); + int startup_after_result = + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, + &startup_after) + : -1; + cbm_daemon_ipc_startup_lock_release(&startup_after); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(startup_result, 1); + ASSERT_TRUE(handoff); + ASSERT_EQ(participant_result, 1); + ASSERT_EQ(lifetime_result, 1); + ASSERT_EQ(transition_result, 1); + ASSERT_EQ(unsealed_lifetime, -1); + ASSERT_EQ(seal_result, 1); + ASSERT_EQ(sealed_lifetime, 1); + ASSERT_TRUE(work_begun); + ASSERT_EQ(startup_during_result, 1); + ASSERT_TRUE(transition_released); + ASSERT_NULL(transition); + ASSERT_EQ(lifetime_after_transition, 1); + ASSERT_TRUE(participant_released); + ASSERT_NULL(daemon_participant); + ASSERT_EQ(startup_after_result, 1); + PASS(); +} + +TEST(daemon_ipc_local_participants_overlap_and_allow_modern_startup) { + static const char key[] = "5e5e6f6f70708181"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_local_transition_t *first = NULL; + cbm_daemon_ipc_local_transition_t *second = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + + bool parent_ok = ipc_test_parent_new(parent, "local-overlap"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int first_acquired = + endpoint ? cbm_daemon_ipc_local_transition_try_acquire( + endpoint, &first) + : -1; + int first_sealed = + first_acquired == 1 + ? cbm_daemon_ipc_local_transition_seal_legacy(first) + : -1; + int first_presence = + first_sealed == 1 + ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, first) + : -1; + bool first_begun = + first_presence == 0 && + cbm_daemon_ipc_local_transition_begin_work(first); + + int second_acquired = + first_begun ? cbm_daemon_ipc_local_transition_try_acquire( + endpoint, &second) + : -1; + int second_sealed = + second_acquired == 1 + ? cbm_daemon_ipc_local_transition_seal_legacy(second) + : -1; + int second_presence = + second_sealed == 1 + ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, + second) + : -1; + bool second_begun = + second_presence == 0 && + cbm_daemon_ipc_local_transition_begin_work(second); + int startup_during = + second_begun ? cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &startup) + : -1; + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + int legacy_while_both = + second_begun ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + bool first_released = cbm_daemon_ipc_local_transition_release(&first); + int legacy_while_second = + first_released ? cbm_daemon_ipc_legacy_generation_probe(endpoint) + : -1; + bool second_released = + cbm_daemon_ipc_local_transition_release(&second); + int legacy_after = + second_released ? cbm_daemon_ipc_legacy_generation_probe(endpoint) + : -1; + + (void)cbm_daemon_ipc_local_transition_release(&second); + (void)cbm_daemon_ipc_local_transition_release(&first); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(first_acquired, 1); + ASSERT_EQ(first_sealed, 1); + ASSERT_EQ(first_presence, 0); + ASSERT_TRUE(first_begun); + ASSERT_EQ(second_acquired, 1); + ASSERT_EQ(second_sealed, 1); + ASSERT_EQ(second_presence, 0); + ASSERT_TRUE(second_begun); + ASSERT_EQ(startup_during, 1); + ASSERT_EQ(legacy_while_both, 1); + ASSERT_TRUE(first_released); + ASSERT_EQ(legacy_while_second, 1); + ASSERT_TRUE(second_released); + ASSERT_EQ(legacy_after, 0); + PASS(); +} + +TEST(daemon_ipc_windows_local_transition_release_retries_retained_mutex) { +#ifndef _WIN32 + PASS(); +#else + static const char key[] = "5f5f606071718282"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_local_transition_t *transition = NULL; + cbm_daemon_ipc_startup_lock_t *after = NULL; + + bool parent_ok = ipc_test_parent_new(parent, "local-release-retry"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int acquired = endpoint + ? cbm_daemon_ipc_local_transition_try_acquire( + endpoint, &transition) + : -1; + int sealed = acquired == 1 + ? cbm_daemon_ipc_local_transition_seal_legacy( + transition) + : -1; + int lifetime = sealed == 1 + ? cbm_daemon_ipc_local_transition_lifetime_probe( + endpoint, transition) + : -1; + bool begun = lifetime == 0 && + cbm_daemon_ipc_local_transition_begin_work(transition); + if (begun) { + cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test(1); + } + bool first_release = + cbm_daemon_ipc_local_transition_release(&transition); + bool retained_after_failure = transition != NULL; + bool retry_release = + cbm_daemon_ipc_local_transition_release(&transition); + cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test(0); + int legacy_after = endpoint + ? cbm_daemon_ipc_legacy_generation_probe(endpoint) + : -1; + int startup_after = endpoint + ? cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &after) + : -1; + cbm_daemon_ipc_startup_lock_release(&after); + (void)cbm_daemon_ipc_local_transition_release(&transition); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(acquired, 1); + ASSERT_EQ(sealed, 1); + ASSERT_EQ(lifetime, 0); + ASSERT_TRUE(begun); + ASSERT_FALSE(first_release); + ASSERT_TRUE(retained_after_failure); + ASSERT_TRUE(retry_release); + ASSERT_NULL(transition); + ASSERT_EQ(legacy_after, 0); + ASSERT_EQ(startup_after, 1); + PASS(); +#endif +} + +TEST(daemon_ipc_windows_startup_release_retains_retry_authority) { +#ifndef _WIN32 + PASS(); +#else + static const char key[] = "6f6f707081819292"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_daemon_ipc_startup_lock_t *blocked = NULL; + cbm_daemon_ipc_startup_lock_t *after = NULL; + + bool parent_ok = ipc_test_parent_new(parent, "startup-release-retry"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int acquired = endpoint + ? cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &startup) + : -1; + if (acquired == 1) { + cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test(1); + } + bool first_release = + cbm_daemon_ipc_startup_lock_release(&startup); + bool retained_after_failure = startup != NULL; + int blocked_while_retained = + retained_after_failure + ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &blocked) + : -1; + bool retry_release = + cbm_daemon_ipc_startup_lock_release(&startup); + cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test(0); + int acquired_after = retry_release + ? cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &after) + : -1; + bool after_released = + cbm_daemon_ipc_startup_lock_release(&after); + (void)cbm_daemon_ipc_startup_lock_release(&blocked); + (void)cbm_daemon_ipc_startup_lock_release(&startup); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(acquired, 1); + ASSERT_FALSE(first_release); + ASSERT_TRUE(retained_after_failure); + ASSERT_EQ(blocked_while_retained, 0); + ASSERT_TRUE(retry_release); + ASSERT_NULL(startup); + ASSERT_EQ(acquired_after, 1); + ASSERT_TRUE(after_released); + PASS(); +#endif +} + +/* A frame deadline is a fail-stop boundary. In particular, Windows + * overlapped I/O can complete while cancellation races a timeout, so an + * offset-zero timeout does not prove that the byte stream is untouched. + * Reusing that connection could interpret trailing bytes as a new header. */ +TEST(daemon_ipc_frame_timeout_poisons_connection) { + static const char key[] = "5a5a6b6b7c7c8d8d"; + static const uint8_t payload[] = {'a', 'f', 't', 'e', 'r'}; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_listener_t *listener = NULL; + cbm_daemon_ipc_connection_t *client = NULL; + cbm_daemon_ipc_connection_t *server = NULL; + cbm_daemon_frame_t frame = {0}; + uint8_t *received_payload = NULL; + int accept_result = -1; + int timeout_result = -1; + int reuse_result = 1; + bool sent_after_timeout = false; + + bool parent_ok = ipc_test_parent_new(parent, "frame-timeout"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + listener = cbm_daemon_ipc_listen(endpoint); + } + if (listener) { + client = cbm_daemon_ipc_connect(endpoint, 500); + } + if (client) { + accept_result = cbm_daemon_ipc_accept(listener, 500, &server); + } + if (accept_result == 1 && server) { + timeout_result = cbm_daemon_ipc_receive_frame(server, 20, &frame, &received_payload); + } + free(received_payload); + received_payload = NULL; + memset(&frame, 0, sizeof(frame)); + if (timeout_result == 0) { + sent_after_timeout = cbm_daemon_ipc_send_frame(client, CBM_DAEMON_FRAME_REQUEST, 0x1357, + payload, (uint32_t)sizeof(payload)); + } + if (sent_after_timeout) { + reuse_result = cbm_daemon_ipc_receive_frame(server, 500, &frame, &received_payload); + } + + free(received_payload); + cbm_daemon_ipc_connection_close(server); + cbm_daemon_ipc_connection_close(client); + cbm_daemon_ipc_listener_close(listener); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(accept_result, 1); + ASSERT_EQ(timeout_result, 0); + ASSERT_TRUE(sent_after_timeout); + ASSERT_EQ(reuse_result, -1); + PASS(); +} + +#ifndef _WIN32 + +static bool ipc_test_fd_write_all(int fd, const void *buffer, size_t length) { + size_t offset = 0; + while (offset < length) { + ssize_t written = write(fd, (const uint8_t *)buffer + offset, length - offset); + if (written > 0) { + offset += (size_t)written; + continue; + } + if (written < 0 && errno == EINTR) { + continue; + } + return false; + } + return true; +} + +static bool ipc_test_fd_read_all(int fd, void *buffer, size_t length) { + size_t offset = 0; + while (offset < length) { + ssize_t received = read(fd, (uint8_t *)buffer + offset, length - offset); + if (received > 0) { + offset += (size_t)received; + continue; + } + if (received < 0 && errno == EINTR) { + continue; + } + return false; + } + return true; +} + +static bool ipc_test_socket_send_all(int fd, const void *buffer, size_t length) { + size_t offset = 0; + while (offset < length) { +#ifdef MSG_NOSIGNAL + ssize_t written = send(fd, (const uint8_t *)buffer + offset, length - offset, MSG_NOSIGNAL); +#else + ssize_t written = send(fd, (const uint8_t *)buffer + offset, length - offset, 0); +#endif + if (written > 0) { + offset += (size_t)written; + continue; + } + if (written < 0 && errno == EINTR) { + continue; + } + return false; + } + return true; +} + +static bool ipc_test_unix_address_set(struct sockaddr_un *address, const char *path, + socklen_t *length_out) { + if (!address || !path || !length_out) { + return false; + } + size_t path_length = strlen(path); + if (path_length == 0 || path_length >= sizeof(address->sun_path)) { + return false; + } + memset(address, 0, sizeof(*address)); + address->sun_family = AF_UNIX; + memcpy(address->sun_path, path, path_length + 1); + socklen_t address_length = + (socklen_t)(offsetof(struct sockaddr_un, sun_path) + path_length + 1); +#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) + address->sun_len = (uint8_t)address_length; +#endif + *length_out = address_length; + return true; +} + +static bool ipc_test_socket_identity_path(char out[TEST_PATH_CAP], + const char *socket_path) { + int written = socket_path + ? snprintf(out, TEST_PATH_CAP, "%s.identity", socket_path) + : -1; + return written > 0 && written < TEST_PATH_CAP; +} + +static bool ipc_test_socket_anchor_path(char out[TEST_PATH_CAP], + const char *runtime_dir, + const char *key) { + int written = runtime_dir && key + ? snprintf(out, TEST_PATH_CAP, "%s/cbm-%s.anc", + runtime_dir, key) + : -1; + return written > 0 && written < TEST_PATH_CAP; +} + +static bool ipc_test_socket_pending_path(char out[TEST_PATH_CAP], + const char *socket_path) { + int written = socket_path + ? snprintf(out, TEST_PATH_CAP, "%s.pending", + socket_path) + : -1; + return written > 0 && written < TEST_PATH_CAP; +} + +#ifndef _WIN32 +static bool ipc_test_record_temp_path(char out[TEST_PATH_CAP], + const char *runtime_dir, + const char *record_path) { + if (!out || !runtime_dir || !record_path) { + return false; + } + out[0] = '\0'; + const char *base = strrchr(record_path, '/'); + base = base ? base + 1 : record_path; + char prefix[TEST_PATH_CAP]; + int prefix_length = snprintf(prefix, sizeof(prefix), "%s.tmp", base); + if (prefix_length <= 0 || prefix_length >= (int)sizeof(prefix)) { + return false; + } + DIR *directory = opendir(runtime_dir); + if (!directory) { + return false; + } + bool found = false; + bool ambiguous = false; + struct dirent *entry; + while ((entry = readdir(directory)) != NULL) { + if (strncmp(entry->d_name, prefix, (size_t)prefix_length) != 0) { + continue; + } + if (found) { + ambiguous = true; + break; + } + int written = snprintf(out, TEST_PATH_CAP, "%s/%s", runtime_dir, + entry->d_name); + found = written > 0 && written < TEST_PATH_CAP; + if (!found) { + break; + } + } + (void)closedir(directory); + if (ambiguous || !found) { + out[0] = '\0'; + return false; + } + return true; +} +#endif + +static void ipc_test_publication_crash_hook( + cbm_daemon_ipc_posix_publication_stage_t stage, void *opaque) { + const cbm_daemon_ipc_posix_publication_stage_t *target = opaque; + if (target && stage == *target) { + _exit(40 + (int)stage); + } +} + +static int ipc_test_cross_process_lock_child(const cbm_daemon_ipc_endpoint_t *endpoint, + int command_fd, int result_fd) { + for (int attempt = 0; attempt < 2; attempt++) { + uint8_t command = 0; + if (!ipc_test_fd_read_all(command_fd, &command, sizeof(command)) || + command != (uint8_t)(attempt + 1)) { + return 10 + attempt; + } + cbm_daemon_ipc_startup_lock_t *lock = NULL; + int result = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &lock); + cbm_daemon_ipc_startup_lock_release(&lock); + if (!ipc_test_fd_write_all(result_fd, &result, sizeof(result))) { + return 20 + attempt; + } + } + return 0; +} + +TEST(daemon_ipc_posix_startup_lock_is_cross_process) { + static const char key[] = "6666777788889999"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *parent_lock = NULL; + int commands[2] = {-1, -1}; + int results[2] = {-1, -1}; + pid_t child = -1; + int child_status = -1; + int parent_result = -1; + int child_while_held = -1; + int child_after_release = -1; + bool pipes_ok = false; + bool first_exchange_ok = false; + bool second_exchange_ok = false; + + bool parent_ok = ipc_test_parent_new(parent, "process-lock"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + if (pipe(commands) == 0) { + if (pipe(results) == 0) { + pipes_ok = true; + } else { + (void)close(commands[0]); + (void)close(commands[1]); + commands[0] = commands[1] = -1; + } + } + } + if (pipes_ok) { + child = fork(); + } + if (child == 0) { + (void)close(commands[1]); + (void)close(results[0]); + int child_result = ipc_test_cross_process_lock_child(endpoint, commands[0], results[1]); + (void)close(commands[0]); + (void)close(results[1]); + _exit(child_result); + } + if (child > 0) { + (void)close(commands[0]); + commands[0] = -1; + (void)close(results[1]); + results[1] = -1; + + parent_result = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &parent_lock); + uint8_t command = 1; + first_exchange_ok = + ipc_test_fd_write_all(commands[1], &command, sizeof(command)) && + ipc_test_fd_read_all(results[0], &child_while_held, sizeof(child_while_held)); + cbm_daemon_ipc_startup_lock_release(&parent_lock); + parent_lock = NULL; + + if (first_exchange_ok) { + command = 2; + second_exchange_ok = + ipc_test_fd_write_all(commands[1], &command, sizeof(command)) && + ipc_test_fd_read_all(results[0], &child_after_release, sizeof(child_after_release)); + } + (void)close(commands[1]); + commands[1] = -1; + (void)close(results[0]); + results[0] = -1; + while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} + } + + cbm_daemon_ipc_startup_lock_release(&parent_lock); + for (size_t i = 0; i < 2; i++) { + if (commands[i] >= 0) { + (void)close(commands[i]); + } + if (results[i] >= 0) { + (void)close(results[i]); + } + } + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(pipes_ok); + ASSERT_GT(child, 0); + ASSERT_EQ(parent_result, 1); + ASSERT_TRUE(first_exchange_ok); + ASSERT_EQ(child_while_held, 0); + ASSERT_TRUE(second_exchange_ok); + ASSERT_EQ(child_after_release, 1); + ASSERT_TRUE(WIFEXITED(child_status)); + ASSERT_EQ(WEXITSTATUS(child_status), 0); + PASS(); +} + +TEST(daemon_ipc_posix_lifetime_reservation_rejects_fork_inheritance) { + static const char key[] = "6a6a7b7b8c8c9d9d"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL; + cbm_daemon_ipc_listener_t *parent_listener = NULL; + int result_pipe[2] = {-1, -1}; + pid_t child = -1; + uint8_t child_result = 0; + int child_status = -1; + int acquired = -1; + int held_after_child = -1; + bool parent_transfer_consumed = false; + int free_after_close = -1; + + if (ipc_test_parent_new(parent, "forked-lifetime")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + acquired = cbm_daemon_ipc_lifetime_reservation_try_acquire( + endpoint, &reservation); + } + if (reservation && pipe(result_pipe) == 0) { + child = fork(); + } + if (child == 0) { + (void)close(result_pipe[0]); + cbm_daemon_ipc_lifetime_reservation_t *inherited = reservation; + cbm_daemon_ipc_listener_t *unexpected = + cbm_daemon_ipc_listen_reserved(endpoint, &inherited); + child_result = unexpected == NULL && inherited == reservation ? 1 : 0; + cbm_daemon_ipc_listener_close(unexpected); + cbm_daemon_ipc_lifetime_reservation_release(inherited); + bool reported = ipc_test_fd_write_all( + result_pipe[1], &child_result, sizeof(child_result)); + (void)close(result_pipe[1]); + _exit(reported && child_result == 1 ? 0 : 1); + } + if (child > 0) { + (void)close(result_pipe[1]); + result_pipe[1] = -1; + bool received = ipc_test_fd_read_all( + result_pipe[0], &child_result, sizeof(child_result)); + (void)close(result_pipe[0]); + result_pipe[0] = -1; + if (!received) { + child_result = 0; + } + while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} + } + if (child_result == 1 && WIFEXITED(child_status) && + WEXITSTATUS(child_status) == 0) { + held_after_child = + cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + parent_listener = + cbm_daemon_ipc_listen_reserved(endpoint, &reservation); + parent_transfer_consumed = + parent_listener != NULL && reservation == NULL; + } + cbm_daemon_ipc_listener_close(parent_listener); + cbm_daemon_ipc_lifetime_reservation_release(reservation); + if (endpoint) { + free_after_close = + cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + } + for (size_t index = 0; index < 2; index++) { + if (result_pipe[index] >= 0) { + (void)close(result_pipe[index]); + } + } + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_EQ(acquired, 1); + ASSERT_GT(child, 0); + ASSERT_EQ(child_result, 1); + ASSERT_TRUE(WIFEXITED(child_status)); + ASSERT_EQ(WEXITSTATUS(child_status), 0); + ASSERT_EQ(held_after_child, 1); + ASSERT_TRUE(parent_transfer_consumed); + ASSERT_EQ(free_after_close, 0); + PASS(); +} + +TEST(daemon_ipc_posix_child_participant_handoff_retains_legacy_bridge) { +#ifdef _WIN32 + PASS(); +#else + static const char key[] = "6b6b7c7c8d8d9e9e"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char legacy_path[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + int parent_to_child[2] = {-1, -1}; + int child_to_parent[2] = {-1, -1}; + pid_t child = -1; + int child_status = -1; + uint8_t joined = 0; + uint8_t released = 0; + int legacy_fd = -1; + int blocked_while_child = -1; + int acquired_after_child = -1; + + bool parent_ok = ipc_test_parent_new(parent, "participant-handoff"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + int path_length = snprintf(legacy_path, sizeof(legacy_path), + "%s/cbm-%s.lock", runtime_dir, key); + parent_ok = path_length > 0 && + path_length < (int)sizeof(legacy_path); + } + int startup_result = + parent_ok ? cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &startup) + : -1; + bool prepared = startup_result == 1 && + cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + bool pipes_ok = prepared && pipe(parent_to_child) == 0 && + pipe(child_to_parent) == 0; + if (pipes_ok) { + child = fork(); + } + if (child == 0) { + (void)close(parent_to_child[1]); + (void)close(child_to_parent[0]); + cbm_daemon_ipc_participant_guard_t *guard = NULL; + int join_result = cbm_daemon_ipc_participant_guard_try_join( + endpoint, &guard); + uint8_t join_byte = join_result == 1 && guard ? 1 : 0; + bool reported = ipc_test_fd_write_all( + child_to_parent[1], &join_byte, sizeof(join_byte)); + uint8_t command = 0; + bool commanded = reported && ipc_test_fd_read_all( + parent_to_child[0], &command, sizeof(command)); + bool guard_released = + cbm_daemon_ipc_participant_guard_release(&guard); + uint8_t release_byte = + commanded && command == 1 && guard_released && !guard ? 1 : 0; + bool release_reported = ipc_test_fd_write_all( + child_to_parent[1], &release_byte, sizeof(release_byte)); + (void)close(parent_to_child[0]); + (void)close(child_to_parent[1]); + _exit(join_byte == 1 && release_reported && release_byte == 1 ? 0 + : 1); + } + if (child > 0) { + (void)close(parent_to_child[0]); + parent_to_child[0] = -1; + (void)close(child_to_parent[1]); + child_to_parent[1] = -1; + bool joined_read = ipc_test_fd_read_all( + child_to_parent[0], &joined, sizeof(joined)); + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + legacy_fd = open(legacy_path, O_RDWR | O_CLOEXEC | O_NOFOLLOW); + if (joined_read && joined == 1 && legacy_fd >= 0) { + blocked_while_child = + flock(legacy_fd, LOCK_EX | LOCK_NB) == 0 ? 0 : + (errno == EWOULDBLOCK || errno == EAGAIN ? 1 : -1); + } + uint8_t command = 1; + bool commanded = ipc_test_fd_write_all( + parent_to_child[1], &command, sizeof(command)); + bool released_read = commanded && ipc_test_fd_read_all( + child_to_parent[0], &released, sizeof(released)); + (void)close(parent_to_child[1]); + parent_to_child[1] = -1; + (void)close(child_to_parent[0]); + child_to_parent[0] = -1; + while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} + if (released_read && released == 1 && WIFEXITED(child_status) && + legacy_fd >= 0) { + acquired_after_child = + flock(legacy_fd, LOCK_EX | LOCK_NB) == 0 ? 1 : 0; + } + } + + cbm_daemon_ipc_startup_lock_release(&startup); + if (legacy_fd >= 0) { + (void)flock(legacy_fd, LOCK_UN); + (void)close(legacy_fd); + } + for (size_t index = 0; index < 2; index++) { + if (parent_to_child[index] >= 0) { + (void)close(parent_to_child[index]); + } + if (child_to_parent[index] >= 0) { + (void)close(child_to_parent[index]); + } + } + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(startup_result, 1); + ASSERT_TRUE(prepared); + ASSERT_TRUE(pipes_ok); + ASSERT_GT(child, 0); + ASSERT_EQ(joined, 1); + ASSERT_EQ(blocked_while_child, 1); + ASSERT_EQ(released, 1); + ASSERT_EQ(acquired_after_child, 1); + ASSERT_TRUE(WIFEXITED(child_status)); + ASSERT_EQ(WEXITSTATUS(child_status), 0); + PASS(); +#endif +} + +TEST(daemon_ipc_posix_publication_boundaries_recover_from_crash) { +#ifdef _WIN32 + PASS(); +#else + static const char key[] = "9f8e7d6c5b4a3210"; + static cbm_daemon_ipc_posix_publication_stage_t stages[] = { + CBM_DAEMON_IPC_POSIX_PUBLICATION_ANCHOR_DURABLE, + CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_DURABLE, + CBM_DAEMON_IPC_POSIX_PUBLICATION_STABLE_DURABLE, + CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_DURABLE, + CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_REMOVED, + }; + bool stage_ok[sizeof(stages) / sizeof(stages[0])] = {0}; + + for (size_t index = 0; index < sizeof(stages) / sizeof(stages[0]); + index++) { + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char socket_path[TEST_PATH_CAP] = {0}; + char anchor_path[TEST_PATH_CAP] = {0}; + char identity_path[TEST_PATH_CAP] = {0}; + char pending_path[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + struct stat socket_status = {0}; + struct stat anchor_status = {0}; + struct stat record_status = {0}; + pid_t child = -1; + int child_status = -1; + + bool parent_ok = ipc_test_parent_new(parent, "publish-boundary"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path( + runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path( + socket_path, + cbm_daemon_ipc_endpoint_address(endpoint)); + } + bool paths_ok = endpoint && + ipc_test_socket_anchor_path(anchor_path, runtime_dir, + key) && + ipc_test_socket_identity_path(identity_path, + socket_path) && + ipc_test_socket_pending_path(pending_path, + socket_path); + if (paths_ok) { + cbm_daemon_ipc_posix_publication_hook_set_for_test( + ipc_test_publication_crash_hook, &stages[index]); + child = fork(); + } + if (child == 0) { + cbm_daemon_ipc_listener_t *listener = + cbm_daemon_ipc_listen(endpoint); + _exit(listener ? 90 : 91); + } + cbm_daemon_ipc_posix_publication_hook_set_for_test(NULL, NULL); + if (child > 0) { + while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} + } + + bool crashed_at_boundary = + child > 0 && WIFEXITED(child_status) && + WEXITSTATUS(child_status) == 40 + (int)stages[index]; + bool anchor_present = lstat(anchor_path, &anchor_status) == 0 && + S_ISSOCK(anchor_status.st_mode); + bool stable_present = lstat(socket_path, &socket_status) == 0 && + S_ISSOCK(socket_status.st_mode); + bool pending_present = lstat(pending_path, &record_status) == 0 && + S_ISREG(record_status.st_mode); + bool marker_present = lstat(identity_path, &record_status) == 0 && + S_ISREG(record_status.st_mode); + bool linked_shape = stages[index] < + CBM_DAEMON_IPC_POSIX_PUBLICATION_STABLE_DURABLE + ? anchor_present && + anchor_status.st_nlink == 1 && + !stable_present + : anchor_present && stable_present && + anchor_status.st_nlink == 2 && + socket_status.st_nlink == 2 && + anchor_status.st_dev == + socket_status.st_dev && + anchor_status.st_ino == + socket_status.st_ino; + bool expected_records = + pending_present == + (stages[index] >= + CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_DURABLE && + stages[index] < + CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_REMOVED) && + marker_present == + (stages[index] >= + CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_DURABLE); + + int startup_result = + crashed_at_boundary + ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, + &startup) + : -1; + int cleanup_result = + startup_result == 1 + ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) + : -1; + cbm_daemon_ipc_startup_lock_release(&startup); + errno = 0; + bool stable_removed = lstat(socket_path, &record_status) != 0 && + errno == ENOENT; + errno = 0; + bool anchor_removed = lstat(anchor_path, &record_status) != 0 && + errno == ENOENT; + errno = 0; + bool pending_removed = lstat(pending_path, &record_status) != 0 && + errno == ENOENT; + errno = 0; + bool marker_removed = lstat(identity_path, &record_status) != 0 && + errno == ENOENT; + stage_ok[index] = parent_ok && paths_ok && crashed_at_boundary && + linked_shape && expected_records && + startup_result == 1 && cleanup_result == 1 && + stable_removed && anchor_removed && + pending_removed && marker_removed; + + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + } + + for (size_t index = 0; index < sizeof(stage_ok) / sizeof(stage_ok[0]); + index++) { + ASSERT_TRUE(stage_ok[index]); + } + PASS(); +#endif +} + +TEST(daemon_ipc_posix_record_publication_windows_recover_from_crash) { +#ifdef _WIN32 + PASS(); +#else + static const char key[] = "1a2b3c4d5e6f7081"; + static const struct { + cbm_daemon_ipc_posix_publication_stage_t stage; + bool marker; + bool linked; + } cases[] = { + {CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_TEMP_SYNCED, false, + false}, + {CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_RECORD_LINKED, false, + true}, + {CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_TEMP_SYNCED, true, + false}, + {CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_RECORD_LINKED, true, + true}, + }; + bool case_ok[sizeof(cases) / sizeof(cases[0])] = {0}; + + for (size_t index = 0; index < sizeof(cases) / sizeof(cases[0]); + index++) { + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char socket_path[TEST_PATH_CAP] = {0}; + char anchor_path[TEST_PATH_CAP] = {0}; + char identity_path[TEST_PATH_CAP] = {0}; + char pending_path[TEST_PATH_CAP] = {0}; + char temp_path[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + struct stat socket_status = {0}; + struct stat anchor_status = {0}; + struct stat pending_status = {0}; + struct stat marker_status = {0}; + struct stat record_status = {0}; + struct stat temp_status = {0}; + struct stat absent_status = {0}; + pid_t child = -1; + int child_status = -1; + + bool parent_ok = ipc_test_parent_new(parent, "record-window"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path( + runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path( + socket_path, + cbm_daemon_ipc_endpoint_address(endpoint)); + } + bool paths_ok = endpoint && + ipc_test_socket_anchor_path(anchor_path, runtime_dir, + key) && + ipc_test_socket_identity_path(identity_path, + socket_path) && + ipc_test_socket_pending_path(pending_path, + socket_path); + if (paths_ok) { + cbm_daemon_ipc_posix_publication_hook_set_for_test( + ipc_test_publication_crash_hook, + (void *)&cases[index].stage); + child = fork(); + } + if (child == 0) { + cbm_daemon_ipc_listener_t *listener = + cbm_daemon_ipc_listen(endpoint); + _exit(listener ? 90 : 91); + } + cbm_daemon_ipc_posix_publication_hook_set_for_test(NULL, NULL); + if (child > 0) { + while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} + } + + bool crashed = child > 0 && WIFEXITED(child_status) && + WEXITSTATUS(child_status) == + 40 + (int)cases[index].stage; + const char *record_path = + cases[index].marker ? identity_path : pending_path; + bool temp_found = + crashed && ipc_test_record_temp_path(temp_path, runtime_dir, + record_path) && + lstat(temp_path, &temp_status) == 0 && + S_ISREG(temp_status.st_mode) && + temp_status.st_uid == geteuid() && + (temp_status.st_mode & 0777) == 0600 && + temp_status.st_nlink == (cases[index].linked ? 2 : 1); + errno = 0; + bool record_shape = + cases[index].linked + ? lstat(record_path, &record_status) == 0 && + S_ISREG(record_status.st_mode) && + record_status.st_nlink == 2 && temp_found && + record_status.st_dev == temp_status.st_dev && + record_status.st_ino == temp_status.st_ino + : lstat(record_path, &absent_status) != 0 && + errno == ENOENT; + bool socket_shape = + cases[index].marker + ? lstat(socket_path, &socket_status) == 0 && + S_ISSOCK(socket_status.st_mode) && + socket_status.st_nlink == 2 && + lstat(anchor_path, &anchor_status) == 0 && + S_ISSOCK(anchor_status.st_mode) && + anchor_status.st_nlink == 2 && + socket_status.st_dev == anchor_status.st_dev && + socket_status.st_ino == anchor_status.st_ino + : lstat(anchor_path, &anchor_status) == 0 && + S_ISSOCK(anchor_status.st_mode) && + anchor_status.st_nlink == 1 && + lstat(socket_path, &absent_status) != 0 && + errno == ENOENT; + errno = 0; + bool preceding_record_shape = + cases[index].marker + ? lstat(pending_path, &pending_status) == 0 && + S_ISREG(pending_status.st_mode) && + pending_status.st_nlink == 1 + : lstat(identity_path, &marker_status) != 0 && + errno == ENOENT; + + int startup_result = + crashed + ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, + &startup) + : -1; + int cleanup_result = + startup_result == 1 + ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) + : -1; + cbm_daemon_ipc_startup_lock_release(&startup); + errno = 0; + bool stable_removed = lstat(socket_path, &absent_status) != 0 && + errno == ENOENT; + errno = 0; + bool anchor_removed = lstat(anchor_path, &absent_status) != 0 && + errno == ENOENT; + errno = 0; + bool pending_removed = lstat(pending_path, &absent_status) != 0 && + errno == ENOENT; + errno = 0; + bool marker_removed = lstat(identity_path, &absent_status) != 0 && + errno == ENOENT; + errno = 0; + bool temp_removed = temp_path[0] != '\0' && + lstat(temp_path, &absent_status) != 0 && + errno == ENOENT; + case_ok[index] = parent_ok && paths_ok && crashed && temp_found && + record_shape && socket_shape && + preceding_record_shape && startup_result == 1 && + cleanup_result == 1 && stable_removed && + anchor_removed && pending_removed && marker_removed && + temp_removed; + + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + } + + for (size_t index = 0; index < sizeof(case_ok) / sizeof(case_ok[0]); + index++) { + ASSERT_TRUE(case_ok[index]); + } + PASS(); +#endif +} + +TEST(daemon_ipc_posix_unknown_record_temp_pair_is_preserved) { +#ifdef _WIN32 + PASS(); +#else + static const char key[] = "2b3c4d5e6f708192"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char socket_path[TEST_PATH_CAP] = {0}; + char pending_path[TEST_PATH_CAP] = {0}; + char temp_path[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + struct stat pending_before = {0}; + struct stat temp_before = {0}; + struct stat pending_after = {0}; + struct stat temp_after = {0}; + + bool parent_ok = ipc_test_parent_new(parent, "unknown-record-temp"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, + cbm_daemon_ipc_endpoint_address(endpoint)); + } + bool pending_path_ok = + endpoint && ipc_test_socket_pending_path(pending_path, socket_path); + int temp_written = + pending_path_ok + ? snprintf(temp_path, sizeof(temp_path), "%s.tmp", pending_path) + : -1; + bool paths_ok = pending_path_ok && temp_written > 0 && + temp_written < (int)sizeof(temp_path); + bool pair_created = paths_ok && ipc_test_write_byte(temp_path, 0x5a) && + chmod(temp_path, 0600) == 0 && + link(temp_path, pending_path) == 0 && + lstat(temp_path, &temp_before) == 0 && + lstat(pending_path, &pending_before) == 0 && + S_ISREG(temp_before.st_mode) && + temp_before.st_nlink == 2 && + pending_before.st_dev == temp_before.st_dev && + pending_before.st_ino == temp_before.st_ino; + int startup_result = + pair_created + ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) + : -1; + int cleanup_result = + startup_result == 1 + ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) + : -1; + cbm_daemon_ipc_startup_lock_release(&startup); + bool pair_preserved = + lstat(temp_path, &temp_after) == 0 && + lstat(pending_path, &pending_after) == 0 && + temp_after.st_dev == temp_before.st_dev && + temp_after.st_ino == temp_before.st_ino && + temp_after.st_nlink == 2 && pending_after.st_nlink == 2 && + pending_after.st_dev == pending_before.st_dev && + pending_after.st_ino == pending_before.st_ino && + temp_after.st_size == 1 && pending_after.st_size == 1; + + (void)unlink(pending_path); + (void)unlink(temp_path); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(paths_ok); + ASSERT_TRUE(pair_created); + ASSERT_EQ(startup_result, 1); + ASSERT_EQ(cleanup_result, 0); + ASSERT_TRUE(pair_preserved); + PASS(); +#endif +} + +TEST(daemon_ipc_posix_recovery_preserves_replaced_stable_socket) { +#ifdef _WIN32 + PASS(); +#else + static const char key[] = "8e7d6c5b4a392817"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char socket_path[TEST_PATH_CAP] = {0}; + char anchor_path[TEST_PATH_CAP] = {0}; + char identity_path[TEST_PATH_CAP] = {0}; + char pending_path[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_daemon_ipc_posix_publication_stage_t crash_stage = + CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_REMOVED; + struct stat replacement_before = {0}; + struct stat replacement_after = {0}; + struct stat status = {0}; + struct sockaddr_un address; + socklen_t address_length = 0; + pid_t child = -1; + int child_status = -1; + int replacement = -1; + + bool parent_ok = ipc_test_parent_new(parent, "stable-replacement"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, + cbm_daemon_ipc_endpoint_address(endpoint)); + } + bool paths_ok = endpoint && + ipc_test_socket_anchor_path(anchor_path, runtime_dir, + key) && + ipc_test_socket_identity_path(identity_path, + socket_path) && + ipc_test_socket_pending_path(pending_path, socket_path) && + ipc_test_unix_address_set(&address, socket_path, + &address_length); + if (paths_ok) { + cbm_daemon_ipc_posix_publication_hook_set_for_test( + ipc_test_publication_crash_hook, &crash_stage); + child = fork(); + } + if (child == 0) { + cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(endpoint); + _exit(listener ? 90 : 91); + } + cbm_daemon_ipc_posix_publication_hook_set_for_test(NULL, NULL); + if (child > 0) { + while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} + } + bool crashed_committed = + child > 0 && WIFEXITED(child_status) && + WEXITSTATUS(child_status) == 40 + (int)crash_stage; + bool stable_unlinked = crashed_committed && unlink(socket_path) == 0; + if (stable_unlinked) { + replacement = socket(AF_UNIX, SOCK_STREAM, 0); + } + bool replacement_ready = + replacement >= 0 && + bind(replacement, (const struct sockaddr *)&address, + address_length) == 0 && + chmod(socket_path, 0600) == 0 && listen(replacement, 1) == 0 && + lstat(socket_path, &replacement_before) == 0 && + S_ISSOCK(replacement_before.st_mode); + int startup_result = + replacement_ready + ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) + : -1; + int cleanup_result = + startup_result == 1 + ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) + : -1; + cbm_daemon_ipc_startup_lock_release(&startup); + bool replacement_preserved = + lstat(socket_path, &replacement_after) == 0 && + S_ISSOCK(replacement_after.st_mode) && + replacement_after.st_dev == replacement_before.st_dev && + replacement_after.st_ino == replacement_before.st_ino; + errno = 0; + bool anchor_removed = lstat(anchor_path, &status) != 0 && + errno == ENOENT; + errno = 0; + bool pending_removed = lstat(pending_path, &status) != 0 && + errno == ENOENT; + errno = 0; + bool marker_removed = lstat(identity_path, &status) != 0 && + errno == ENOENT; + + if (replacement >= 0) { + (void)close(replacement); + } + (void)unlink(socket_path); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(paths_ok); + ASSERT_TRUE(crashed_committed); + ASSERT_TRUE(stable_unlinked); + ASSERT_TRUE(replacement_ready); + ASSERT_EQ(startup_result, 1); + ASSERT_EQ(cleanup_result, 0); + ASSERT_TRUE(replacement_preserved); + ASSERT_TRUE(anchor_removed); + ASSERT_TRUE(pending_removed); + ASSERT_TRUE(marker_removed); + PASS(); +#endif +} + +TEST(daemon_ipc_posix_pending_without_anchor_never_deletes_stable) { +#ifdef _WIN32 + PASS(); +#else + static const char key[] = "7d6c5b4a39281706"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char socket_path[TEST_PATH_CAP] = {0}; + char anchor_path[TEST_PATH_CAP] = {0}; + char pending_path[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_daemon_ipc_posix_publication_stage_t crash_stage = + CBM_DAEMON_IPC_POSIX_PUBLICATION_STABLE_DURABLE; + struct stat stable_before = {0}; + struct stat stable_after = {0}; + struct stat status = {0}; + pid_t child = -1; + int child_status = -1; + + bool parent_ok = ipc_test_parent_new(parent, "pending-no-anchor"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, + cbm_daemon_ipc_endpoint_address(endpoint)); + } + bool paths_ok = endpoint && + ipc_test_socket_anchor_path(anchor_path, runtime_dir, + key) && + ipc_test_socket_pending_path(pending_path, socket_path); + if (paths_ok) { + cbm_daemon_ipc_posix_publication_hook_set_for_test( + ipc_test_publication_crash_hook, &crash_stage); + child = fork(); + } + if (child == 0) { + cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(endpoint); + _exit(listener ? 90 : 91); + } + cbm_daemon_ipc_posix_publication_hook_set_for_test(NULL, NULL); + if (child > 0) { + while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} + } + bool crashed_before_marker = + child > 0 && WIFEXITED(child_status) && + WEXITSTATUS(child_status) == 40 + (int)crash_stage; + bool anchor_removed = + crashed_before_marker && lstat(socket_path, &stable_before) == 0 && + S_ISSOCK(stable_before.st_mode) && unlink(anchor_path) == 0; + int startup_result = + anchor_removed + ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) + : -1; + int cleanup_result = + startup_result == 1 + ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) + : -1; + cbm_daemon_ipc_startup_lock_release(&startup); + bool stable_preserved = + lstat(socket_path, &stable_after) == 0 && + S_ISSOCK(stable_after.st_mode) && + stable_after.st_dev == stable_before.st_dev && + stable_after.st_ino == stable_before.st_ino; + errno = 0; + bool pending_removed = lstat(pending_path, &status) != 0 && + errno == ENOENT; + + (void)unlink(socket_path); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(paths_ok); + ASSERT_TRUE(crashed_before_marker); + ASSERT_TRUE(anchor_removed); + ASSERT_EQ(startup_result, 1); + ASSERT_EQ(cleanup_result, 0); + ASSERT_TRUE(stable_preserved); + ASSERT_TRUE(pending_removed); + PASS(); +#endif +} + +TEST(daemon_ipc_posix_current_generation_crash_cleanup_requires_startup_lock) { + static const char key[] = "a1b2c3d4e5f60718"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char socket_path[TEST_PATH_CAP] = {0}; + char anchor_path[TEST_PATH_CAP] = {0}; + char identity_path[TEST_PATH_CAP] = {0}; + char pending_path[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_endpoint_t *wrong_endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_daemon_ipc_startup_lock_t *wrong_startup = NULL; + int ready_pipe[2] = {-1, -1}; + pid_t child = -1; + int child_status = -1; + uint8_t ready = 0; + struct stat status = {0}; + bool paths_ok = false; + bool child_ready = false; + bool artifacts_survived_crash = false; + int lifetime_after_crash = -1; + int cleanup_without_lock = -2; + int wrong_startup_result = -1; + int cleanup_with_wrong_lock = -2; + int startup_result = -1; + int cleanup_result = -1; + bool artifacts_removed = false; + + if (ipc_test_parent_new(parent, "crash-identity")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, + cbm_daemon_ipc_endpoint_address(endpoint)); + paths_ok = ipc_test_socket_anchor_path(anchor_path, runtime_dir, key) && + ipc_test_socket_identity_path(identity_path, socket_path) && + ipc_test_socket_pending_path(pending_path, socket_path) && + pipe(ready_pipe) == 0; + } + if (paths_ok) { + child = fork(); + } + if (child == 0) { + (void)close(ready_pipe[0]); + cbm_daemon_ipc_listener_t *listener = + cbm_daemon_ipc_listen(endpoint); + uint8_t result = listener ? 'R' : 'E'; + bool reported = + ipc_test_fd_write_all(ready_pipe[1], &result, sizeof(result)); + (void)close(ready_pipe[1]); + /* Deliberately bypass listener_close: the kernel releases descriptors + * and locks, while the current-generation socket identity remains. */ + _exit(listener && reported ? 0 : 1); + } + if (child > 0) { + (void)close(ready_pipe[1]); + ready_pipe[1] = -1; + child_ready = ipc_test_fd_read_all(ready_pipe[0], &ready, + sizeof(ready)) && + ready == 'R'; + (void)close(ready_pipe[0]); + ready_pipe[0] = -1; + while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} + } + if (child_ready && WIFEXITED(child_status) && + WEXITSTATUS(child_status) == 0) { + artifacts_survived_crash = + lstat(socket_path, &status) == 0 && S_ISSOCK(status.st_mode) && + status.st_nlink == 2 && + lstat(anchor_path, &status) == 0 && S_ISSOCK(status.st_mode) && + status.st_nlink == 2 && + lstat(identity_path, &status) == 0 && S_ISREG(status.st_mode); + lifetime_after_crash = + cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + cleanup_without_lock = + cbm_daemon_ipc_stale_generation_cleanup(endpoint, NULL); + wrong_endpoint = cbm_daemon_ipc_endpoint_new( + "a1b2c3d4e5f60719", parent); + wrong_startup_result = + wrong_endpoint + ? cbm_daemon_ipc_startup_lock_try_acquire( + wrong_endpoint, &wrong_startup) + : -1; + cleanup_with_wrong_lock = + wrong_startup + ? cbm_daemon_ipc_stale_generation_cleanup( + endpoint, wrong_startup) + : -2; + cbm_daemon_ipc_startup_lock_release(&wrong_startup); + wrong_startup = NULL; + startup_result = cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &startup); + } + if (startup) { + cleanup_result = cbm_daemon_ipc_stale_generation_cleanup( + endpoint, startup); + errno = 0; + bool socket_removed = lstat(socket_path, &status) != 0 && + errno == ENOENT; + errno = 0; + bool identity_removed = lstat(identity_path, &status) != 0 && + errno == ENOENT; + errno = 0; + bool anchor_removed = lstat(anchor_path, &status) != 0 && + errno == ENOENT; + errno = 0; + bool pending_removed = lstat(pending_path, &status) != 0 && + errno == ENOENT; + artifacts_removed = socket_removed && anchor_removed && + identity_removed && pending_removed; + } + + cbm_daemon_ipc_startup_lock_release(&startup); + cbm_daemon_ipc_startup_lock_release(&wrong_startup); + for (size_t index = 0; index < 2; index++) { + if (ready_pipe[index] >= 0) { + (void)close(ready_pipe[index]); + } + } + cbm_daemon_ipc_endpoint_free(wrong_endpoint); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(paths_ok); + ASSERT_GT(child, 0); + ASSERT_TRUE(child_ready); + ASSERT_TRUE(WIFEXITED(child_status)); + ASSERT_EQ(WEXITSTATUS(child_status), 0); + ASSERT_TRUE(artifacts_survived_crash); + ASSERT_EQ(lifetime_after_crash, 0); + ASSERT_EQ(cleanup_without_lock, -1); + ASSERT_EQ(wrong_startup_result, 1); + ASSERT_EQ(cleanup_with_wrong_lock, -1); + ASSERT_EQ(startup_result, 1); + ASSERT_EQ(cleanup_result, 1); + ASSERT_TRUE(artifacts_removed); + PASS(); +} + +TEST(daemon_ipc_posix_unknown_socket_without_identity_refuses_cleanup) { + static const char key[] = "b1c2d3e4f5061728"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char socket_path[TEST_PATH_CAP] = {0}; + char identity_path[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_daemon_ipc_local_transition_t *transition = NULL; + int raw_listener = -1; + struct sockaddr_un address; + socklen_t address_length = 0; + struct stat before = {0}; + struct stat after = {0}; + bool paths_ok = false; + bool unknown_created = false; + int startup_result = -1; + int cleanup_result = -1; + int transition_result = -1; + int transition_seal_result = -1; + bool unknown_unchanged = false; + bool identity_absent = false; + + if (ipc_test_parent_new(parent, "unknown-socket")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, + cbm_daemon_ipc_endpoint_address(endpoint)); + paths_ok = ipc_test_socket_identity_path(identity_path, socket_path) && + ipc_test_unix_address_set(&address, socket_path, + &address_length); + } + if (paths_ok) { + raw_listener = socket(AF_UNIX, SOCK_STREAM, 0); + } + if (raw_listener >= 0) { + unknown_created = + bind(raw_listener, (const struct sockaddr *)&address, + address_length) == 0 && + chmod(socket_path, 0600) == 0 && listen(raw_listener, 1) == 0 && + lstat(socket_path, &before) == 0 && S_ISSOCK(before.st_mode) && + before.st_uid == geteuid() && (before.st_mode & 0777) == 0600; + (void)close(raw_listener); + raw_listener = -1; + } + if (unknown_created) { + startup_result = cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &startup); + } + if (startup) { + cleanup_result = cbm_daemon_ipc_stale_generation_cleanup( + endpoint, startup); + unknown_unchanged = + lstat(socket_path, &after) == 0 && S_ISSOCK(after.st_mode) && + after.st_dev == before.st_dev && after.st_ino == before.st_ino; + errno = 0; + identity_absent = lstat(identity_path, &after) != 0 && + errno == ENOENT; + } + + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + if (unknown_unchanged) { + transition_result = cbm_daemon_ipc_local_transition_try_acquire( + endpoint, &transition); + } + if (transition) { + transition_seal_result = + cbm_daemon_ipc_local_transition_seal_legacy(transition); + } + (void)cbm_daemon_ipc_local_transition_release(&transition); + if (raw_listener >= 0) { + (void)close(raw_listener); + } + (void)unlink(socket_path); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(paths_ok); + ASSERT_TRUE(unknown_created); + ASSERT_EQ(startup_result, 1); + ASSERT_EQ(cleanup_result, 0); + ASSERT_EQ(transition_result, 1); + ASSERT_EQ(transition_seal_result, 0); + ASSERT_TRUE(unknown_unchanged); + ASSERT_TRUE(identity_absent); + PASS(); +} + +TEST(daemon_ipc_posix_active_listener_is_never_cleaned_under_queue_pressure) { + static const char key[] = "c1d2e3f405162738"; + enum { CLIENT_CAP = 64 }; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char socket_path[TEST_PATH_CAP] = {0}; + char anchor_path[TEST_PATH_CAP] = {0}; + char identity_path[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_listener_t *listener = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + cbm_daemon_ipc_connection_t *clients[CLIENT_CAP] = {0}; + size_t client_count = 0; + struct stat socket_before = {0}; + struct stat anchor_before = {0}; + struct stat identity_before = {0}; + struct stat socket_after = {0}; + struct stat anchor_after = {0}; + struct stat identity_after = {0}; + bool paths_ok = false; + bool artifacts_before = false; + int startup_result = -1; + int cleanup_result = -1; + bool artifacts_unchanged = false; + int endpoint_after_cleanup = -1; + + if (ipc_test_parent_new(parent, "active-cleanup")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, + cbm_daemon_ipc_endpoint_address(endpoint)); + paths_ok = ipc_test_socket_anchor_path(anchor_path, runtime_dir, key) && + ipc_test_socket_identity_path(identity_path, socket_path); + listener = cbm_daemon_ipc_listen(endpoint); + } + if (listener) { + artifacts_before = + lstat(socket_path, &socket_before) == 0 && + S_ISSOCK(socket_before.st_mode) && + lstat(anchor_path, &anchor_before) == 0 && + S_ISSOCK(anchor_before.st_mode) && + anchor_before.st_dev == socket_before.st_dev && + anchor_before.st_ino == socket_before.st_ino && + lstat(identity_path, &identity_before) == 0 && + S_ISREG(identity_before.st_mode); + } + /* Never accept: pressure the listen queue into the BSD case where a live + * listener can reject further connects with ECONNREFUSED. Cleanup must + * rely only on the retained lifetime reservation, never on connect(). */ + while (listener && client_count < CLIENT_CAP) { + cbm_daemon_ipc_connection_t *client = + cbm_daemon_ipc_connect(endpoint, 1); + if (!client) { + break; + } + clients[client_count++] = client; + } + if (listener) { + startup_result = cbm_daemon_ipc_startup_lock_try_acquire( + endpoint, &startup); + } + if (startup) { + cleanup_result = cbm_daemon_ipc_stale_generation_cleanup( + endpoint, startup); + artifacts_unchanged = + lstat(socket_path, &socket_after) == 0 && + S_ISSOCK(socket_after.st_mode) && + socket_after.st_dev == socket_before.st_dev && + socket_after.st_ino == socket_before.st_ino && + lstat(anchor_path, &anchor_after) == 0 && + S_ISSOCK(anchor_after.st_mode) && + anchor_after.st_dev == anchor_before.st_dev && + anchor_after.st_ino == anchor_before.st_ino && + lstat(identity_path, &identity_after) == 0 && + S_ISREG(identity_after.st_mode) && + identity_after.st_dev == identity_before.st_dev && + identity_after.st_ino == identity_before.st_ino; + endpoint_after_cleanup = cbm_daemon_ipc_endpoint_probe(endpoint, 1); + } + + cbm_daemon_ipc_startup_lock_release(&startup); + for (size_t index = 0; index < client_count; index++) { + cbm_daemon_ipc_connection_close(clients[index]); + } + cbm_daemon_ipc_listener_close(listener); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(paths_ok); + ASSERT_TRUE(artifacts_before); + ASSERT_TRUE(client_count > 0); + ASSERT_EQ(startup_result, 1); + ASSERT_EQ(cleanup_result, 0); + ASSERT_TRUE(artifacts_unchanged); + ASSERT_EQ(endpoint_after_cleanup, 1); + PASS(); +} + +TEST(daemon_ipc_posix_partial_frame_timeout_poisons_connection) { + static const char key[] = "777788889999aaaa"; + static const uint8_t payload[] = {'n', 'e', 'w'}; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_listener_t *listener = NULL; + cbm_daemon_ipc_connection_t *server_connection = NULL; + int raw_client = -1; + uint8_t partial_header[CBM_DAEMON_FRAME_HEADER_SIZE] = {0}; + uint8_t full_header[CBM_DAEMON_FRAME_HEADER_SIZE] = {0}; + cbm_daemon_frame_t frame = {0}; + uint8_t *received_payload = NULL; + bool raw_connected = false; + bool partial_sent = false; + bool full_frame_sent = false; + int accept_result = -1; + int partial_receive_result = -1; + int reuse_result = 1; + + bool parent_ok = ipc_test_parent_new(parent, "partial-frame"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + listener = cbm_daemon_ipc_listen(endpoint); + } + if (listener) { + raw_client = socket(AF_UNIX, SOCK_STREAM, 0); + } + if (raw_client >= 0) { +#ifdef SO_NOSIGPIPE + int no_sigpipe = 1; + (void)setsockopt(raw_client, SOL_SOCKET, SO_NOSIGPIPE, &no_sigpipe, sizeof(no_sigpipe)); +#endif + struct sockaddr_un address; + socklen_t address_length = 0; + if (ipc_test_unix_address_set(&address, cbm_daemon_ipc_endpoint_address(endpoint), + &address_length)) { + int connect_result; + do { + connect_result = + connect(raw_client, (const struct sockaddr *)&address, address_length); + } while (connect_result != 0 && errno == EINTR); + raw_connected = connect_result == 0; + } + } + if (raw_connected) { + accept_result = cbm_daemon_ipc_accept(listener, 500, &server_connection); + } + if (accept_result == 1 && server_connection && + cbm_daemon_frame_header_encode(partial_header, CBM_DAEMON_FRAME_REQUEST, 0, 0)) { + partial_sent = + ipc_test_socket_send_all(raw_client, partial_header, CBM_DAEMON_FRAME_HEADER_SIZE / 2); + } + if (partial_sent) { + partial_receive_result = + cbm_daemon_ipc_receive_frame(server_connection, 50, &frame, &received_payload); + } + free(received_payload); + received_payload = NULL; + memset(&frame, 0, sizeof(frame)); + if (partial_receive_result == 0 && + cbm_daemon_frame_header_encode(full_header, CBM_DAEMON_FRAME_REQUEST, 0x55aa, + (uint32_t)sizeof(payload))) { + full_frame_sent = ipc_test_socket_send_all(raw_client, full_header, sizeof(full_header)) && + ipc_test_socket_send_all(raw_client, payload, sizeof(payload)); + } + if (full_frame_sent) { + reuse_result = + cbm_daemon_ipc_receive_frame(server_connection, 500, &frame, &received_payload); + } + + free(received_payload); + if (raw_client >= 0) { + (void)close(raw_client); + } + cbm_daemon_ipc_connection_close(server_connection); + cbm_daemon_ipc_listener_close(listener); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(raw_connected); + ASSERT_EQ(accept_result, 1); + ASSERT_TRUE(partial_sent); + ASSERT_EQ(partial_receive_result, 0); + ASSERT_TRUE(full_frame_sent); + ASSERT_EQ(reuse_result, -1); /* a timed-out partial frame permanently poisons the stream */ + PASS(); +} + +#ifdef __APPLE__ +TEST(daemon_ipc_macos_clears_inherited_mutating_acl_from_new_runtime) { + static const char key[] = "aa11bb22cc33dd44"; + char parent[TEST_PATH_CAP] = {0}; + char control[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + struct stat parent_status = {0}; + struct stat control_status = {0}; + struct stat runtime_status = {0}; + bool parent_ok = ipc_test_parent_new(parent, "mac-acl-inherit"); + int acl_fixture = + parent_ok ? ipc_test_macos_set_mutating_acl(parent, true) : -1; + if (acl_fixture == 0) { + ipc_test_remove_flat_dir(parent); + SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); + } + + bool parent_mode_stayed_private = + acl_fixture == 1 && lstat(parent, &parent_status) == 0 && + S_ISDIR(parent_status.st_mode) && (parent_status.st_mode & 0777) == 0700; + int control_written = + parent_mode_stayed_private + ? snprintf(control, sizeof(control), "%s/inheritance-control", parent) + : -1; + bool control_created = + control_written > 0 && control_written < (int)sizeof(control) && + mkdir(control, 0700) == 0; + bool control_mode_stayed_private = + control_created && lstat(control, &control_status) == 0 && + S_ISDIR(control_status.st_mode) && (control_status.st_mode & 0777) == 0700; + int control_acl_entries = + control_created ? ipc_test_macos_extended_acl_entry_count(control) : -1; + bool control_removed = !control_created || rmdir(control) == 0; + + /* The control child proves this filesystem actually propagated the ACL; + * CBM must actively strip it from its own dedicated child, not merely rely + * on mkdir(0700)/fchmod(0700). */ + if (control_removed && control_acl_entries > 0) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + bool runtime_mode_stayed_private = + runtime_dir[0] != '\0' && lstat(runtime_dir, &runtime_status) == 0 && + S_ISDIR(runtime_status.st_mode) && + (runtime_status.st_mode & 0777) == 0700; + int runtime_acl_entries = + runtime_dir[0] != '\0' + ? ipc_test_macos_extended_acl_entry_count(runtime_dir) + : -1; + + bool endpoint_created = endpoint != NULL; + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(acl_fixture, 1); + ASSERT_TRUE(parent_mode_stayed_private); + ASSERT_TRUE(control_created); + ASSERT_TRUE(control_mode_stayed_private); + ASSERT_GT(control_acl_entries, 0); + ASSERT_TRUE(control_removed); + ASSERT_TRUE(endpoint_created); + ASSERT_TRUE(runtime_mode_stayed_private); + ASSERT_EQ(runtime_acl_entries, 0); + PASS(); +} + +TEST(daemon_ipc_macos_repairs_or_rejects_existing_runtime_mutating_acl) { + static const char key[] = "ee55aa66bb77cc88"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *seed = NULL; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + struct stat runtime_status = {0}; + + bool parent_ok = ipc_test_parent_new(parent, "mac-acl-existing"); + if (parent_ok) { + seed = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (seed) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(seed)); + } + bool runtime_seeded = runtime_dir[0] != '\0'; + cbm_daemon_ipc_endpoint_free(seed); + seed = NULL; + + int acl_fixture = + runtime_seeded ? ipc_test_macos_set_mutating_acl(runtime_dir, false) : -1; + if (acl_fixture == 0) { + ipc_test_remove_tree(runtime_dir, parent); + SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); + } + bool unsafe_mode_stayed_private = + acl_fixture == 1 && lstat(runtime_dir, &runtime_status) == 0 && + S_ISDIR(runtime_status.st_mode) && + (runtime_status.st_mode & 0777) == 0700; + int acl_entries_before = + acl_fixture == 1 + ? ipc_test_macos_extended_acl_entry_count(runtime_dir) + : -1; + + if (acl_entries_before > 0) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + int startup_status = + endpoint + ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) + : -1; + int acl_entries_after = + ipc_test_macos_extended_acl_entry_count(runtime_dir); + + /* A constructor rejection or a lock-use rejection is fail-closed. If use + * succeeds, the dedicated directory must already have had its ACL cleared + * and revalidated. Mode 0700 alone is deliberately insufficient. */ + bool rejected_before_use = + endpoint == NULL || startup_status == -1; + bool repaired_before_use = + endpoint != NULL && startup_status == 1 && acl_entries_after == 0; + + cbm_daemon_ipc_startup_lock_release(&startup); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(runtime_seeded); + ASSERT_EQ(acl_fixture, 1); + ASSERT_TRUE(unsafe_mode_stayed_private); + ASSERT_GT(acl_entries_before, 0); + ASSERT_TRUE(rejected_before_use || repaired_before_use); + PASS(); +} + +TEST(daemon_ipc_macos_runtime_acl_injection_invalidates_existing_endpoint) { + static const char key[] = "aa77bb88cc99dd00"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + struct stat runtime_status = {0}; + + bool parent_ok = ipc_test_parent_new(parent, "mac-acl-injected"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int acl_fixture = + runtime_dir[0] != '\0' + ? ipc_test_macos_set_mutating_acl(runtime_dir, false) + : -1; + if (acl_fixture == 0) { + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); + } + bool mode_stayed_private = + acl_fixture == 1 && lstat(runtime_dir, &runtime_status) == 0 && + S_ISDIR(runtime_status.st_mode) && + (runtime_status.st_mode & 07777) == 0700; + int startup_status = + mode_stayed_private + ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) + : 0; + + cbm_daemon_ipc_startup_lock_release(&startup); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_TRUE(runtime_dir[0] != '\0'); + ASSERT_EQ(acl_fixture, 1); + ASSERT_TRUE(mode_stayed_private); + ASSERT_EQ(startup_status, -1); + ASSERT_NULL(startup); + PASS(); +} + +TEST(daemon_ipc_macos_lock_acl_injection_invalidates_retained_startup) { + static const char key[] = "ee11dd22cc33bb44"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char startup_path[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + struct stat startup_status = {0}; + + bool parent_ok = ipc_test_parent_new(parent, "mac-acl-lock"); + if (parent_ok) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + } + int startup_result = + endpoint + ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) + : -1; + int path_written = + startup_result == 1 + ? snprintf(startup_path, sizeof(startup_path), + "%s/cbm-%s.startup-v2.lock", runtime_dir, key) + : -1; + bool path_ok = path_written > 0 && path_written < (int)sizeof(startup_path); + int acl_fixture = + path_ok ? ipc_test_macos_set_mutating_acl(startup_path, false) : -1; + if (acl_fixture == 0) { + cbm_daemon_ipc_startup_lock_release(&startup); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); + } + bool mode_stayed_private = + acl_fixture == 1 && lstat(startup_path, &startup_status) == 0 && + S_ISREG(startup_status.st_mode) && + (startup_status.st_mode & 07777) == 0600; + bool prepared = mode_stayed_private && + cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + + cbm_daemon_ipc_startup_lock_release(&startup); + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(startup_result, 1); + ASSERT_TRUE(path_ok); + ASSERT_EQ(acl_fixture, 1); + ASSERT_TRUE(mode_stayed_private); + ASSERT_FALSE(prepared); + PASS(); +} +#endif + +TEST(daemon_ipc_posix_runtime_and_socket_are_owner_only) { + static const char key[] = "9999aaaabbbbcccc"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char socket_path[TEST_PATH_CAP] = {0}; + char anchor_path[TEST_PATH_CAP] = {0}; + char identity_path[TEST_PATH_CAP] = {0}; + char pending_path[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_listener_t *listener = NULL; + struct stat runtime_stat = {0}; + struct stat socket_stat = {0}; + struct stat anchor_stat = {0}; + struct stat identity_stat = {0}; + bool runtime_ok = false; + bool socket_ok = false; + bool anchor_ok = false; + bool identity_ok = false; + bool socket_removed = false; + bool anchor_removed = false; + bool pending_absent = false; + bool identity_removed = false; + + if (ipc_test_parent_new(parent, "permissions")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, cbm_daemon_ipc_endpoint_address(endpoint)); + (void)ipc_test_socket_anchor_path(anchor_path, runtime_dir, key); + (void)ipc_test_socket_identity_path(identity_path, socket_path); + (void)ipc_test_socket_pending_path(pending_path, socket_path); + runtime_ok = lstat(runtime_dir, &runtime_stat) == 0 && S_ISDIR(runtime_stat.st_mode) && + (runtime_stat.st_mode & 0777) == 0700 && runtime_stat.st_uid == geteuid(); + listener = cbm_daemon_ipc_listen(endpoint); + } + if (listener) { + socket_ok = lstat(socket_path, &socket_stat) == 0 && S_ISSOCK(socket_stat.st_mode) && + (socket_stat.st_mode & 0777) == 0600 && + socket_stat.st_uid == geteuid() && + socket_stat.st_nlink == 2; + anchor_ok = lstat(anchor_path, &anchor_stat) == 0 && + S_ISSOCK(anchor_stat.st_mode) && + (anchor_stat.st_mode & 0777) == 0600 && + anchor_stat.st_uid == geteuid() && + anchor_stat.st_nlink == 2 && + anchor_stat.st_dev == socket_stat.st_dev && + anchor_stat.st_ino == socket_stat.st_ino; + identity_ok = lstat(identity_path, &identity_stat) == 0 && + S_ISREG(identity_stat.st_mode) && + (identity_stat.st_mode & 0777) == 0600 && + identity_stat.st_uid == geteuid() && + identity_stat.st_nlink == 1; + errno = 0; + pending_absent = lstat(pending_path, &identity_stat) != 0 && + errno == ENOENT; + } + cbm_daemon_ipc_listener_close(listener); + if (socket_path[0] != '\0') { + errno = 0; + socket_removed = lstat(socket_path, &socket_stat) != 0 && errno == ENOENT; + } + if (identity_path[0] != '\0') { + errno = 0; + identity_removed = lstat(identity_path, &identity_stat) != 0 && + errno == ENOENT; + } + if (anchor_path[0] != '\0') { + errno = 0; + anchor_removed = lstat(anchor_path, &anchor_stat) != 0 && + errno == ENOENT; + } + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(runtime_ok); + ASSERT_TRUE(socket_ok); + ASSERT_TRUE(anchor_ok); + ASSERT_TRUE(identity_ok); + ASSERT_TRUE(pending_absent); + ASSERT_TRUE(socket_removed); + ASSERT_TRUE(anchor_removed); + ASSERT_TRUE(identity_removed); + PASS(); +} + +static bool ipc_test_write_byte(const char *path, unsigned char byte) { + FILE *file = fopen(path, "wb"); + if (!file) { + return false; + } + bool ok = fwrite(&byte, 1, 1, file) == 1; + return fclose(file) == 0 && ok; +} + +TEST(daemon_ipc_posix_private_log_creates_first_ever_cache_tree_safely) { + char parent[TEST_PATH_CAP] = {0}; + char first[TEST_PATH_CAP] = {0}; + char cache[TEST_PATH_CAP] = {0}; + char logs[TEST_PATH_CAP] = {0}; + char log_path[TEST_PATH_CAP] = {0}; + FILE *log = NULL; + struct stat status = {0}; + bool paths_ok = false; + bool absent_before = false; + bool opened = false; + bool tree_private = false; + bool file_private = false; + + if (ipc_test_parent_new(parent, "private-log-first-cache")) { + int first_written = snprintf(first, sizeof(first), "%s/first", parent); + int cache_written = snprintf(cache, sizeof(cache), "%s/cache", first); + int logs_written = snprintf(logs, sizeof(logs), "%s/logs", cache); + int log_written = + snprintf(log_path, sizeof(log_path), "%s/daemon.log", logs); + paths_ok = first_written > 0 && first_written < (int)sizeof(first) && + cache_written > 0 && cache_written < (int)sizeof(cache) && + logs_written > 0 && logs_written < (int)sizeof(logs) && + log_written > 0 && log_written < (int)sizeof(log_path); + } + if (paths_ok) { + errno = 0; + absent_before = lstat(first, &status) != 0 && errno == ENOENT; + log = cbm_daemon_ipc_private_log_open(logs, "daemon.log", 4096); + opened = log != NULL; + } + if (log) { + (void)fputs("first startup\n", log); + (void)fclose(log); + log = NULL; + } + if (opened) { + struct stat first_status; + struct stat cache_status; + struct stat logs_status; + tree_private = + lstat(first, &first_status) == 0 && + S_ISDIR(first_status.st_mode) && + (first_status.st_mode & 0777) == 0700 && + lstat(cache, &cache_status) == 0 && + S_ISDIR(cache_status.st_mode) && + (cache_status.st_mode & 0777) == 0700 && + lstat(logs, &logs_status) == 0 && + S_ISDIR(logs_status.st_mode) && + (logs_status.st_mode & 0777) == 0700; + file_private = lstat(log_path, &status) == 0 && + S_ISREG(status.st_mode) && status.st_uid == geteuid() && + (status.st_mode & 0777) == 0600; + } + + (void)unlink(log_path); + (void)rmdir(logs); + (void)rmdir(cache); + (void)rmdir(first); + ipc_test_remove_flat_dir(parent); + + ASSERT_TRUE(paths_ok); + ASSERT_TRUE(absent_before); + ASSERT_TRUE(opened); + ASSERT_TRUE(tree_private); + ASSERT_TRUE(file_private); + PASS(); +} + +TEST(daemon_ipc_posix_private_log_rejects_symlinks_and_is_owner_only) { + char parent[TEST_PATH_CAP] = {0}; + char logs[TEST_PATH_CAP] = {0}; + char log_path[TEST_PATH_CAP] = {0}; + char victim_dir[TEST_PATH_CAP] = {0}; + char victim_file[TEST_PATH_CAP] = {0}; + FILE *log = NULL; + struct stat status = {0}; + bool paths_ok = false; + bool directory_symlink_created = false; + bool directory_symlink_rejected = false; + bool secure_log_opened = false; + bool directory_owner_only = false; + bool file_owner_only = false; + bool file_symlink_created = false; + bool file_symlink_rejected = false; + bool victim_unchanged = false; + + if (ipc_test_parent_new(parent, "private-log")) { + int logs_written = snprintf(logs, sizeof(logs), "%s/logs", parent); + int log_written = + snprintf(log_path, sizeof(log_path), "%s/daemon.log", logs); + int victim_dir_written = + snprintf(victim_dir, sizeof(victim_dir), "%s/victim", parent); + int victim_file_written = snprintf(victim_file, sizeof(victim_file), + "%s/sentinel", parent); + paths_ok = logs_written > 0 && logs_written < (int)sizeof(logs) && + log_written > 0 && log_written < (int)sizeof(log_path) && + victim_dir_written > 0 && + victim_dir_written < (int)sizeof(victim_dir) && + victim_file_written > 0 && + victim_file_written < (int)sizeof(victim_file) && + mkdir(victim_dir, 0700) == 0 && + ipc_test_write_byte(victim_file, 0x4d); + } + if (paths_ok) { + directory_symlink_created = symlink(victim_dir, logs) == 0; + } + if (directory_symlink_created) { + log = cbm_daemon_ipc_private_log_open(logs, "daemon.log", 4096); + directory_symlink_rejected = log == NULL; + if (log) { + (void)fclose(log); + log = NULL; + } + (void)unlink(logs); + } + if (directory_symlink_rejected) { + log = cbm_daemon_ipc_private_log_open(logs, "daemon.log", 4096); + secure_log_opened = log != NULL; + if (log) { + (void)fputs("secure\n", log); + (void)fclose(log); + log = NULL; + } + directory_owner_only = + lstat(logs, &status) == 0 && S_ISDIR(status.st_mode) && + status.st_uid == geteuid() && (status.st_mode & 0777) == 0700; + file_owner_only = + lstat(log_path, &status) == 0 && S_ISREG(status.st_mode) && + status.st_uid == geteuid() && (status.st_mode & 0777) == 0600; + } + if (file_owner_only) { + (void)unlink(log_path); + file_symlink_created = symlink(victim_file, log_path) == 0; + } + if (file_symlink_created) { + log = cbm_daemon_ipc_private_log_open(logs, "daemon.log", 4096); + file_symlink_rejected = log == NULL; + if (log) { + (void)fclose(log); + log = NULL; + } + status.st_size = -1; + victim_unchanged = stat(victim_file, &status) == 0 && + S_ISREG(status.st_mode) && status.st_size == 1; + } + + (void)unlink(log_path); + (void)unlink(victim_file); + (void)rmdir(logs); + (void)rmdir(victim_dir); + ipc_test_remove_flat_dir(parent); + + ASSERT_TRUE(paths_ok); + ASSERT_TRUE(directory_symlink_created); + ASSERT_TRUE(directory_symlink_rejected); + ASSERT_TRUE(secure_log_opened); + ASSERT_TRUE(directory_owner_only); + ASSERT_TRUE(file_owner_only); + ASSERT_TRUE(file_symlink_created); + ASSERT_TRUE(file_symlink_rejected); + ASSERT_TRUE(victim_unchanged); + PASS(); +} + +TEST(daemon_ipc_posix_rejects_non_socket_and_symlink_endpoints) { + static const char key[] = "ddddeeeeffff0000"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + char socket_path[TEST_PATH_CAP] = {0}; + char sentinel_path[TEST_PATH_CAP] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = NULL; + cbm_daemon_ipc_listener_t *unexpected_listener = NULL; + struct stat st = {0}; + bool regular_created = false; + bool regular_rejected_unchanged = false; + bool sentinel_created = false; + bool symlink_created = false; + bool symlink_rejected_unchanged = false; + bool sentinel_unchanged = false; + + if (ipc_test_parent_new(parent, "stale-path")) { + endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + } + if (endpoint) { + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, cbm_daemon_ipc_endpoint_address(endpoint)); + (void)snprintf(sentinel_path, sizeof(sentinel_path), "%s/sentinel", parent); + regular_created = ipc_test_write_byte(socket_path, 0x42); + } + if (regular_created) { + unexpected_listener = cbm_daemon_ipc_listen(endpoint); + regular_rejected_unchanged = unexpected_listener == NULL && lstat(socket_path, &st) == 0 && + S_ISREG(st.st_mode) && st.st_size == 1; + cbm_daemon_ipc_listener_close(unexpected_listener); + unexpected_listener = NULL; + (void)unlink(socket_path); + sentinel_created = ipc_test_write_byte(sentinel_path, 0x7e); + } + if (sentinel_created) { + symlink_created = symlink(sentinel_path, socket_path) == 0; + } + if (symlink_created) { + unexpected_listener = cbm_daemon_ipc_listen(endpoint); + symlink_rejected_unchanged = + unexpected_listener == NULL && lstat(socket_path, &st) == 0 && S_ISLNK(st.st_mode); + sentinel_unchanged = + stat(sentinel_path, &st) == 0 && S_ISREG(st.st_mode) && st.st_size == 1; + cbm_daemon_ipc_listener_close(unexpected_listener); + } + + if (socket_path[0] != '\0') { + (void)unlink(socket_path); + } + if (sentinel_path[0] != '\0') { + (void)unlink(sentinel_path); + } + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_tree(runtime_dir, parent); + + ASSERT_TRUE(regular_created); + ASSERT_TRUE(regular_rejected_unchanged); + ASSERT_TRUE(sentinel_created); + ASSERT_TRUE(symlink_created); + ASSERT_TRUE(symlink_rejected_unchanged); + ASSERT_TRUE(sentinel_unchanged); + PASS(); +} + +#endif /* !_WIN32 */ + +SUITE(daemon_ipc) { + RUN_TEST(daemon_ipc_pending_timeout_race_returns_completed_io); + RUN_TEST(daemon_ipc_pending_wait_failure_cancels_and_drains); + RUN_TEST(daemon_ipc_pending_timeout_cancelled_returns_timeout); + RUN_TEST(daemon_ipc_windows_generation_address_binds_account_key_and_nonce); + RUN_TEST(daemon_ipc_windows_legacy_names_are_frozen_for_migration); + RUN_TEST(daemon_ipc_windows_rendezvous_record_is_exact_and_canonical); +#ifdef _WIN32 + RUN_TEST(daemon_ipc_windows_default_endpoint_ignores_temp_environment); + RUN_TEST(daemon_ipc_windows_legacy_bridge_covers_handoff_and_lifetime); + RUN_TEST(daemon_ipc_windows_local_transition_atomically_reserves_legacy_pipe); + RUN_TEST(daemon_ipc_windows_startup_retries_transient_rendezvous_reader); + RUN_TEST(daemon_ipc_windows_rendezvous_bridges_concurrent_lifetime_owner); + RUN_TEST(daemon_ipc_windows_generation_rotates_and_escapes_occupied_old_pipe); + RUN_TEST(daemon_ipc_windows_corrupt_rendezvous_fails_closed_until_startup_repairs); + RUN_TEST(daemon_ipc_windows_startup_and_lifetime_locks_are_cross_process); +#endif + RUN_TEST(daemon_ipc_endpoint_is_namespaced_by_instance_key); + RUN_TEST(daemon_ipc_relative_runtime_parent_is_canonical_and_stable); + RUN_TEST(daemon_ipc_rejects_uppercase_instance_key); + RUN_TEST(daemon_ipc_no_spawn_probe_distinguishes_absent_active_and_busy); + RUN_TEST(daemon_ipc_lifetime_reservation_survives_saturated_second_listen); + RUN_TEST(daemon_ipc_lifetime_reservation_transfers_without_unlock_window); + RUN_TEST(daemon_ipc_local_frame_roundtrip); + RUN_TEST(daemon_ipc_bounded_receive_rejects_oversize_before_payload); + RUN_TEST(daemon_ipc_wait_forever_is_interruptible); + RUN_TEST(daemon_ipc_connect_waits_for_delayed_listener); + RUN_TEST(daemon_ipc_startup_lock_has_one_winner); + RUN_TEST(daemon_ipc_activation_probe_ignores_matching_startup_claim_only); + RUN_TEST(daemon_ipc_local_transition_coexists_with_active_daemon_lifetime); + RUN_TEST(daemon_ipc_local_participants_overlap_and_allow_modern_startup); + RUN_TEST(daemon_ipc_windows_local_transition_release_retries_retained_mutex); + RUN_TEST(daemon_ipc_windows_startup_release_retains_retry_authority); + RUN_TEST(daemon_ipc_frame_timeout_poisons_connection); +#ifndef _WIN32 + RUN_TEST(daemon_ipc_posix_startup_lock_is_cross_process); + RUN_TEST(daemon_ipc_posix_lifetime_reservation_rejects_fork_inheritance); + RUN_TEST(daemon_ipc_posix_child_participant_handoff_retains_legacy_bridge); + RUN_TEST(daemon_ipc_posix_publication_boundaries_recover_from_crash); + RUN_TEST(daemon_ipc_posix_record_publication_windows_recover_from_crash); + RUN_TEST(daemon_ipc_posix_unknown_record_temp_pair_is_preserved); + RUN_TEST(daemon_ipc_posix_recovery_preserves_replaced_stable_socket); + RUN_TEST(daemon_ipc_posix_pending_without_anchor_never_deletes_stable); + RUN_TEST(daemon_ipc_posix_current_generation_crash_cleanup_requires_startup_lock); + RUN_TEST(daemon_ipc_posix_unknown_socket_without_identity_refuses_cleanup); + RUN_TEST(daemon_ipc_posix_active_listener_is_never_cleaned_under_queue_pressure); + RUN_TEST(daemon_ipc_posix_partial_frame_timeout_poisons_connection); +#ifdef __APPLE__ + RUN_TEST(daemon_ipc_macos_clears_inherited_mutating_acl_from_new_runtime); + RUN_TEST(daemon_ipc_macos_repairs_or_rejects_existing_runtime_mutating_acl); + RUN_TEST(daemon_ipc_macos_runtime_acl_injection_invalidates_existing_endpoint); + RUN_TEST(daemon_ipc_macos_lock_acl_injection_invalidates_retained_startup); +#endif + RUN_TEST(daemon_ipc_posix_runtime_and_socket_are_owner_only); + RUN_TEST(daemon_ipc_posix_private_log_creates_first_ever_cache_tree_safely); + RUN_TEST(daemon_ipc_posix_private_log_rejects_symlinks_and_is_owner_only); + RUN_TEST(daemon_ipc_posix_rejects_non_socket_and_symlink_endpoints); +#endif +} diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c new file mode 100644 index 000000000..eedd8e37d --- /dev/null +++ b/tests/test_daemon_runtime.c @@ -0,0 +1,4011 @@ +/* + * test_daemon_runtime.c — RED contract for the mandatory daemon runtime. + * + * These tests exercise a real in-process service over the authenticated IPC + * transport. A focused isolated host child also pins listener-before-background + * ordering; frontend bootstrap remains covered at its own layer. + */ +#include "test_framework.h" +#include "test_helpers.h" + +#include "daemon/host.h" +#include "daemon/host_internal.h" +#include "daemon/ipc.h" +#include "daemon/runtime.h" +#include "daemon/service.h" +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/compat_thread.h" +#include "foundation/log.h" +#include "foundation/platform.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include "foundation/win_utf8.h" +#include +#include +#else +#include +#include +#ifdef __APPLE__ +#include +#endif +#include +#include +#include +#include +#endif + +enum { + RUNTIME_TEST_PATH_CAP = 1024, + RUNTIME_TEST_LOG_CAP = 4096, + RUNTIME_TEST_TIMEOUT_MS = 2000, + /* Generation-zero rendezvous layout, deliberately repeated rather than + * derived from production macros so an accidental resize fails loudly. */ + RUNTIME_TEST_RENDEZVOUS_ABI = 1, + RUNTIME_TEST_RENDEZVOUS_REQUEST_SIZE = 133, + RUNTIME_TEST_RENDEZVOUS_RESPONSE_SIZE = 798, + RUNTIME_TEST_RENDEZVOUS_VERSION_OFFSET = 4, + RUNTIME_TEST_RENDEZVOUS_BUILD_OFFSET = 68, + RUNTIME_TEST_RENDEZVOUS_ACTIVE_VERSION_OFFSET = 28, + RUNTIME_TEST_RENDEZVOUS_ACTIVE_BUILD_OFFSET = 92, + RUNTIME_TEST_RENDEZVOUS_REQUESTED_VERSION_OFFSET = 157, + RUNTIME_TEST_RENDEZVOUS_REQUESTED_BUILD_OFFSET = 221, + RUNTIME_TEST_RENDEZVOUS_MESSAGE_OFFSET = 286, + /* Activation shutdown is a separate frozen first-frame envelope: + * u32 action followed byte-for-byte by the 133-byte identity above. + * Response: u32 ABI, u32 accepted, u64 clients, u64 connections. */ + RUNTIME_TEST_ACTIVATION_REQUEST_SIZE = 137, + RUNTIME_TEST_ACTIVATION_RESPONSE_SIZE = 24, + RUNTIME_TEST_ACTIVATION_IDENTITY_OFFSET = 4, + RUNTIME_TEST_ACTIVATION_RESPONSE_CLIENTS_OFFSET = 8, + RUNTIME_TEST_ACTIVATION_RESPONSE_CONNECTIONS_OFFSET = 16, +}; + +_Static_assert(CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN == 8 && + CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE == + RUNTIME_TEST_ACTIVATION_REQUEST_SIZE && + CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE == + RUNTIME_TEST_ACTIVATION_RESPONSE_SIZE, + "activation shutdown frozen wire contract changed"); + +static const char RUNTIME_BUILD_B[] = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +static char runtime_self_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; +static atomic_bool runtime_conflict_log_fallback_seen; +static atomic_bool runtime_activation_shutdown_log_seen; + +static void runtime_test_conflict_log_fallback_sink(const char *line) { + if (line && strstr(line, "daemon.conflict_log_append_failed")) { + atomic_store_explicit(&runtime_conflict_log_fallback_seen, true, + memory_order_release); + } +} + +static void runtime_test_activation_shutdown_sink(const char *line) { + if (line && strstr(line, "daemon.activation_shutdown") && + strstr(line, "requester_pid") && strstr(line, "requester_build") && + strstr(line, "action") && strstr(line, "active_clients") && + strstr(line, "active_connections")) { + atomic_store_explicit(&runtime_activation_shutdown_log_seen, true, + memory_order_release); + } +} + +typedef struct { + char parent[RUNTIME_TEST_PATH_CAP]; + char runtime_dir[RUNTIME_TEST_PATH_CAP]; + char key[CBM_DAEMON_KEY_SIZE]; + char log_path[RUNTIME_TEST_PATH_CAP]; + char rotated_log_path[RUNTIME_TEST_PATH_CAP]; + char lock_log_path[RUNTIME_TEST_PATH_CAP]; + cbm_daemon_ipc_endpoint_t *endpoint; + cbm_daemon_runtime_service_t *service; +} runtime_test_fixture_t; + +typedef struct { + atomic_int opened; + atomic_int requests; + atomic_int request_cancels; + atomic_int cancelled; + atomic_int closed; + atomic_bool block_first_request; + atomic_bool first_request_started; + atomic_bool ignore_first_request_cancel; + atomic_bool release_first_request; + atomic_bool block_second_open; + atomic_bool second_open_started; + atomic_bool release_second_open; +} runtime_application_context_t; + +typedef struct { + runtime_application_context_t *context; + atomic_bool cancel_requested; + cbm_daemon_client_id_t client_id; + uint64_t authenticated_process_id; +} runtime_application_session_t; + +typedef struct { + cbm_daemon_runtime_client_t *client; + const uint8_t *request; + uint32_t request_length; + cbm_daemon_runtime_application_token_t request_token; + bool tagged; + atomic_bool *completed; + cbm_daemon_runtime_application_status_t status; + uint8_t *response; + uint32_t response_length; +} runtime_application_client_call_t; + +typedef struct { + const cbm_daemon_ipc_endpoint_t *endpoint; + cbm_daemon_build_identity_t identity; + cbm_daemon_runtime_connect_result_t result; + cbm_daemon_runtime_client_t *client; + atomic_bool completed; +} runtime_application_connect_call_t; + +static cbm_daemon_build_identity_t runtime_test_identity(const char *version, + const char *build) { + cbm_daemon_build_identity_t identity = { + .semantic_version = version, + .build_fingerprint = build, + .protocol_abi = 3, + .store_abi = 11, + .feature_abi = 7, + }; + return identity; +} + +static uint64_t runtime_test_process_id(void) { +#ifdef _WIN32 + return (uint64_t)GetCurrentProcessId(); +#else + return (uint64_t)getpid(); +#endif +} + +static const char *runtime_test_self_build(void) { + if (runtime_self_build[0] == '\0') { + (void)cbm_daemon_runtime_process_build_fingerprint( + runtime_test_process_id(), runtime_self_build); + } + return runtime_self_build; +} + +static bool runtime_test_is_fingerprint(const char *value) { + if (!value || strlen(value) != CBM_DAEMON_BUILD_FINGERPRINT_SIZE - 1) { + return false; + } + for (size_t i = 0; i < CBM_DAEMON_BUILD_FINGERPRINT_SIZE - 1; i++) { + char ch = value[i]; + if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) { + return false; + } + } + return true; +} + +static bool runtime_test_copy_path(char out[RUNTIME_TEST_PATH_CAP], const char *path) { + if (!path) { + out[0] = '\0'; + return false; + } + int written = snprintf(out, RUNTIME_TEST_PATH_CAP, "%s", path); + return written >= 0 && written < RUNTIME_TEST_PATH_CAP; +} + +#ifdef _WIN32 + +enum { + RUNTIME_TEST_FILE_RENAME_INFO_EX = 22, + RUNTIME_TEST_FILE_RENAME_REPLACE_IF_EXISTS = 0x00000001, + RUNTIME_TEST_FILE_RENAME_POSIX_SEMANTICS = 0x00000002, +}; + +typedef struct { + DWORD flags; + HANDLE root_directory; + DWORD file_name_length; + wchar_t file_name[1]; +} runtime_test_windows_rename_info_t; + +static bool runtime_test_windows_copy_self(const char *destination) { + wchar_t source[32768]; + DWORD source_length = GetModuleFileNameW( + NULL, source, (DWORD)(sizeof(source) / sizeof(source[0]))); + wchar_t *destination_wide = cbm_utf8_to_wide(destination); + bool copied = source_length > 0 && + source_length < + (DWORD)(sizeof(source) / sizeof(source[0])) && + destination_wide && + CopyFileW(source, destination_wide, TRUE) != 0; + free(destination_wide); + return copied; +} + +static bool runtime_test_windows_posix_replace(const char *source, + const char *destination) { + wchar_t *source_wide = cbm_utf8_to_wide(source); + wchar_t *destination_wide = cbm_utf8_to_wide(destination); + size_t destination_length = + destination_wide ? wcslen(destination_wide) : 0; + size_t destination_bytes = destination_length * sizeof(wchar_t); + size_t information_size = + offsetof(runtime_test_windows_rename_info_t, file_name) + + destination_bytes + sizeof(wchar_t); + bool sizes_ok = destination_length > 0 && + destination_bytes <= MAXDWORD && + information_size <= MAXDWORD; + runtime_test_windows_rename_info_t *information = + sizes_ok ? calloc(1, information_size) : NULL; + if (information) { + information->flags = + RUNTIME_TEST_FILE_RENAME_REPLACE_IF_EXISTS | + RUNTIME_TEST_FILE_RENAME_POSIX_SEMANTICS; + information->file_name_length = (DWORD)destination_bytes; + memcpy(information->file_name, destination_wide, + destination_bytes + sizeof(wchar_t)); + } + HANDLE source_file = + source_wide && information + ? CreateFileW(source_wide, DELETE | SYNCHRONIZE, + FILE_SHARE_READ | FILE_SHARE_WRITE | + FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | + FILE_FLAG_OPEN_REPARSE_POINT, + NULL) + : INVALID_HANDLE_VALUE; + bool replaced = + source_file != INVALID_HANDLE_VALUE && + SetFileInformationByHandle( + source_file, + (FILE_INFO_BY_HANDLE_CLASS)RUNTIME_TEST_FILE_RENAME_INFO_EX, + information, (DWORD)information_size) != 0; + if (source_file != INVALID_HANDLE_VALUE && !CloseHandle(source_file)) { + replaced = false; + } + free(information); + free(destination_wide); + free(source_wide); + return replaced; +} + +static bool runtime_test_windows_spawn_image_holder( + const char *image_path, const char *ready_event, + PROCESS_INFORMATION *process_out) { + if (!image_path || !ready_event || !process_out) { + return false; + } + memset(process_out, 0, sizeof(*process_out)); + char command_line[RUNTIME_TEST_PATH_CAP * 2]; + int written = snprintf(command_line, sizeof(command_line), + "\"%s\" __cbm_runtime_image_holder \"%s\"", + image_path, ready_event); + wchar_t *application = cbm_utf8_to_wide(image_path); + wchar_t *command = written > 0 && written < (int)sizeof(command_line) + ? cbm_utf8_to_wide(command_line) + : NULL; + STARTUPINFOW startup; + memset(&startup, 0, sizeof(startup)); + startup.cb = sizeof(startup); + bool created = application && command && + CreateProcessW(application, command, NULL, NULL, FALSE, + CREATE_NO_WINDOW, NULL, NULL, &startup, + process_out) != 0; + free(command); + free(application); + if (created) { + (void)CloseHandle(process_out->hThread); + process_out->hThread = NULL; + } + return created; +} + +#endif + +#if defined(__APPLE__) || defined(__linux__) + +static bool runtime_test_copy_executable(const char *source, + const char *destination) { + int source_fd = open(source, O_RDONLY | O_CLOEXEC); + int destination_fd = source_fd >= 0 + ? open(destination, + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, + 0700) + : -1; + unsigned char buffer[64 * 1024]; + bool ok = source_fd >= 0 && destination_fd >= 0; + while (ok) { + ssize_t count = read(source_fd, buffer, sizeof(buffer)); + if (count == 0) { + break; + } + if (count < 0) { + if (errno == EINTR) { + continue; + } + ok = false; + break; + } + size_t written = 0; + while (written < (size_t)count) { + ssize_t chunk = write(destination_fd, buffer + written, + (size_t)count - written); + if (chunk > 0) { + written += (size_t)chunk; + } else if (chunk < 0 && errno == EINTR) { + continue; + } else { + ok = false; + break; + } + } + } + if (destination_fd >= 0 && fchmod(destination_fd, 0700) != 0) { + ok = false; + } + if (destination_fd >= 0 && close(destination_fd) != 0) { + ok = false; + } + if (source_fd >= 0 && close(source_fd) != 0) { + ok = false; + } + if (!ok) { + (void)unlink(destination); + } + return ok; +} + +static pid_t runtime_test_spawn_blocked_executable(const char *path, + int *release_fd_out) { + int ready[2] = {-1, -1}; + int input[2] = {-1, -1}; + if (!path || !release_fd_out || pipe(ready) != 0 || pipe(input) != 0) { + if (ready[0] >= 0) { + (void)close(ready[0]); + (void)close(ready[1]); + } + if (input[0] >= 0) { + (void)close(input[0]); + (void)close(input[1]); + } + return -1; + } + int ready_flags = fcntl(ready[1], F_GETFD); + if (ready_flags < 0 || + fcntl(ready[1], F_SETFD, ready_flags | FD_CLOEXEC) != 0) { + (void)close(ready[0]); + (void)close(ready[1]); + (void)close(input[0]); + (void)close(input[1]); + return -1; + } + pid_t child = fork(); + if (child == 0) { + (void)close(ready[0]); + (void)close(input[1]); + if (dup2(input[0], STDIN_FILENO) < 0) { + _exit(126); + } + if (input[0] != STDIN_FILENO) { + (void)close(input[0]); + } + execl(path, path, (char *)NULL); + const char failed = 'x'; + (void)write(ready[1], &failed, 1); + _exit(127); + } + (void)close(ready[1]); + (void)close(input[0]); + char unexpected = '\0'; + ssize_t ready_count; + do { + ready_count = read(ready[0], &unexpected, 1); + } while (ready_count < 0 && errno == EINTR); + (void)close(ready[0]); + int status = 0; + bool running = child > 0 && ready_count == 0 && + waitpid(child, &status, WNOHANG) == 0; + if (!running) { + (void)close(input[1]); + if (child > 0) { + (void)kill(child, SIGKILL); + while (waitpid(child, &status, 0) < 0 && errno == EINTR) { + } + } + return -1; + } + *release_fd_out = input[1]; + return child; +} + +static void runtime_test_stop_blocked_executable(pid_t child, int release_fd) { + if (release_fd >= 0) { + (void)close(release_fd); + } + if (child <= 0) { + return; + } + (void)kill(child, SIGTERM); + int status = 0; + while (waitpid(child, &status, 0) < 0 && errno == EINTR) { + } +} + +#endif + +#if defined(__APPLE__) || defined(__linux__) +static bool runtime_test_self_image_path( + char source[RUNTIME_TEST_PATH_CAP]) { +#ifdef __APPLE__ + int length = proc_pidpath(getpid(), source, RUNTIME_TEST_PATH_CAP); + bool resolved = length > 0 && length < RUNTIME_TEST_PATH_CAP; + if (resolved) { + source[length] = '\0'; + } +#else + ssize_t length = + readlink("/proc/self/exe", source, RUNTIME_TEST_PATH_CAP - 1); + bool resolved = length > 0 && + length < (ssize_t)RUNTIME_TEST_PATH_CAP - 1; + if (resolved) { + source[length] = '\0'; + } +#endif + return resolved; +} +#endif + +static bool runtime_test_copy_self_image(const char *destination) { +#ifdef _WIN32 + return runtime_test_windows_copy_self(destination); +#elif defined(__APPLE__) || defined(__linux__) + char source[RUNTIME_TEST_PATH_CAP]; + return runtime_test_self_image_path(source) && + runtime_test_copy_executable(source, destination); +#else + (void)destination; + return false; +#endif +} + +#if defined(_WIN32) || defined(__linux__) +static bool runtime_test_append_image_marker(const char *path) { + FILE *file = cbm_fopen(path, "ab"); + bool written = file && fputc('\n', file) != EOF; + if (file) { + written = fclose(file) == 0 && written; + } + return written; +} +#endif + +#ifdef __APPLE__ +static bool runtime_test_mac_ad_hoc_sign(const char *path) { + if (!path) { + return false; + } + pid_t child = fork(); + if (child == 0) { + execl("/usr/bin/codesign", "codesign", "--force", "--sign", "-", + "--timestamp=none", "--identifier", + "org.deusdata.cbm.foreign-test", path, (char *)NULL); + _exit(127); + } + int status = 0; + pid_t waited; + do { + waited = child > 0 ? waitpid(child, &status, 0) : -1; + } while (waited < 0 && errno == EINTR); + return waited == child && WIFEXITED(status) && WEXITSTATUS(status) == 0; +} +#endif + +static bool runtime_test_run_hello_image( + const char *image_path, const runtime_test_fixture_t *fixture, + const cbm_daemon_build_identity_t *identity, int *exit_code_out) { + if (!image_path || !fixture || !identity || !identity->semantic_version || + !identity->build_fingerprint || !exit_code_out) { + return false; + } + *exit_code_out = -1; +#ifdef _WIN32 + char command_line[RUNTIME_TEST_PATH_CAP * 3]; + int written = snprintf( + command_line, sizeof(command_line), + "\"%s\" __cbm_runtime_hello_client \"%s\" %s %s %s", image_path, + fixture->parent, fixture->key, identity->semantic_version, + identity->build_fingerprint); + wchar_t *application = cbm_utf8_to_wide(image_path); + wchar_t *command = written > 0 && written < (int)sizeof(command_line) + ? cbm_utf8_to_wide(command_line) + : NULL; + STARTUPINFOW startup; + PROCESS_INFORMATION process; + memset(&startup, 0, sizeof(startup)); + memset(&process, 0, sizeof(process)); + startup.cb = sizeof(startup); + bool started = application && command && + CreateProcessW(application, command, NULL, NULL, FALSE, + CREATE_NO_WINDOW, NULL, NULL, &startup, + &process) != 0; + free(command); + free(application); + bool waited = started && + WaitForSingleObject(process.hProcess, 10000) == WAIT_OBJECT_0; + DWORD exit_code = 0; + bool read = waited && GetExitCodeProcess(process.hProcess, &exit_code) != 0; + if (started) { + (void)CloseHandle(process.hThread); + (void)CloseHandle(process.hProcess); + } + if (read && exit_code <= INT_MAX) { + *exit_code_out = (int)exit_code; + } + return read && exit_code <= INT_MAX; +#elif defined(__APPLE__) || defined(__linux__) + pid_t child = fork(); + if (child == 0) { + execl(image_path, image_path, "__cbm_runtime_hello_client", + fixture->parent, fixture->key, identity->semantic_version, + identity->build_fingerprint, (char *)NULL); + _exit(127); + } + int status = 0; + pid_t waited; + do { + waited = child > 0 ? waitpid(child, &status, 0) : -1; + } while (waited < 0 && errno == EINTR); + if (waited != child || !WIFEXITED(status)) { + return false; + } + *exit_code_out = WEXITSTATUS(status); + return true; +#else + (void)image_path; + (void)fixture; + (void)identity; + return false; +#endif +} + +static bool runtime_test_run_activation_image( + const char *image_path, const runtime_test_fixture_t *fixture, + const cbm_daemon_build_identity_t *identity, + cbm_daemon_runtime_activation_action_t action, int *exit_code_out) { + if (!image_path || !fixture || !identity || !identity->semantic_version || + !identity->build_fingerprint || !exit_code_out) { + return false; + } + *exit_code_out = -1; +#ifdef _WIN32 + char command_line[RUNTIME_TEST_PATH_CAP * 3]; + int written = snprintf( + command_line, sizeof(command_line), + "\"%s\" __cbm_runtime_activation_client \"%s\" %s %s %s %u", + image_path, fixture->parent, fixture->key, + identity->semantic_version, identity->build_fingerprint, + (unsigned int)action); + wchar_t *application = cbm_utf8_to_wide(image_path); + wchar_t *command = written > 0 && written < (int)sizeof(command_line) + ? cbm_utf8_to_wide(command_line) + : NULL; + STARTUPINFOW startup; + PROCESS_INFORMATION process; + memset(&startup, 0, sizeof(startup)); + memset(&process, 0, sizeof(process)); + startup.cb = sizeof(startup); + bool started = application && command && + CreateProcessW(application, command, NULL, NULL, FALSE, + CREATE_NO_WINDOW, NULL, NULL, &startup, + &process) != 0; + free(command); + free(application); + bool waited = started && + WaitForSingleObject(process.hProcess, 10000) == + WAIT_OBJECT_0; + if (started && !waited) { + (void)TerminateProcess(process.hProcess, 30); + (void)WaitForSingleObject(process.hProcess, 5000); + } + DWORD exit_code = 0; + bool read = waited && + GetExitCodeProcess(process.hProcess, &exit_code) != 0; + if (started) { + (void)CloseHandle(process.hThread); + (void)CloseHandle(process.hProcess); + } + if (read && exit_code <= INT_MAX) { + *exit_code_out = (int)exit_code; + } + return read && exit_code <= INT_MAX; +#elif defined(__APPLE__) || defined(__linux__) + char action_text[16]; + int action_written = snprintf(action_text, sizeof(action_text), "%u", + (unsigned int)action); + pid_t child = action_written > 0 && action_written < (int)sizeof(action_text) + ? fork() + : -1; + if (child == 0) { + (void)alarm(10); + execl(image_path, image_path, "__cbm_runtime_activation_client", + fixture->parent, fixture->key, identity->semantic_version, + identity->build_fingerprint, action_text, (char *)NULL); + _exit(127); + } + int status = 0; + pid_t waited; + do { + waited = child > 0 ? waitpid(child, &status, 0) : -1; + } while (waited < 0 && errno == EINTR); + if (waited != child || !WIFEXITED(status)) { + return false; + } + *exit_code_out = WEXITSTATUS(status); + return true; +#else + (void)image_path; + (void)fixture; + (void)identity; + (void)action; + return false; +#endif +} + +#ifdef __APPLE__ +static bool runtime_test_run_mapped_hello_image( + const char *image_path, const char *mapped_image_path, + const runtime_test_fixture_t *fixture, + const cbm_daemon_build_identity_t *identity, int *exit_code_out) { + if (!image_path || !mapped_image_path || !fixture || !identity || + !identity->semantic_version || !identity->build_fingerprint || + !exit_code_out) { + return false; + } + *exit_code_out = -1; + pid_t child = fork(); + if (child == 0) { + execl(image_path, image_path, "__cbm_runtime_mapped_hello_client", + mapped_image_path, fixture->parent, fixture->key, + identity->semantic_version, identity->build_fingerprint, + (char *)NULL); + _exit(127); + } + int status = 0; + pid_t waited; + do { + waited = child > 0 ? waitpid(child, &status, 0) : -1; + } while (waited < 0 && errno == EINTR); + if (waited != child || !WIFEXITED(status)) { + return false; + } + *exit_code_out = WEXITSTATUS(status); + return true; +} +#endif + +static cbm_daemon_runtime_application_session_t *runtime_application_session_open( + void *opaque, cbm_daemon_client_id_t client_id, + uint64_t authenticated_process_id) { + runtime_application_context_t *context = opaque; + runtime_application_session_t *session = calloc(1, sizeof(*session)); + if (!context || !session || client_id == CBM_DAEMON_CLIENT_ID_INVALID || + authenticated_process_id == 0) { + free(session); + return NULL; + } + session->context = context; + session->client_id = client_id; + session->authenticated_process_id = authenticated_process_id; + atomic_init(&session->cancel_requested, false); + int open_index = atomic_fetch_add_explicit(&context->opened, 1, + memory_order_relaxed); + if (open_index == 1 && + atomic_load_explicit(&context->block_second_open, + memory_order_acquire)) { + atomic_store_explicit(&context->second_open_started, true, + memory_order_release); + while (!atomic_load_explicit(&context->release_second_open, + memory_order_acquire)) { + struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + (void)cbm_nanosleep(&pause, NULL); + } + } + return (cbm_daemon_runtime_application_session_t *)session; +} + +static cbm_daemon_runtime_application_status_t runtime_application_request( + void *opaque, cbm_daemon_runtime_application_session_t *opaque_session, + cbm_daemon_runtime_application_token_t request_token, + const uint8_t *request, uint32_t request_length, uint8_t **response_out, + uint32_t *response_length_out) { + (void)request_token; + runtime_application_context_t *context = opaque; + runtime_application_session_t *session = + (runtime_application_session_t *)opaque_session; + if (!context || !session || session->context != context || !response_out || + !response_length_out || (request_length > 0 && !request)) { + return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + *response_out = NULL; + *response_length_out = 0; + int request_index = + atomic_fetch_add_explicit(&context->requests, 1, memory_order_relaxed); + if (request_index == 0 && + atomic_load_explicit(&context->block_first_request, + memory_order_acquire)) { + atomic_store_explicit(&context->first_request_started, true, + memory_order_release); + bool ignore_cancel = atomic_load_explicit( + &context->ignore_first_request_cancel, memory_order_acquire); + while (!(ignore_cancel + ? atomic_load_explicit(&context->release_first_request, + memory_order_acquire) + : atomic_load_explicit(&session->cancel_requested, + memory_order_acquire))) { + struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + (void)cbm_nanosleep(&pause, NULL); + } + return CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED; + } + if (request_length == 0) { + return CBM_DAEMON_RUNTIME_APPLICATION_OK; + } + uint8_t *response = malloc(request_length); + if (!response) { + return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + memcpy(response, request, request_length); + *response_out = response; + *response_length_out = request_length; + return CBM_DAEMON_RUNTIME_APPLICATION_OK; +} + +static void *runtime_application_client_request_thread(void *opaque) { + runtime_application_client_call_t *call = opaque; + call->status = call->tagged + ? cbm_daemon_runtime_client_application_request_tagged( + call->client, call->request_token, call->request, + call->request_length, &call->response, + &call->response_length, + RUNTIME_TEST_TIMEOUT_MS) + : cbm_daemon_runtime_client_application_request( + call->client, call->request, + call->request_length, &call->response, + &call->response_length, + RUNTIME_TEST_TIMEOUT_MS); + if (call->completed) { + /* This must remain the helper's final access to call/client state. A + * failed OS join can then fall back to this lifetime sentinel without + * racing stack teardown or client cleanup. */ + atomic_store_explicit(call->completed, true, memory_order_release); + } + return NULL; +} + +static void *runtime_application_client_connect_thread(void *opaque) { + runtime_application_connect_call_t *call = opaque; + call->client = cbm_daemon_runtime_client_connect( + call->endpoint, &call->identity, RUNTIME_TEST_TIMEOUT_MS, + &call->result); + atomic_store_explicit(&call->completed, true, memory_order_release); + return NULL; +} + +static void runtime_application_request_cancel( + void *opaque, cbm_daemon_runtime_application_session_t *opaque_session, + cbm_daemon_runtime_application_token_t request_token) { + (void)request_token; + runtime_application_context_t *context = opaque; + runtime_application_session_t *session = + (runtime_application_session_t *)opaque_session; + if (!context || !session || session->context != context) { + return; + } + atomic_store_explicit(&session->cancel_requested, true, + memory_order_release); + (void)atomic_fetch_add_explicit(&context->request_cancels, 1, + memory_order_relaxed); +} + +static void runtime_application_session_cancel( + void *opaque, cbm_daemon_runtime_application_session_t *opaque_session) { + runtime_application_context_t *context = opaque; + runtime_application_session_t *session = + (runtime_application_session_t *)opaque_session; + if (!context || !session || session->context != context) { + return; + } + atomic_store_explicit(&session->cancel_requested, true, + memory_order_release); + (void)atomic_fetch_add_explicit(&context->cancelled, 1, + memory_order_relaxed); +} + +static void runtime_application_session_close( + void *opaque, cbm_daemon_runtime_application_session_t *opaque_session) { + runtime_application_context_t *context = opaque; + runtime_application_session_t *session = + (runtime_application_session_t *)opaque_session; + if (!context || !session || session->context != context) { + return; + } + (void)atomic_fetch_add_explicit(&context->closed, 1, + memory_order_relaxed); + free(session); +} + +static cbm_daemon_runtime_application_callbacks_t runtime_application_callbacks( + runtime_application_context_t *context) { + cbm_daemon_runtime_application_callbacks_t callbacks = { + .context = context, + .session_open = runtime_application_session_open, + .request = runtime_application_request, + .request_cancel = runtime_application_request_cancel, + .session_cancel = runtime_application_session_cancel, + .session_close = runtime_application_session_close, + }; + return callbacks; +} + +static void runtime_application_context_init(runtime_application_context_t *context, + bool block_first_request) { + memset(context, 0, sizeof(*context)); + atomic_init(&context->opened, 0); + atomic_init(&context->requests, 0); + atomic_init(&context->request_cancels, 0); + atomic_init(&context->cancelled, 0); + atomic_init(&context->closed, 0); + atomic_init(&context->block_first_request, block_first_request); + atomic_init(&context->first_request_started, false); + atomic_init(&context->ignore_first_request_cancel, false); + atomic_init(&context->release_first_request, false); + atomic_init(&context->block_second_open, false); + atomic_init(&context->second_open_started, false); + atomic_init(&context->release_second_open, false); +} + +static bool runtime_test_wait_atomic_bool(atomic_bool *value, + uint32_t timeout_ms) { + uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms; + while (!atomic_load_explicit(value, memory_order_acquire)) { + if (cbm_now_ms() >= deadline) { + return false; + } + struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + (void)cbm_nanosleep(&pause, NULL); + } + return true; +} + +static bool runtime_test_wait_atomic_int(atomic_int *value, int expected, + uint32_t timeout_ms) { + uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms; + while (atomic_load_explicit(value, memory_order_acquire) != expected) { + if (cbm_now_ms() >= deadline) { + return false; + } + struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + (void)cbm_nanosleep(&pause, NULL); + } + return true; +} + +static void runtime_test_put_u32(uint8_t out[4], uint32_t value) { + out[0] = (uint8_t)(value >> 24); + out[1] = (uint8_t)(value >> 16); + out[2] = (uint8_t)(value >> 8); + out[3] = (uint8_t)value; +} + +static uint32_t runtime_test_get_u32(const uint8_t in[4]) { + return ((uint32_t)in[0] << 24) | ((uint32_t)in[1] << 16) | + ((uint32_t)in[2] << 8) | (uint32_t)in[3]; +} + +static void runtime_test_put_u64(uint8_t out[8], uint64_t value) { + for (size_t index = 0; index < 8; index++) { + out[index] = (uint8_t)(value >> (56U - index * 8U)); + } +} + +static uint64_t runtime_test_get_u64(const uint8_t in[8]) { + uint64_t value = 0; + for (size_t index = 0; index < 8; index++) { + value = (value << 8U) | in[index]; + } + return value; +} + +static bool runtime_test_raw_activation_exchange( + const cbm_daemon_ipc_endpoint_t *endpoint, const uint8_t *request, + uint32_t request_length, + cbm_daemon_runtime_activation_result_t *result_out) { + if (result_out) { + memset(result_out, 0, sizeof(*result_out)); + } + if (!endpoint || !request || !result_out) { + return false; + } + cbm_daemon_ipc_connection_t *connection = + cbm_daemon_ipc_connect(endpoint, RUNTIME_TEST_TIMEOUT_MS); + bool sent = connection && cbm_daemon_ipc_send_frame( + connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN, + request, request_length); + cbm_daemon_frame_t frame = {0}; + uint8_t *response = NULL; + int received = sent ? cbm_daemon_ipc_receive_frame( + connection, RUNTIME_TEST_TIMEOUT_MS, &frame, + &response) + : 0; + uint32_t status = received == 1 && response + ? runtime_test_get_u32(response + 4) + : UINT32_MAX; + bool valid = received == 1 && + frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN && + frame.length == RUNTIME_TEST_ACTIVATION_RESPONSE_SIZE && + response && + runtime_test_get_u32(response) == + RUNTIME_TEST_RENDEZVOUS_ABI && + status <= 1U; + if (valid) { + result_out->accepted = status == 1U; + result_out->active_clients = runtime_test_get_u64( + response + RUNTIME_TEST_ACTIVATION_RESPONSE_CLIENTS_OFFSET); + result_out->active_connections = runtime_test_get_u64( + response + RUNTIME_TEST_ACTIVATION_RESPONSE_CONNECTIONS_OFFSET); + } + free(response); + cbm_daemon_ipc_connection_close(connection); + return valid; +} + +static bool runtime_test_activation_request_encode( + uint8_t out[RUNTIME_TEST_ACTIVATION_REQUEST_SIZE], + cbm_daemon_runtime_activation_action_t action, + const cbm_daemon_build_identity_t *identity) { + if (!out || !identity) { + return false; + } + memset(out, 0, RUNTIME_TEST_ACTIVATION_REQUEST_SIZE); + runtime_test_put_u32(out, (uint32_t)action); + return cbm_daemon_runtime_hello_request_encode( + out + RUNTIME_TEST_ACTIVATION_IDENTITY_OFFSET, identity); +} + +static bool runtime_test_fixed_string_equals(const uint8_t *wire, + size_t capacity, + const char *expected) { + if (!wire || !expected) { + return false; + } + size_t length = strlen(expected); + if (length >= capacity || memcmp(wire, expected, length) != 0 || + wire[length] != 0) { + return false; + } + for (size_t i = length + 1; i < capacity; i++) { + if (wire[i] != 0) { + return false; + } + } + return true; +} + +static cbm_daemon_ipc_connection_t *runtime_test_raw_client_connect( + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity) { + uint8_t hello[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE]; + if (!cbm_daemon_runtime_hello_request_encode(hello, identity)) { + return NULL; + } + cbm_daemon_ipc_connection_t *connection = + cbm_daemon_ipc_connect(endpoint, RUNTIME_TEST_TIMEOUT_MS); + if (!connection || + !cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_HELLO, hello, + (uint32_t)sizeof(hello))) { + cbm_daemon_ipc_connection_close(connection); + return NULL; + } + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + int received = cbm_daemon_ipc_receive_frame(connection, + RUNTIME_TEST_TIMEOUT_MS, + &frame, &payload); + bool accepted = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == CBM_DAEMON_RUNTIME_OP_HELLO && + frame.length > 0; + free(payload); + if (!accepted) { + cbm_daemon_ipc_connection_close(connection); + return NULL; + } + return connection; +} + +static bool runtime_test_raw_application_send_token( + cbm_daemon_ipc_connection_t *connection, uint64_t request_token, + const void *payload, uint32_t payload_length, uint32_t declared_length) { + uint64_t wire_length = 12ULL + payload_length; + if (!connection || request_token == 0 || wire_length > UINT32_MAX || + (payload_length > 0 && !payload)) { + return false; + } + uint8_t *wire = malloc((size_t)wire_length); + if (!wire) { + return false; + } + runtime_test_put_u64(wire, request_token); + runtime_test_put_u32(wire + 8, declared_length); + if (payload_length > 0) { + memcpy(wire + 12, payload, payload_length); + } + bool sent = cbm_daemon_ipc_send_frame( + connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST, wire, + (uint32_t)wire_length); + free(wire); + return sent; +} + +static bool runtime_test_raw_application_send( + cbm_daemon_ipc_connection_t *connection, const void *payload, + uint32_t payload_length, uint32_t declared_length) { + static atomic_uint_fast64_t next_token = ATOMIC_VAR_INIT(1); + uint64_t request_token = atomic_fetch_add_explicit( + &next_token, 1, memory_order_relaxed); + return runtime_test_raw_application_send_token( + connection, request_token, payload, payload_length, declared_length); +} + +static bool runtime_test_raw_application_cancel( + cbm_daemon_ipc_connection_t *connection, uint64_t request_token) { + uint8_t wire[8]; + if (!connection || request_token == 0) { + return false; + } + runtime_test_put_u64(wire, request_token); + return cbm_daemon_ipc_send_frame( + connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, wire, + (uint32_t)sizeof(wire)); +} + +static bool runtime_test_raw_application_receive_status_token( + cbm_daemon_ipc_connection_t *connection, + uint64_t expected_token, + cbm_daemon_runtime_application_status_t expected_status) { + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + int received = cbm_daemon_ipc_receive_frame(connection, + RUNTIME_TEST_TIMEOUT_MS, + &frame, &payload); + bool valid = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST && + frame.length >= 16 && payload && + runtime_test_get_u64(payload) == expected_token && + runtime_test_get_u32(payload + 8) == + (uint32_t)expected_status && + runtime_test_get_u32(payload + 12) == frame.length - 16; + free(payload); + return valid; +} + +static bool runtime_test_raw_application_receive_status( + cbm_daemon_ipc_connection_t *connection, + cbm_daemon_runtime_application_status_t expected_status) { + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + int received = cbm_daemon_ipc_receive_frame(connection, + RUNTIME_TEST_TIMEOUT_MS, + &frame, &payload); + bool valid = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST && + frame.length >= 16 && payload && + runtime_test_get_u64(payload) != 0 && + runtime_test_get_u32(payload + 8) == + (uint32_t)expected_status && + runtime_test_get_u32(payload + 12) == frame.length - 16; + free(payload); + return valid; +} + +static long long runtime_test_last_os_error(void) { +#ifdef _WIN32 + return (long long)GetLastError(); +#else + return (long long)errno; +#endif +} + +static bool runtime_test_fixture_start_failed(const char *tag, + const char *stage, + long long detail) { + printf(" runtime fixture startup failed: tag=%s stage=%s detail=%lld\n", + tag ? tag : "(null)", stage ? stage : "(null)", detail); + return false; +} + +static bool runtime_test_fixture_start_configured( + runtime_test_fixture_t *fixture, const char *tag, + const cbm_daemon_build_identity_t *identity, uint32_t max_clients, + uint64_t lease_timeout_ms, + const cbm_daemon_runtime_application_callbacks_t *application) { + memset(fixture, 0, sizeof(*fixture)); + int parent_written = snprintf(fixture->parent, sizeof(fixture->parent), + "%s/cbm-runtime-%s-XXXXXX", cbm_tmpdir(), tag); + if (parent_written <= 0 || parent_written >= (int)sizeof(fixture->parent)) { + return runtime_test_fixture_start_failed(tag, "temporary-path", + parent_written); + } + if (!cbm_mkdtemp(fixture->parent)) { + return runtime_test_fixture_start_failed( + tag, "temporary-directory", runtime_test_last_os_error()); + } + + if (!cbm_daemon_rendezvous_key(fixture->key)) { + return runtime_test_fixture_start_failed( + tag, "rendezvous-key", runtime_test_last_os_error()); + } + fixture->endpoint = + cbm_daemon_ipc_endpoint_new(fixture->key, fixture->parent); + if (!fixture->endpoint) { + return runtime_test_fixture_start_failed( + tag, "endpoint", runtime_test_last_os_error()); + } + if (!runtime_test_copy_path( + fixture->runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(fixture->endpoint))) { + return runtime_test_fixture_start_failed(tag, "runtime-path", 0); + } + + int log_written = snprintf(fixture->log_path, sizeof(fixture->log_path), "%s/conflicts.ndjson", + fixture->parent); + int rotated_written = snprintf(fixture->rotated_log_path, + sizeof(fixture->rotated_log_path), "%s.1", + fixture->log_path); + int lock_written = snprintf(fixture->lock_log_path, + sizeof(fixture->lock_log_path), "%s.lock", + fixture->log_path); + if (log_written <= 0 || log_written >= (int)sizeof(fixture->log_path)) { + return runtime_test_fixture_start_failed(tag, "conflict-log-path", + log_written); + } + if (rotated_written <= 0 || + rotated_written >= (int)sizeof(fixture->rotated_log_path)) { + return runtime_test_fixture_start_failed(tag, "rotated-log-path", + rotated_written); + } + if (lock_written <= 0 || + lock_written >= (int)sizeof(fixture->lock_log_path)) { + return runtime_test_fixture_start_failed(tag, "lock-log-path", + lock_written); + } + + cbm_daemon_runtime_service_config_t config = { + .endpoint = fixture->endpoint, + .identity = *identity, + .conflict_log_path = fixture->log_path, + .conflict_log_cap_bytes = 64U * 1024U, + .max_clients = max_clients, + .lease_timeout_ms = lease_timeout_ms, + .request_timeout_ms = RUNTIME_TEST_TIMEOUT_MS, + .shutdown_timeout_ms = RUNTIME_TEST_TIMEOUT_MS, + }; + if (application) { + config.application = *application; + } + fixture->service = cbm_daemon_runtime_service_start(&config); + if (!fixture->service) { + return runtime_test_fixture_start_failed( + tag, "service-start", runtime_test_last_os_error()); + } + cbm_daemon_runtime_service_state_t state = + cbm_daemon_runtime_service_state(fixture->service); + if (state != CBM_DAEMON_RUNTIME_SERVICE_RUNNING) { + return runtime_test_fixture_start_failed(tag, "service-state", + (long long)state); + } + return true; +} + +static bool runtime_test_fixture_start_limited(runtime_test_fixture_t *fixture, + const char *tag, + const cbm_daemon_build_identity_t *identity, + uint32_t max_clients) { + return runtime_test_fixture_start_configured(fixture, tag, identity, + max_clients, 5000, NULL); +} + +static bool runtime_test_fixture_start(runtime_test_fixture_t *fixture, const char *tag, + const cbm_daemon_build_identity_t *identity) { + return runtime_test_fixture_start_limited(fixture, tag, identity, 8); +} + +static void runtime_test_fixture_finish(runtime_test_fixture_t *fixture); + +/* The public convenience constructor participates in the migration guard for + * the service's complete lifetime. free must refuse a running service without + * consuming retry authority, then prove participant release after stop. */ +TEST(daemon_runtime_convenience_service_owns_participant_guard) { + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start(&fixture, "participant", &identity); + int active = started + ? cbm_daemon_ipc_legacy_generation_probe(fixture.endpoint) + : -1; + bool running_free_refused = + started && !cbm_daemon_runtime_service_free(fixture.service); + bool stopped = started && cbm_daemon_runtime_service_stop( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + bool freed = stopped && + cbm_daemon_runtime_service_free(fixture.service); + if (freed) { + fixture.service = NULL; + } + int released = freed + ? cbm_daemon_ipc_legacy_generation_probe(fixture.endpoint) + : -1; + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(active, 1); + ASSERT_TRUE(running_free_refused); + ASSERT_TRUE(stopped); + ASSERT_TRUE(freed); + ASSERT_EQ(released, 0); + PASS(); +} + +static bool runtime_test_fixture_start_application( + runtime_test_fixture_t *fixture, const char *tag, + const cbm_daemon_build_identity_t *identity, + runtime_application_context_t *context) { + cbm_daemon_runtime_application_callbacks_t application = + runtime_application_callbacks(context); + return runtime_test_fixture_start_configured(fixture, tag, identity, 8, + 5000, &application); +} + +static void runtime_test_fixture_finish(runtime_test_fixture_t *fixture) { + if (fixture->service) { + cbm_daemon_runtime_service_state_t state = + cbm_daemon_runtime_service_state(fixture->service); + if (state != CBM_DAEMON_RUNTIME_SERVICE_EXITED) { + (void)cbm_daemon_runtime_service_stop(fixture->service, + RUNTIME_TEST_TIMEOUT_MS); + } + (void)cbm_daemon_runtime_service_free(fixture->service); + } + cbm_daemon_ipc_endpoint_free(fixture->endpoint); + (void)cbm_unlink(fixture->rotated_log_path); + (void)cbm_unlink(fixture->log_path); + (void)cbm_unlink(fixture->lock_log_path); + (void)cbm_rmdir(fixture->runtime_dir); + (void)cbm_rmdir(fixture->parent); + memset(fixture, 0, sizeof(*fixture)); +} + +static bool runtime_test_read_log(const char *path, char out[RUNTIME_TEST_LOG_CAP]) { + FILE *file = cbm_fopen(path, "rb"); + if (!file) { + return false; + } + size_t used = fread(out, 1, RUNTIME_TEST_LOG_CAP - 1, file); + bool complete = !ferror(file) && feof(file); + out[used] = '\0'; + (void)fclose(file); + return complete; +} + +#ifndef _WIN32 +static int runtime_test_failed_host_child(const char *parent, + const char *key) { + char cache[RUNTIME_TEST_PATH_CAP]; + char log_path[RUNTIME_TEST_PATH_CAP]; + int cache_written = snprintf(cache, sizeof(cache), "%s/cache", parent); + int log_written = snprintf(log_path, sizeof(log_path), + "%s/logs/cbm-daemon.log", cache); + if (cache_written <= 0 || cache_written >= (int)sizeof(cache) || + log_written <= 0 || log_written >= (int)sizeof(log_path) || + cbm_setenv("CBM_CACHE_DIR", cache, 1) != 0) { + return 40; + } + + cbm_daemon_ipc_endpoint_t *endpoint = + cbm_daemon_ipc_endpoint_new(key, parent); + atomic_int stop_requested = ATOMIC_VAR_INIT(0); + cbm_daemon_build_identity_t mismatched = runtime_test_identity( + "host-order-test", RUNTIME_BUILD_B); + cbm_daemon_host_config_t config = { + .endpoint = endpoint, + .identity = mismatched, + .executable_path = "/host-order-test", + .stop_requested = &stop_requested, + }; + int run_result = endpoint ? cbm_daemon_host_run(&config) : 0; + + char log[RUNTIME_TEST_LOG_CAP] = {0}; + bool read = runtime_test_read_log(log_path, log); + bool failed_at_runtime = + read && strstr(log, "daemon.start_failed") != NULL && + strstr(log, "runtime") != NULL; + bool watcher_started = read && strstr(log, "watcher.start") != NULL; + cbm_daemon_ipc_endpoint_free(endpoint); + if (run_result != -1 || !failed_at_runtime) { + return 41; + } + return watcher_started ? 42 : 0; +} + +/* RED on the former host order: host_state_start launched the watcher before + * runtime fingerprint validation/listener reservation, so the isolated log + * always contained watcher.start even though no daemon could serve a client. */ +TEST(daemon_host_failed_listener_reservation_starts_no_background_work) { + char parent[RUNTIME_TEST_PATH_CAP]; + int written = snprintf(parent, sizeof(parent), + "%s/cbm-host-order-XXXXXX", cbm_tmpdir()); + bool parent_created = written > 0 && written < (int)sizeof(parent) && + cbm_mkdtemp(parent) != NULL; + char key[CBM_DAEMON_KEY_SIZE] = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = + parent_created && cbm_daemon_rendezvous_key(key) + ? cbm_daemon_ipc_endpoint_new(key, parent) + : NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + bool setup = endpoint && + cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) == 1 && + cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + pid_t child = setup ? fork() : -1; + if (child == 0) { + _exit(runtime_test_failed_host_child(parent, key)); + } + + int status = 0; + bool waited = child > 0 && waitpid(child, &status, 0) == child; + bool startup_released = + cbm_daemon_ipc_startup_lock_release(&startup); + cbm_daemon_ipc_endpoint_free(endpoint); + th_cleanup(parent_created ? parent : NULL); + + ASSERT_TRUE(setup); + ASSERT_TRUE(child > 0); + ASSERT_TRUE(waited); + ASSERT_TRUE(startup_released); + ASSERT_TRUE(WIFEXITED(status)); + ASSERT_EQ(WEXITSTATUS(status), 0); + PASS(); +} + +static _Noreturn void runtime_test_forced_host_shutdown_child( + const char *parent) { + char cache[RUNTIME_TEST_PATH_CAP]; + int written = snprintf(cache, sizeof(cache), "%s/cache", parent); + if (written <= 0 || written >= (int)sizeof(cache) || + cbm_setenv("CBM_CACHE_DIR", cache, 1) != 0) { + _exit(80); + } + (void)alarm(2); + cbm_daemon_host_force_terminate_for_test("noncooperative_callback"); +} + +static bool runtime_test_cleanup_release_never_succeeds(void *context) { + (void)context; + return false; +} + +static _Noreturn void runtime_test_persistent_cleanup_failure_child( + const char *parent) { + char cache[RUNTIME_TEST_PATH_CAP]; + int written = snprintf(cache, sizeof(cache), "%s/cache", parent); + if (written <= 0 || written >= (int)sizeof(cache) || + cbm_setenv("CBM_CACHE_DIR", cache, 1) != 0) { + _exit(81); + } + cbm_daemon_host_cleanup_release_until_complete_for_test( + runtime_test_cleanup_release_never_succeeds, NULL); + /* Continuing after an unreleased native coordination claim would let the + * daemon report a clean stop that never actually completed. */ + _exit(82); +} + +/* Native unlock/close failures are retryable, but a persistent OS error must + * not turn daemon shutdown into an immortal process. The parent polls with a + * hard deadline and always kills/reaps a regressed child so this test itself + * can fail without hanging the rest of the suite. */ +TEST(daemon_host_persistent_cleanup_release_failure_is_process_bounded) { + char parent[RUNTIME_TEST_PATH_CAP]; + char log_path[RUNTIME_TEST_PATH_CAP]; + int parent_written = snprintf(parent, sizeof(parent), + "%s/cbm-host-cleanup-XXXXXX", cbm_tmpdir()); + bool parent_created = parent_written > 0 && + parent_written < (int)sizeof(parent) && + cbm_mkdtemp(parent) != NULL; + int log_written = parent_created + ? snprintf(log_path, sizeof(log_path), + "%s/cache/logs/cbm-daemon.log", parent) + : -1; + bool path_ok = log_written > 0 && log_written < (int)sizeof(log_path); + pid_t child = path_ok ? fork() : -1; + if (child == 0) { + runtime_test_persistent_cleanup_failure_child(parent); + } + + int status = 0; + bool completed = false; + uint64_t deadline = cbm_now_ms() + RUNTIME_TEST_TIMEOUT_MS; + while (child > 0 && cbm_now_ms() < deadline) { + pid_t waited = waitpid(child, &status, WNOHANG); + if (waited == child) { + completed = true; + break; + } + if (waited < 0 && errno != EINTR) { + break; + } + cbm_usleep(1000); + } + if (child > 0 && !completed) { + (void)kill(child, SIGKILL); + while (waitpid(child, &status, 0) < 0 && errno == EINTR) { + } + } + + char log[RUNTIME_TEST_LOG_CAP] = {0}; + bool durable = completed && runtime_test_read_log(log_path, log) && + strstr(log, "daemon.forced_shutdown") != NULL && + strstr(log, "coordination_cleanup") != NULL; + th_cleanup(parent_created ? parent : NULL); + + ASSERT_TRUE(parent_created); + ASSERT_TRUE(path_ok); + ASSERT_TRUE(child > 0); + ASSERT_TRUE(completed); + ASSERT_TRUE(WIFEXITED(status)); + ASSERT_EQ(WEXITSTATUS(status), EXIT_FAILURE); + ASSERT_TRUE(durable); + PASS(); +} + +TEST(daemon_host_forced_shutdown_is_logged_flushed_and_process_bounded) { + char parent[RUNTIME_TEST_PATH_CAP]; + char log_path[RUNTIME_TEST_PATH_CAP]; + int parent_written = snprintf(parent, sizeof(parent), + "%s/cbm-host-force-XXXXXX", cbm_tmpdir()); + bool parent_created = parent_written > 0 && + parent_written < (int)sizeof(parent) && + cbm_mkdtemp(parent) != NULL; + int log_written = parent_created + ? snprintf(log_path, sizeof(log_path), + "%s/cache/logs/cbm-daemon.log", parent) + : -1; + bool path_ok = log_written > 0 && log_written < (int)sizeof(log_path); + pid_t child = path_ok ? fork() : -1; + if (child == 0) { + runtime_test_forced_host_shutdown_child(parent); + } + + int status = 0; + bool waited = child > 0 && waitpid(child, &status, 0) == child; + char log[RUNTIME_TEST_LOG_CAP] = {0}; + bool durable = waited && runtime_test_read_log(log_path, log) && + strstr(log, "daemon.forced_shutdown") != NULL && + strstr(log, "noncooperative_callback") != NULL; + th_cleanup(parent_created ? parent : NULL); + + ASSERT_TRUE(parent_created); + ASSERT_TRUE(path_ok); + ASSERT_TRUE(child > 0); + ASSERT_TRUE(waited); + ASSERT_TRUE(WIFEXITED(status)); + ASSERT_EQ(WEXITSTATUS(status), EXIT_FAILURE); + ASSERT_TRUE(durable); + PASS(); +} +#endif + +TEST(daemon_runtime_exact_hello_issues_connection_bound_identity) { + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start(&fixture, "exact-hello", &identity); + cbm_daemon_runtime_connect_result_t result = {0}; + cbm_daemon_runtime_client_t *client = NULL; + bool accepted = false; + bool identity_anchored = false; + bool closed = false; + bool exited = false; + + if (started) { + /* Service and client are the same process image here, exercising the + * native-identity HELLO path rather than copied-image fallback. */ + client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &result); + } + if (client) { + uint64_t expected_pid = runtime_test_process_id(); + accepted = result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED && + result.hello_status == CBM_DAEMON_HELLO_COMPATIBLE && + result.client_id != CBM_DAEMON_CLIENT_ID_INVALID && + result.client_id == cbm_daemon_runtime_client_id(client); + identity_anchored = + result.authenticated_process_id == expected_pid && + cbm_daemon_runtime_client_process_id(client) == expected_pid && + cbm_daemon_runtime_service_client_process_id(fixture.service, result.client_id) == + expected_pid; + closed = cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); + client = NULL; + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, + RUNTIME_TEST_TIMEOUT_MS); + } + + if (client) { + (void)cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(accepted); + ASSERT_TRUE(identity_anchored); + ASSERT_TRUE(closed); + ASSERT_TRUE(exited); + PASS(); +} + +TEST(daemon_runtime_activation_rejects_forged_and_malformed_without_stop) { + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + cbm_daemon_build_identity_t forged = + runtime_test_identity("9.9.9", RUNTIME_BUILD_B); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start( + &fixture, "activation-reject", &identity); + cbm_daemon_runtime_connect_result_t owner_result = {0}; + cbm_daemon_runtime_client_t *owner = NULL; + uint8_t request[RUNTIME_TEST_ACTIVATION_REQUEST_SIZE]; + bool encoded = runtime_test_activation_request_encode( + request, CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE, &identity); + cbm_daemon_runtime_activation_result_t short_result = {0}; + cbm_daemon_runtime_activation_result_t action_result = {0}; + cbm_daemon_runtime_activation_result_t abi_result = {0}; + cbm_daemon_runtime_activation_result_t forged_result = {0}; + bool short_rejected = false; + bool action_rejected = false; + bool abi_rejected = false; + bool forged_rejected = false; + bool unchanged = false; + bool heartbeat = false; + bool closed = false; + bool exited = false; + + if (started) { + owner = cbm_daemon_runtime_client_connect( + fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, + &owner_result); + } + if (owner && encoded) { + short_rejected = runtime_test_raw_activation_exchange( + fixture.endpoint, request, + RUNTIME_TEST_ACTIVATION_REQUEST_SIZE - 1U, + &short_result) && + !short_result.accepted; + runtime_test_put_u32(request, UINT32_C(99)); + action_rejected = runtime_test_raw_activation_exchange( + fixture.endpoint, request, + RUNTIME_TEST_ACTIVATION_REQUEST_SIZE, + &action_result) && + !action_result.accepted; + runtime_test_put_u32( + request, (uint32_t)CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE); + runtime_test_put_u32( + request + RUNTIME_TEST_ACTIVATION_IDENTITY_OFFSET, + RUNTIME_TEST_RENDEZVOUS_ABI + 1U); + abi_rejected = runtime_test_raw_activation_exchange( + fixture.endpoint, request, + RUNTIME_TEST_ACTIVATION_REQUEST_SIZE, + &abi_result) && + !abi_result.accepted; + forged_rejected = + cbm_daemon_runtime_request_activation_shutdown( + fixture.endpoint, &forged, + CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL, + RUNTIME_TEST_TIMEOUT_MS, &forged_result) && + !forged_result.accepted; + unchanged = + cbm_daemon_runtime_service_state(fixture.service) == + CBM_DAEMON_RUNTIME_SERVICE_RUNNING && + cbm_daemon_runtime_service_active_clients(fixture.service) == 1 && + cbm_daemon_runtime_service_wait_for_connections( + fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); + heartbeat = cbm_daemon_runtime_client_heartbeat( + owner, RUNTIME_TEST_TIMEOUT_MS); + } + if (owner) { + closed = cbm_daemon_runtime_client_close( + owner, RUNTIME_TEST_TIMEOUT_MS); + owner = NULL; + } + if (started) { + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(owner_result.status, CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED); + ASSERT_TRUE(encoded); + ASSERT_TRUE(short_rejected); + ASSERT_TRUE(action_rejected); + ASSERT_TRUE(abi_rejected); + ASSERT_TRUE(forged_rejected); + ASSERT_TRUE(unchanged); + ASSERT_TRUE(heartbeat); + ASSERT_TRUE(closed); + ASSERT_TRUE(exited); + PASS(); +} + +TEST(daemon_runtime_activation_ack_snapshots_then_interrupts_all_clients) { + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start( + &fixture, "activation-drain", &identity); + cbm_daemon_runtime_connect_result_t first_result = {0}; + cbm_daemon_runtime_connect_result_t second_result = {0}; + cbm_daemon_runtime_client_t *first = NULL; + cbm_daemon_runtime_client_t *second = NULL; + cbm_daemon_runtime_activation_result_t activation = {0}; + bool requested = false; + bool first_interrupted = false; + bool second_interrupted = false; + bool exited = false; + atomic_store_explicit(&runtime_activation_shutdown_log_seen, false, + memory_order_release); + cbm_log_set_sink(runtime_test_activation_shutdown_sink); + + if (started) { + first = cbm_daemon_runtime_client_connect( + fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, + &first_result); + second = cbm_daemon_runtime_client_connect( + fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, + &second_result); + } + if (first && second) { + requested = cbm_daemon_runtime_request_activation_shutdown( + fixture.endpoint, &identity, + CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE, + RUNTIME_TEST_TIMEOUT_MS, &activation); + first_interrupted = !cbm_daemon_runtime_client_heartbeat( + first, RUNTIME_TEST_TIMEOUT_MS); + second_interrupted = !cbm_daemon_runtime_client_heartbeat( + second, RUNTIME_TEST_TIMEOUT_MS); + } + cbm_log_set_sink(NULL); + if (first) { + (void)cbm_daemon_runtime_client_close(first, + RUNTIME_TEST_TIMEOUT_MS); + first = NULL; + } + if (second) { + (void)cbm_daemon_runtime_client_close(second, + RUNTIME_TEST_TIMEOUT_MS); + second = NULL; + } + if (started) { + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(first_result.status, CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED); + ASSERT_EQ(second_result.status, CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED); + ASSERT_TRUE(requested); + ASSERT_TRUE(activation.accepted); + ASSERT_EQ(activation.active_clients, 2); + /* The one-shot activation requester is not part of the drain snapshot. */ + ASSERT_EQ(activation.active_connections, 2); + ASSERT_TRUE(atomic_load_explicit(&runtime_activation_shutdown_log_seen, + memory_order_acquire)); + ASSERT_TRUE(first_interrupted); + ASSERT_TRUE(second_interrupted); + ASSERT_TRUE(exited); + PASS(); +} + +#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) +TEST(daemon_runtime_activation_accepts_authenticated_different_build) { + cbm_daemon_build_identity_t active_identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + char directory[RUNTIME_TEST_PATH_CAP] = {0}; + char foreign_image[RUNTIME_TEST_PATH_CAP] = {0}; + int directory_written = snprintf( + directory, sizeof(directory), "%s/cbm-runtime-activation-XXXXXX", + cbm_tmpdir()); + bool directory_created = directory_written > 0 && + directory_written < (int)sizeof(directory) && + cbm_mkdtemp(directory) != NULL; + int image_written = directory_created + ? snprintf(foreign_image, sizeof(foreign_image), + "%s/foreign-activation", directory) + : -1; + bool copied = image_written > 0 && + image_written < (int)sizeof(foreign_image) && + runtime_test_copy_self_image(foreign_image); +#ifdef __APPLE__ + /* Appending an overlay invalidates Mach-O strict validation. A distinct + * signing identifier changes the executable bytes while keeping the copy + * runnable, exactly like the mapped-main-image adversarial fixture. */ + bool changed = copied; + bool runnable = changed && runtime_test_mac_ad_hoc_sign(foreign_image); +#else + bool changed = copied && runtime_test_append_image_marker(foreign_image); + bool runnable = changed; +#endif + char foreign_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + bool fingerprinted = + runnable && cbm_daemon_build_fingerprint_file(foreign_image, + foreign_build); + bool differs = fingerprinted && + strcmp(foreign_build, + active_identity.build_fingerprint) != 0; + cbm_daemon_build_identity_t foreign_identity = + runtime_test_identity("9.9.9", foreign_build); + + /* Finalize the foreign executable before the runtime starts any worker + * threads. In particular, macOS code signing is an external process; the + * test must not fork that helper from the live multithreaded daemon. The + * immutable signed copy remains the actual peer image authenticated by + * the activation request below. */ + runtime_test_fixture_t fixture; + memset(&fixture, 0, sizeof(fixture)); + bool started = differs && runtime_test_fixture_start( + &fixture, "activation-foreign", + &active_identity); + cbm_daemon_runtime_connect_result_t owner_result = {0}; + cbm_daemon_runtime_client_t *owner = NULL; + if (started) { + owner = cbm_daemon_runtime_client_connect( + fixture.endpoint, &active_identity, RUNTIME_TEST_TIMEOUT_MS, + &owner_result); + } + int foreign_exit = -1; + bool foreign_ran = owner && differs && + runtime_test_run_activation_image( + foreign_image, &fixture, &foreign_identity, + CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL, + &foreign_exit); + bool owner_interrupted = owner && foreign_ran && + !cbm_daemon_runtime_client_heartbeat( + owner, RUNTIME_TEST_TIMEOUT_MS); + if (owner) { + (void)cbm_daemon_runtime_client_close(owner, + RUNTIME_TEST_TIMEOUT_MS); + owner = NULL; + } + bool exited = started && cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + runtime_test_fixture_finish(&fixture); + if (image_written > 0 && image_written < (int)sizeof(foreign_image)) { + (void)cbm_unlink(foreign_image); + } + if (directory_created) { + (void)cbm_rmdir(directory); + } + + ASSERT_TRUE(started); + ASSERT_EQ(owner_result.status, CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED); + ASSERT_TRUE(directory_created); + ASSERT_TRUE(copied); + ASSERT_TRUE(changed); + ASSERT_TRUE(runnable); + ASSERT_TRUE(fingerprinted); + ASSERT_TRUE(differs); + ASSERT_TRUE(foreign_ran); + ASSERT_EQ(foreign_exit, 0); + ASSERT_TRUE(owner_interrupted); + ASSERT_TRUE(exited); + PASS(); +} +#endif + +TEST(daemon_runtime_rendezvous_layout_is_frozen_and_detailed_abi_independent) { + cbm_daemon_build_identity_t first = + runtime_test_identity("2.4.0", runtime_test_self_build()); + cbm_daemon_build_identity_t different_detail = first; + different_detail.protocol_abi = 0; + different_detail.store_abi = 0; + different_detail.feature_abi = 0; + + uint8_t first_wire[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE]; + uint8_t second_wire[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE]; + bool first_encoded = + cbm_daemon_runtime_hello_request_encode(first_wire, &first); + bool second_encoded = cbm_daemon_runtime_hello_request_encode( + second_wire, &different_detail); + + ASSERT_TRUE(first_encoded); + ASSERT_TRUE(second_encoded); + ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE, + RUNTIME_TEST_RENDEZVOUS_REQUEST_SIZE); + ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE, + RUNTIME_TEST_RENDEZVOUS_RESPONSE_SIZE); + ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + CBM_DAEMON_VERSION_TEXT_SIZE); + ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + CBM_DAEMON_BUILD_FINGERPRINT_SIZE); + ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP, + CBM_DAEMON_CONFLICT_MESSAGE_SIZE); + ASSERT_EQ(runtime_test_get_u32(first_wire), + RUNTIME_TEST_RENDEZVOUS_ABI); + ASSERT_TRUE(runtime_test_fixed_string_equals( + first_wire + RUNTIME_TEST_RENDEZVOUS_VERSION_OFFSET, + CBM_DAEMON_VERSION_TEXT_SIZE, first.semantic_version)); + ASSERT_TRUE(runtime_test_fixed_string_equals( + first_wire + RUNTIME_TEST_RENDEZVOUS_BUILD_OFFSET, + CBM_DAEMON_BUILD_FINGERPRINT_SIZE, first.build_fingerprint)); + ASSERT_EQ(memcmp(first_wire, second_wire, sizeof(first_wire)), 0); + PASS(); +} + +TEST(daemon_runtime_future_generation_gets_stable_explicit_conflict) { + const char *active_build = runtime_test_self_build(); + cbm_daemon_build_identity_t active = + runtime_test_identity("2.4.0", active_build); + cbm_daemon_build_identity_t future = + runtime_test_identity("9.0.0-future-wire-v2", RUNTIME_BUILD_B); + future.protocol_abi = active.protocol_abi + 1000; + future.store_abi = active.store_abi + 2000; + future.feature_abi = active.feature_abi + 3000; + + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start( + &fixture, "future-generation", &active); + cbm_daemon_runtime_connect_result_t owner_result = {0}; + cbm_daemon_runtime_client_t *owner = NULL; + cbm_daemon_ipc_connection_t *future_connection = NULL; + uint8_t future_wire[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE]; + bool encoded = false; + bool sent = false; + bool explicit_conflict = false; + bool logged = false; + bool exited = false; + cbm_daemon_frame_t response_frame = {0}; + uint8_t *response_payload = NULL; + char log[RUNTIME_TEST_LOG_CAP] = {0}; + char expected_message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE] = {0}; + int expected_length = snprintf( + expected_message, sizeof(expected_message), + "CBM could not start because a conflicting CBM process is active " + "(version; active version %s, build %s; requested version %s, build %s). " + "Close all CBM sessions and commands, then retry.", + active.semantic_version, active_build, future.semantic_version, + future.build_fingerprint); + bool expected_message_valid = + expected_length > 0 && (size_t)expected_length < sizeof(expected_message); + + if (started) { + owner = cbm_daemon_runtime_client_connect( + fixture.endpoint, &active, RUNTIME_TEST_TIMEOUT_MS, &owner_result); + } + if (owner) { + encoded = cbm_daemon_runtime_hello_request_encode(future_wire, &future); + future_connection = cbm_daemon_ipc_connect( + fixture.endpoint, RUNTIME_TEST_TIMEOUT_MS); + } + if (future_connection && encoded) { + /* A future generation sends only the permanent identity envelope here; + * its detailed runtime layout is negotiated by exact executable build, + * never added to this stable endpoint message. */ + sent = cbm_daemon_ipc_send_frame( + future_connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_HELLO, future_wire, + RUNTIME_TEST_RENDEZVOUS_REQUEST_SIZE); + int received = cbm_daemon_ipc_receive_frame( + future_connection, RUNTIME_TEST_TIMEOUT_MS, &response_frame, + &response_payload); + explicit_conflict = + expected_message_valid && received == 1 && response_payload && + response_frame.type == CBM_DAEMON_FRAME_RESPONSE && + response_frame.flags == CBM_DAEMON_RUNTIME_OP_HELLO && + response_frame.length == RUNTIME_TEST_RENDEZVOUS_RESPONSE_SIZE && + runtime_test_get_u32(response_payload) == + CBM_DAEMON_RUNTIME_CONNECT_CONFLICT && + runtime_test_get_u32(response_payload + 4) == + CBM_DAEMON_HELLO_VERSION_CONFLICT && + runtime_test_fixed_string_equals( + response_payload + RUNTIME_TEST_RENDEZVOUS_ACTIVE_VERSION_OFFSET, + CBM_DAEMON_VERSION_TEXT_SIZE, active.semantic_version) && + runtime_test_fixed_string_equals( + response_payload + RUNTIME_TEST_RENDEZVOUS_ACTIVE_BUILD_OFFSET, + CBM_DAEMON_BUILD_FINGERPRINT_SIZE, active_build) && + runtime_test_fixed_string_equals( + response_payload + RUNTIME_TEST_RENDEZVOUS_REQUESTED_VERSION_OFFSET, + CBM_DAEMON_VERSION_TEXT_SIZE, future.semantic_version) && + runtime_test_fixed_string_equals( + response_payload + RUNTIME_TEST_RENDEZVOUS_REQUESTED_BUILD_OFFSET, + CBM_DAEMON_BUILD_FINGERPRINT_SIZE, + future.build_fingerprint) && + runtime_test_fixed_string_equals( + response_payload + RUNTIME_TEST_RENDEZVOUS_MESSAGE_OFFSET, + CBM_DAEMON_CONFLICT_MESSAGE_SIZE, expected_message); + } + cbm_daemon_ipc_connection_close(future_connection); + future_connection = NULL; + free(response_payload); + response_payload = NULL; + + if (owner) { + logged = runtime_test_read_log(fixture.log_path, log) && + strstr(log, "\"reason\":\"version\"") != NULL && + strstr(log, active_build) != NULL && + strstr(log, RUNTIME_BUILD_B) != NULL; + (void)cbm_daemon_runtime_client_close(owner, + RUNTIME_TEST_TIMEOUT_MS); + owner = NULL; + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + + cbm_daemon_ipc_connection_close(future_connection); + free(response_payload); + if (owner) { + (void)cbm_daemon_runtime_client_close(owner, + RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(encoded); + ASSERT_TRUE(sent); + ASSERT_TRUE(explicit_conflict); + ASSERT_TRUE(logged); + ASSERT_TRUE(exited); + PASS(); +} + +TEST(daemon_runtime_matching_clients_share_one_service_endpoint) { + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start(&fixture, "shared-endpoint", &identity); + cbm_daemon_ipc_endpoint_t *same_endpoint = NULL; + cbm_daemon_runtime_client_t *first = NULL; + cbm_daemon_runtime_client_t *second = NULL; + cbm_daemon_runtime_connect_result_t first_result = {0}; + cbm_daemon_runtime_connect_result_t second_result = {0}; + bool same_address = false; + bool both_registered = false; + bool one_remains = false; + bool exited = false; + + char key[CBM_DAEMON_KEY_SIZE]; + if (started && cbm_daemon_rendezvous_key(key)) { + same_endpoint = cbm_daemon_ipc_endpoint_new(key, fixture.parent); + } + if (same_endpoint) { + same_address = + strcmp(cbm_daemon_ipc_endpoint_address(fixture.endpoint), + cbm_daemon_ipc_endpoint_address(same_endpoint)) == 0; + first = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &first_result); + second = cbm_daemon_runtime_client_connect(same_endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &second_result); + } + if (first && second) { + both_registered = first_result.client_id != second_result.client_id && + cbm_daemon_runtime_service_active_clients(fixture.service) == 2; + (void)cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS); + first = NULL; + one_remains = cbm_daemon_runtime_service_wait_for_clients( + fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(second, RUNTIME_TEST_TIMEOUT_MS); + second = NULL; + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, + RUNTIME_TEST_TIMEOUT_MS); + } + + if (second) { + (void)cbm_daemon_runtime_client_close(second, RUNTIME_TEST_TIMEOUT_MS); + } + if (first) { + (void)cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS); + } + cbm_daemon_ipc_endpoint_free(same_endpoint); + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(same_address); + ASSERT_TRUE(both_registered); + ASSERT_TRUE(one_remains); + ASSERT_TRUE(exited); + PASS(); +} + +TEST(daemon_runtime_same_version_different_build_is_visible_and_logged) { + const char *active_build = runtime_test_self_build(); + cbm_daemon_build_identity_t active = runtime_test_identity("2.4.0", active_build); + cbm_daemon_build_identity_t rebuilt = runtime_test_identity("2.4.0", RUNTIME_BUILD_B); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start(&fixture, "build-conflict", &active); + cbm_daemon_runtime_connect_result_t owner_result = {0}; + cbm_daemon_runtime_connect_result_t conflict_result = {0}; + cbm_daemon_runtime_client_t *owner = NULL; + cbm_daemon_runtime_client_t *rejected = NULL; + bool explicit_conflict = false; + bool logged = false; + bool owner_unchanged = false; + bool exited = false; + char log[RUNTIME_TEST_LOG_CAP] = {0}; + + if (started) { + owner = cbm_daemon_runtime_client_connect(fixture.endpoint, &active, + RUNTIME_TEST_TIMEOUT_MS, &owner_result); + } + if (owner) { + rejected = cbm_daemon_runtime_client_connect(fixture.endpoint, &rebuilt, + RUNTIME_TEST_TIMEOUT_MS, &conflict_result); + explicit_conflict = + rejected == NULL && + conflict_result.status == CBM_DAEMON_RUNTIME_CONNECT_CONFLICT && + conflict_result.hello_status == CBM_DAEMON_HELLO_BUILD_CONFLICT && + strstr(conflict_result.message, "could not start") != NULL && + strstr(conflict_result.message, active_build) != NULL && + strstr(conflict_result.message, RUNTIME_BUILD_B) != NULL; + owner_unchanged = + cbm_daemon_runtime_service_active_clients(fixture.service) == 1; + logged = runtime_test_read_log(fixture.log_path, log) && + strstr(log, "daemon.version_conflict") != NULL && + strstr(log, "\"reason\":\"build\"") != NULL && + strstr(log, active_build) != NULL && strstr(log, RUNTIME_BUILD_B) != NULL; + (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); + owner = NULL; + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, + RUNTIME_TEST_TIMEOUT_MS); + } + + if (rejected) { + (void)cbm_daemon_runtime_client_close(rejected, RUNTIME_TEST_TIMEOUT_MS); + } + if (owner) { + (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(explicit_conflict); + ASSERT_TRUE(owner_unchanged); + ASSERT_TRUE(logged); + ASSERT_TRUE(exited); + PASS(); +} + +TEST(daemon_runtime_conflict_log_failure_uses_operation_log_fallback) { + const char *active_build = runtime_test_self_build(); + cbm_daemon_build_identity_t active = runtime_test_identity("2.4.0", active_build); + cbm_daemon_build_identity_t rebuilt = runtime_test_identity("2.4.0", RUNTIME_BUILD_B); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start(&fixture, "conflict-log-fallback", &active); + bool obstruction_created = started && cbm_mkdir_p(fixture.log_path, 0700); + cbm_daemon_runtime_connect_result_t owner_result = {0}; + cbm_daemon_runtime_connect_result_t conflict_result = {0}; + cbm_daemon_runtime_client_t *owner = NULL; + cbm_daemon_runtime_client_t *rejected = NULL; + CBMLogLevel prior_level = cbm_log_get_level(); + bool explicit_conflict = false; + bool exited = false; + + atomic_store_explicit(&runtime_conflict_log_fallback_seen, false, + memory_order_release); + if (obstruction_created) { + cbm_log_set_level(CBM_LOG_ERROR); + cbm_log_set_sink(runtime_test_conflict_log_fallback_sink); + owner = cbm_daemon_runtime_client_connect( + fixture.endpoint, &active, RUNTIME_TEST_TIMEOUT_MS, &owner_result); + } + if (owner) { + rejected = cbm_daemon_runtime_client_connect( + fixture.endpoint, &rebuilt, RUNTIME_TEST_TIMEOUT_MS, + &conflict_result); + explicit_conflict = + rejected == NULL && + conflict_result.status == CBM_DAEMON_RUNTIME_CONNECT_CONFLICT && + conflict_result.hello_status == CBM_DAEMON_HELLO_BUILD_CONFLICT; + (void)cbm_daemon_runtime_client_close(owner, + RUNTIME_TEST_TIMEOUT_MS); + owner = NULL; + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + cbm_log_set_sink(NULL); + cbm_log_set_level(prior_level); + + if (rejected) { + (void)cbm_daemon_runtime_client_close(rejected, + RUNTIME_TEST_TIMEOUT_MS); + } + if (owner) { + (void)cbm_daemon_runtime_client_close(owner, + RUNTIME_TEST_TIMEOUT_MS); + } + bool fallback_seen = atomic_load_explicit( + &runtime_conflict_log_fallback_seen, memory_order_acquire); + if (obstruction_created) { + (void)cbm_rmdir(fixture.log_path); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(obstruction_created); + ASSERT_TRUE(explicit_conflict); + ASSERT_TRUE(fallback_seen); + ASSERT_TRUE(exited); + PASS(); +} + +TEST(daemon_runtime_disconnect_releases_only_connection_subscriptions) { + static const char project[] = "runtime-shared-project"; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start(&fixture, "owned-subscriptions", &identity); + cbm_daemon_runtime_client_t *first = NULL; + cbm_daemon_runtime_client_t *second = NULL; + cbm_daemon_runtime_connect_result_t first_result = {0}; + cbm_daemon_runtime_connect_result_t second_result = {0}; + cbm_daemon_subscription_id_t first_subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + cbm_daemon_subscription_id_t second_subscription = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + bool subscribed = false; + bool second_survived = false; + bool reaped = false; + bool exited = false; + + if (started) { + first = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &first_result); + second = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &second_result); + } + if (first && second) { + cbm_daemon_subscription_result_t first_status = + cbm_daemon_runtime_client_job_subscribe(first, project, &first_subscription, + RUNTIME_TEST_TIMEOUT_MS); + cbm_daemon_subscription_result_t second_status = + cbm_daemon_runtime_client_job_subscribe(second, project, &second_subscription, + RUNTIME_TEST_TIMEOUT_MS); + subscribed = first_status == CBM_DAEMON_SUBSCRIPTION_STARTED && + second_status == CBM_DAEMON_SUBSCRIPTION_JOINED && + first_subscription != CBM_DAEMON_SUBSCRIPTION_ID_INVALID && + second_subscription != CBM_DAEMON_SUBSCRIPTION_ID_INVALID && + first_subscription != second_subscription && + cbm_daemon_runtime_service_job_subscribers(fixture.service, project) == 2; + + (void)cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS); + first = NULL; + second_survived = cbm_daemon_runtime_service_wait_for_clients( + fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS) && + cbm_daemon_runtime_service_job_subscribers(fixture.service, project) == + 1 && + cbm_daemon_runtime_client_job_unsubscribe( + second, second_subscription, RUNTIME_TEST_TIMEOUT_MS) && + cbm_daemon_runtime_service_job_subscribers(fixture.service, project) == + 0; + reaped = cbm_daemon_runtime_service_job_reaped(fixture.service, project); + (void)cbm_daemon_runtime_client_close(second, RUNTIME_TEST_TIMEOUT_MS); + second = NULL; + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, + RUNTIME_TEST_TIMEOUT_MS); + } + + if (second) { + (void)cbm_daemon_runtime_client_close(second, RUNTIME_TEST_TIMEOUT_MS); + } + if (first) { + (void)cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(subscribed); + ASSERT_TRUE(second_survived); + ASSERT_TRUE(reaped); + ASSERT_TRUE(exited); + PASS(); +} + +TEST(daemon_runtime_final_disconnect_automatically_exits_within_bound) { + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start(&fixture, "bounded-exit", &identity); + cbm_daemon_runtime_client_t *client = NULL; + cbm_daemon_runtime_connect_result_t result = {0}; + bool terminal_transition = false; + bool exited = false; + + if (started) { + client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &result); + } + if (client) { + (void)cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); + client = NULL; + cbm_daemon_runtime_service_state_t after_close = + cbm_daemon_runtime_service_state(fixture.service); + terminal_transition = after_close == CBM_DAEMON_RUNTIME_SERVICE_STOPPING || + after_close == CBM_DAEMON_RUNTIME_SERVICE_EXITED; + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, + RUNTIME_TEST_TIMEOUT_MS); + } + + if (client) { + (void)cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(terminal_transition); + ASSERT_TRUE(exited); + PASS(); +} + +TEST(daemon_runtime_authenticated_idle_connection_outlives_lease_interval) { + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_configured( + &fixture, "idle-connection", &identity, 8, 20, NULL); + cbm_daemon_runtime_connect_result_t result = {0}; + cbm_daemon_runtime_client_t *client = NULL; + bool remained_connected = false; + bool exited = false; + + if (started) { + client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, + &result); + } + if (client) { + struct timespec beyond_lease = {.tv_sec = 0, .tv_nsec = 60000000}; + (void)cbm_nanosleep(&beyond_lease, NULL); + remained_connected = + cbm_daemon_runtime_service_active_clients(fixture.service) == 1 && + cbm_daemon_runtime_client_heartbeat(client, + RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(client, + RUNTIME_TEST_TIMEOUT_MS); + client = NULL; + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + + if (client) { + (void)cbm_daemon_runtime_client_close(client, + RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(remained_connected); + ASSERT_TRUE(exited); + PASS(); +} + +TEST(daemon_runtime_connection_cap_covers_slow_hello_and_stopping_is_terminal) { + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_limited(&fixture, "connection-cap", &identity, 2); + cbm_daemon_ipc_connection_t *slow_hello = NULL; + cbm_daemon_runtime_client_t *accepted = NULL; + cbm_daemon_runtime_client_t *overflow = NULL; + cbm_daemon_runtime_client_t *resurrection = NULL; + cbm_daemon_runtime_connect_result_t accepted_result = {0}; + cbm_daemon_runtime_connect_result_t overflow_result = {0}; + cbm_daemon_runtime_connect_result_t resurrection_result = {0}; + bool slow_slot_counted = false; + bool capacity_rejected = false; + bool slow_slot_released = false; + bool exited = false; + bool no_resurrection = false; + + if (started) { + slow_hello = cbm_daemon_ipc_connect(fixture.endpoint, RUNTIME_TEST_TIMEOUT_MS); + } + if (slow_hello) { + slow_slot_counted = cbm_daemon_runtime_service_wait_for_connections( + fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); + accepted = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &accepted_result); + } + if (accepted) { + overflow = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &overflow_result); + capacity_rejected = + overflow == NULL && overflow_result.status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED && + strstr(overflow_result.message, "capacity") != NULL && + cbm_daemon_runtime_service_active_connections(fixture.service) == 2 && + cbm_daemon_runtime_service_active_clients(fixture.service) == 1; + + cbm_daemon_ipc_connection_close(slow_hello); + slow_hello = NULL; + slow_slot_released = cbm_daemon_runtime_service_wait_for_connections( + fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(accepted, RUNTIME_TEST_TIMEOUT_MS); + accepted = NULL; + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, + RUNTIME_TEST_TIMEOUT_MS); + + resurrection = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, 100, + &resurrection_result); + cbm_daemon_runtime_service_state_t terminal_state = + cbm_daemon_runtime_service_state(fixture.service); + no_resurrection = + resurrection == NULL && + (resurrection_result.status == CBM_DAEMON_RUNTIME_CONNECT_ERROR || + resurrection_result.status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED) && + cbm_daemon_runtime_service_active_clients(fixture.service) == 0 && + (terminal_state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING || + terminal_state == CBM_DAEMON_RUNTIME_SERVICE_EXITED); + } + + if (resurrection) { + (void)cbm_daemon_runtime_client_close(resurrection, RUNTIME_TEST_TIMEOUT_MS); + } + if (overflow) { + (void)cbm_daemon_runtime_client_close(overflow, RUNTIME_TEST_TIMEOUT_MS); + } + if (accepted) { + (void)cbm_daemon_runtime_client_close(accepted, RUNTIME_TEST_TIMEOUT_MS); + } + cbm_daemon_ipc_connection_close(slow_hello); + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(slow_slot_counted); + ASSERT_TRUE(capacity_rejected); + ASSERT_TRUE(slow_slot_released); + ASSERT_TRUE(exited); + ASSERT_TRUE(no_resurrection); + PASS(); +} + +TEST(daemon_runtime_rejects_forged_identity_extension) { + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start(&fixture, "forged-identity", &identity); + cbm_daemon_ipc_connection_t *raw = NULL; + uint8_t forged[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE + + sizeof(uint64_t) * 2] = {0}; + uint64_t forged_client_id = UINT64_MAX - 1; + uint64_t forged_process_id = UINT64_MAX; + bool encoded = false; + bool sent = false; + bool rejected = false; + cbm_daemon_frame_t response_frame = {0}; + uint8_t *response_payload = NULL; + + if (started) { + encoded = cbm_daemon_runtime_hello_request_encode(forged, &identity); + memcpy(forged + CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE, + &forged_client_id, + sizeof(forged_client_id)); + memcpy(forged + CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE + + sizeof(forged_client_id), + &forged_process_id, sizeof(forged_process_id)); + raw = cbm_daemon_ipc_connect(fixture.endpoint, RUNTIME_TEST_TIMEOUT_MS); + } + if (raw && encoded) { + sent = cbm_daemon_ipc_send_frame(raw, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_HELLO, forged, + (uint32_t)sizeof(forged)); + int received = cbm_daemon_ipc_receive_frame(raw, RUNTIME_TEST_TIMEOUT_MS, + &response_frame, &response_payload); + rejected = received != 1 && + cbm_daemon_runtime_service_active_clients(fixture.service) == 0; + } + free(response_payload); + cbm_daemon_ipc_connection_close(raw); + + /* A malformed peer must not poison the stable service for valid clients. */ + cbm_daemon_runtime_connect_result_t valid_result = {0}; + cbm_daemon_runtime_client_t *valid = NULL; + bool valid_after_rejection = false; + bool exited = false; + if (started) { + valid = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &valid_result); + } + if (valid) { + valid_after_rejection = + valid_result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED && + valid_result.authenticated_process_id == runtime_test_process_id() && + valid_result.client_id != forged_client_id; + (void)cbm_daemon_runtime_client_close(valid, RUNTIME_TEST_TIMEOUT_MS); + valid = NULL; + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, + RUNTIME_TEST_TIMEOUT_MS); + } + + if (valid) { + (void)cbm_daemon_runtime_client_close(valid, RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(encoded); + ASSERT_TRUE(sent); + ASSERT_TRUE(rejected); + ASSERT_TRUE(valid_after_rejection); + ASSERT_TRUE(exited); + PASS(); +} + +TEST(daemon_runtime_application_response_roundtrip_is_byte_exact) { + static const uint8_t request[] = { + 0x00, 0x7f, 0x80, 0xff, 'c', 'b', 'm', 0x00, 0x13, + }; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_application_context_t context; + runtime_application_context_init(&context, false); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_application( + &fixture, "application-roundtrip", &identity, &context); + cbm_daemon_runtime_connect_result_t result = {0}; + cbm_daemon_runtime_client_t *client = NULL; + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t status = + CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + bool exact = false; + bool closed = false; + bool exited = false; + + if (started) { + client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, + &result); + } + if (client) { + status = cbm_daemon_runtime_client_application_request( + client, request, (uint32_t)sizeof(request), &response, + &response_length, RUNTIME_TEST_TIMEOUT_MS); + exact = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + response_length == sizeof(request) && response && + memcmp(response, request, sizeof(request)) == 0; + free(response); + response = NULL; + closed = cbm_daemon_runtime_client_close(client, + RUNTIME_TEST_TIMEOUT_MS); + client = NULL; + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + + free(response); + if (client) { + (void)cbm_daemon_runtime_client_close(client, + RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(exact); + ASSERT_TRUE(closed); + ASSERT_TRUE(exited); + ASSERT_EQ(atomic_load(&context.opened), 1); + ASSERT_EQ(atomic_load(&context.requests), 1); + ASSERT_EQ(atomic_load(&context.cancelled), 1); + ASSERT_EQ(atomic_load(&context.closed), 1); + PASS(); +} + +TEST(daemon_runtime_final_disconnect_rejects_blocked_provisional_session) { + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_application_context_t context; + runtime_application_context_init(&context, false); + atomic_store_explicit(&context.block_second_open, true, + memory_order_release); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_application( + &fixture, "application-open-race", &identity, &context); + cbm_daemon_runtime_connect_result_t owner_result = {0}; + cbm_daemon_runtime_client_t *owner = NULL; + runtime_application_connect_call_t contender = { + .identity = identity, + }; + atomic_init(&contender.completed, false); + cbm_thread_t connect_thread; + int connect_thread_create_rc = -1; + int connect_thread_join_rc = -1; + bool connect_thread_started = false; + bool provisional_started = false; + bool owner_closed = false; + bool shutdown_won = false; + bool contender_accepted = false; + bool exited = false; + + if (started) { + owner = cbm_daemon_runtime_client_connect( + fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, + &owner_result); + } + if (owner) { + contender.endpoint = fixture.endpoint; + connect_thread_create_rc = cbm_thread_create( + &connect_thread, 128U * 1024U, + runtime_application_client_connect_thread, &contender); + connect_thread_started = connect_thread_create_rc == 0; + provisional_started = connect_thread_started && + runtime_test_wait_atomic_bool( + &context.second_open_started, + RUNTIME_TEST_TIMEOUT_MS); + } + if (provisional_started) { + owner_closed = cbm_daemon_runtime_client_close( + owner, RUNTIME_TEST_TIMEOUT_MS); + owner = NULL; + shutdown_won = cbm_daemon_runtime_service_state(fixture.service) == + CBM_DAEMON_RUNTIME_SERVICE_STOPPING; + } + + /* Release on every setup outcome so neither the server worker nor the + * helper can retain stack-owned test state during cleanup. */ + atomic_store_explicit(&context.release_second_open, true, + memory_order_release); + if (connect_thread_started) { + connect_thread_join_rc = cbm_thread_join(&connect_thread); + if (connect_thread_join_rc == 0) { + connect_thread_started = false; + } + } + if (connect_thread_started) { + while (!atomic_load_explicit(&contender.completed, + memory_order_acquire)) { + struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + (void)cbm_nanosleep(&pause, NULL); + } + } + contender_accepted = contender.client != NULL; + if (owner) { + (void)cbm_daemon_runtime_client_close(owner, + RUNTIME_TEST_TIMEOUT_MS); + owner = NULL; + } + if (contender.client) { + (void)cbm_daemon_runtime_client_close(contender.client, + RUNTIME_TEST_TIMEOUT_MS); + contender.client = NULL; + } + if (started) { + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(owner_result.status, CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED); + ASSERT_EQ(connect_thread_create_rc, 0); + ASSERT_TRUE(provisional_started); + ASSERT_TRUE(owner_closed); + ASSERT_TRUE(shutdown_won); + ASSERT_EQ(connect_thread_join_rc, 0); + ASSERT_TRUE(atomic_load_explicit(&contender.completed, + memory_order_acquire)); + ASSERT_FALSE(contender_accepted); + ASSERT_TRUE(exited); + ASSERT_EQ(atomic_load(&context.opened), 2); + ASSERT_EQ(atomic_load(&context.cancelled), 2); + ASSERT_EQ(atomic_load(&context.closed), 2); + PASS(); +} + +TEST(daemon_runtime_request_cancel_is_exact_and_session_remains_usable) { + static const uint8_t blocking_request[] = {'b', 'l', 'o', 'c', 'k'}; + static const uint8_t next_request[] = {0xde, 0xad, 0x00, 0xbe, 0xef}; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_application_context_t context; + runtime_application_context_init(&context, true); + atomic_bool request_thread_completed; + atomic_init(&request_thread_completed, false); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_application( + &fixture, "application-request-cancel", &identity, &context); + cbm_daemon_runtime_connect_result_t result = {0}; + cbm_daemon_runtime_client_t *client = NULL; + cbm_daemon_runtime_application_token_t request_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + runtime_application_client_call_t call = { + .request = blocking_request, + .request_length = (uint32_t)sizeof(blocking_request), + .tagged = true, + .completed = &request_thread_completed, + .status = CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR, + }; + cbm_thread_t request_thread; + int request_thread_create_rc = -1; + int request_thread_join_rc = -1; + bool request_thread_join_attempted = false; + bool token_reserved = false; + bool request_thread_started = false; + bool callback_started = false; + cbm_daemon_runtime_cancel_result_t wrong_cancel = + CBM_DAEMON_RUNTIME_CANCEL_ERROR; + int cancels_after_wrong = -1; + cbm_daemon_runtime_cancel_result_t exact_cancel = + CBM_DAEMON_RUNTIME_CANCEL_ERROR; + bool cancel_delivered = false; + cbm_daemon_runtime_cancel_result_t late_duplicate_cancel = + CBM_DAEMON_RUNTIME_CANCEL_ERROR; + int cancels_after_duplicate = -1; + bool close_begun = false; + bool request_thread_joined = false; + uint8_t *next_response = NULL; + uint32_t next_response_length = 0; + cbm_daemon_runtime_application_status_t next_status = + CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + bool next_exact = false; + bool heartbeat = false; + bool closed = false; + bool exited = false; + + if (started) { + client = cbm_daemon_runtime_client_connect( + fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &result); + } + if (client) { + token_reserved = cbm_daemon_runtime_client_application_token_reserve( + client, &request_token); + } + if (token_reserved) { + call.client = client; + call.request_token = request_token; + request_thread_create_rc = cbm_thread_create( + &request_thread, 128U * 1024U, + runtime_application_client_request_thread, &call); + request_thread_started = request_thread_create_rc == 0; + if (!request_thread_started) { + printf(" runtime helper thread create failed: rc=%d\n", + request_thread_create_rc); + } + callback_started = request_thread_started && + runtime_test_wait_atomic_bool( + &context.first_request_started, + RUNTIME_TEST_TIMEOUT_MS); + } + if (callback_started) { + wrong_cancel = cbm_daemon_runtime_client_application_cancel( + client, request_token + 1U); + cancels_after_wrong = atomic_load_explicit( + &context.request_cancels, memory_order_acquire); + exact_cancel = cbm_daemon_runtime_client_application_cancel( + client, request_token); + cancel_delivered = + exact_cancel == CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED && + runtime_test_wait_atomic_int(&context.request_cancels, 1, + RUNTIME_TEST_TIMEOUT_MS); + } + if (request_thread_started && !cancel_delivered && client) { + close_begun = cbm_daemon_runtime_client_close_begin(client); + } + if (request_thread_started && (cancel_delivered || close_begun)) { + request_thread_join_attempted = true; + request_thread_join_rc = cbm_thread_join(&request_thread); + request_thread_joined = request_thread_join_rc == 0; + if (request_thread_joined) { + request_thread_started = false; + } else { + printf(" runtime helper thread join failed: rc=%d\n", + request_thread_join_rc); + } + } + if (request_thread_joined && cancel_delivered && !close_begun) { + late_duplicate_cancel = + cbm_daemon_runtime_client_application_cancel(client, + request_token); + cancels_after_duplicate = atomic_load_explicit( + &context.request_cancels, memory_order_acquire); + next_status = cbm_daemon_runtime_client_application_request( + client, next_request, (uint32_t)sizeof(next_request), + &next_response, &next_response_length, + RUNTIME_TEST_TIMEOUT_MS); + next_exact = next_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + next_response_length == sizeof(next_request) && + next_response && + memcmp(next_response, next_request, + sizeof(next_request)) == 0; + heartbeat = cbm_daemon_runtime_client_heartbeat( + client, RUNTIME_TEST_TIMEOUT_MS); + } + free(next_response); + if (request_thread_started) { + /* Keep call/client storage alive until the helper's final release + * store even when an exceptional OS join failure prevents proving + * termination through the thread API. */ + while (!atomic_load_explicit(&request_thread_completed, + memory_order_acquire)) { + struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + (void)cbm_nanosleep(&pause, NULL); + } + if (!request_thread_join_attempted) { + request_thread_join_attempted = true; + request_thread_join_rc = cbm_thread_join(&request_thread); + request_thread_joined = request_thread_join_rc == 0; + if (request_thread_joined) { + request_thread_started = false; + } else { + printf(" runtime helper thread join failed: rc=%d\n", + request_thread_join_rc); + } + } + } + if (client) { + closed = close_begun + ? cbm_daemon_runtime_client_close_finish( + client, RUNTIME_TEST_TIMEOUT_MS) + : cbm_daemon_runtime_client_close( + client, RUNTIME_TEST_TIMEOUT_MS); + client = NULL; + } + if (started) { + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + bool cancelled_response = + call.status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && + call.response == NULL && call.response_length == 0; + free(call.response); + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(token_reserved); + ASSERT_EQ(request_thread_create_rc, 0); + ASSERT_TRUE(request_thread_join_attempted); + ASSERT_EQ(request_thread_join_rc, 0); + ASSERT_TRUE(callback_started); + ASSERT_EQ(wrong_cancel, CBM_DAEMON_RUNTIME_CANCEL_STALE); + ASSERT_EQ(cancels_after_wrong, 0); + ASSERT_EQ(exact_cancel, CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED); + ASSERT_TRUE(cancel_delivered); + ASSERT_TRUE(request_thread_joined); + ASSERT_TRUE(cancelled_response); + ASSERT_EQ(late_duplicate_cancel, CBM_DAEMON_RUNTIME_CANCEL_STALE); + ASSERT_EQ(cancels_after_duplicate, 1); + ASSERT_TRUE(next_exact); + ASSERT_TRUE(heartbeat); + ASSERT_TRUE(closed); + ASSERT_TRUE(exited); + ASSERT_EQ(atomic_load(&context.opened), 1); + ASSERT_EQ(atomic_load(&context.requests), 2); + ASSERT_EQ(atomic_load(&context.request_cancels), 1); + ASSERT_EQ(atomic_load(&context.cancelled), 1); + ASSERT_EQ(atomic_load(&context.closed), 1); + PASS(); +} + +TEST(daemon_runtime_presend_request_cancel_is_sticky_and_nonterminal) { + static const uint8_t blocking_request[] = {'p', 'r', 'e'}; + static const uint8_t next_request[] = {'n', 'e', 'x', 't'}; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_application_context_t context; + runtime_application_context_init(&context, true); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_application( + &fixture, "application-presend-cancel", &identity, &context); + cbm_daemon_runtime_connect_result_t result = {0}; + cbm_daemon_runtime_client_t *client = NULL; + cbm_daemon_runtime_application_token_t request_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + bool token_reserved = false; + cbm_daemon_runtime_cancel_result_t cancel = + CBM_DAEMON_RUNTIME_CANCEL_ERROR; + uint8_t *cancelled_response = NULL; + uint32_t cancelled_response_length = 0; + cbm_daemon_runtime_application_status_t cancelled_status = + CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + uint8_t *next_response = NULL; + uint32_t next_response_length = 0; + cbm_daemon_runtime_application_status_t next_status = + CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + bool next_exact = false; + bool closed = false; + bool exited = false; + + if (started) { + client = cbm_daemon_runtime_client_connect( + fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &result); + } + if (client) { + token_reserved = cbm_daemon_runtime_client_application_token_reserve( + client, &request_token); + } + if (token_reserved) { + cancel = cbm_daemon_runtime_client_application_cancel( + client, request_token); + cancelled_status = + cbm_daemon_runtime_client_application_request_tagged( + client, request_token, blocking_request, + (uint32_t)sizeof(blocking_request), &cancelled_response, + &cancelled_response_length, RUNTIME_TEST_TIMEOUT_MS); + } + if (cancelled_status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED) { + next_status = cbm_daemon_runtime_client_application_request( + client, next_request, (uint32_t)sizeof(next_request), + &next_response, &next_response_length, + RUNTIME_TEST_TIMEOUT_MS); + next_exact = next_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + next_response_length == sizeof(next_request) && + next_response && + memcmp(next_response, next_request, + sizeof(next_request)) == 0; + } + free(cancelled_response); + free(next_response); + if (client) { + closed = cbm_daemon_runtime_client_close( + client, RUNTIME_TEST_TIMEOUT_MS); + client = NULL; + } + if (started) { + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(token_reserved); + ASSERT_EQ(cancel, CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED); + ASSERT_EQ(cancelled_status, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); + ASSERT_EQ(cancelled_response_length, 0); + ASSERT_TRUE(next_exact); + ASSERT_TRUE(closed); + ASSERT_TRUE(exited); + ASSERT_EQ(atomic_load(&context.opened), 1); + ASSERT_EQ(atomic_load(&context.requests), 2); + ASSERT_EQ(atomic_load(&context.request_cancels), 1); + ASSERT_EQ(atomic_load(&context.cancelled), 1); + ASSERT_EQ(atomic_load(&context.closed), 1); + PASS(); +} + +TEST(daemon_runtime_allows_only_one_unstarted_application_token) { + static const uint8_t first_request[] = {'f', 'i', 'r', 's', 't'}; + static const uint8_t second_request[] = {'s', 'e', 'c', 'o', 'n', 'd'}; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_application_context_t context; + runtime_application_context_init(&context, false); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_application( + &fixture, "application-token-reservation", &identity, &context); + cbm_daemon_runtime_connect_result_t result = {0}; + cbm_daemon_runtime_client_t *client = NULL; + cbm_daemon_runtime_application_token_t first_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + cbm_daemon_runtime_application_token_t rejected_token = UINT64_MAX; + cbm_daemon_runtime_application_token_t second_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + bool first_reserved = false; + bool duplicate_reservation_rejected = false; + bool first_exact = false; + bool second_reserved = false; + bool second_exact = false; + bool closed = false; + bool exited = false; + + if (started) { + client = cbm_daemon_runtime_client_connect( + fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &result); + } + if (client) { + first_reserved = cbm_daemon_runtime_client_application_token_reserve( + client, &first_token); + duplicate_reservation_rejected = + !cbm_daemon_runtime_client_application_token_reserve( + client, &rejected_token) && + rejected_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + } + if (first_reserved && duplicate_reservation_rejected) { + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t status = + cbm_daemon_runtime_client_application_request_tagged( + client, first_token, first_request, + (uint32_t)sizeof(first_request), &response, &response_length, + RUNTIME_TEST_TIMEOUT_MS); + first_exact = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + response_length == sizeof(first_request) && response && + memcmp(response, first_request, sizeof(first_request)) == + 0; + free(response); + } + if (first_exact) { + second_reserved = cbm_daemon_runtime_client_application_token_reserve( + client, &second_token); + } + if (second_reserved) { + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t status = + cbm_daemon_runtime_client_application_request_tagged( + client, second_token, second_request, + (uint32_t)sizeof(second_request), &response, &response_length, + RUNTIME_TEST_TIMEOUT_MS); + second_exact = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + response_length == sizeof(second_request) && response && + memcmp(response, second_request, + sizeof(second_request)) == 0; + free(response); + } + if (client) { + closed = cbm_daemon_runtime_client_close( + client, RUNTIME_TEST_TIMEOUT_MS); + client = NULL; + } + if (started) { + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(first_reserved); + ASSERT_TRUE(duplicate_reservation_rejected); + ASSERT_TRUE(first_exact); + ASSERT_TRUE(second_reserved); + ASSERT_EQ(second_token, first_token + 1U); + ASSERT_TRUE(second_exact); + ASSERT_TRUE(closed); + ASSERT_TRUE(exited); + ASSERT_EQ(atomic_load(&context.requests), 2); + PASS(); +} + +TEST(daemon_runtime_consumes_busy_application_token_before_response) { + static const uint8_t blocking_request[] = {'b', 'l', 'o', 'c', 'k'}; + static const uint8_t busy_request[] = {'b', 'u', 's', 'y'}; + enum { FIRST_TOKEN = 41, BUSY_TOKEN = 42 }; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_application_context_t context; + runtime_application_context_init(&context, true); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_application( + &fixture, "application-token-replay", &identity, &context); + cbm_daemon_ipc_connection_t *raw = NULL; + bool first_sent = false; + bool first_started = false; + bool busy_sent = false; + bool busy_received = false; + bool cancel_sent = false; + bool cancellation_received = false; + bool replay_sent = false; + bool replay_rejected = false; + bool exited = false; + + if (started) { + raw = runtime_test_raw_client_connect(fixture.endpoint, &identity); + } + if (raw) { + first_sent = runtime_test_raw_application_send_token( + raw, FIRST_TOKEN, blocking_request, + (uint32_t)sizeof(blocking_request), + (uint32_t)sizeof(blocking_request)); + first_started = first_sent && runtime_test_wait_atomic_bool( + &context.first_request_started, + RUNTIME_TEST_TIMEOUT_MS); + } + if (first_started) { + busy_sent = runtime_test_raw_application_send_token( + raw, BUSY_TOKEN, busy_request, (uint32_t)sizeof(busy_request), + (uint32_t)sizeof(busy_request)); + busy_received = + busy_sent && runtime_test_raw_application_receive_status_token( + raw, BUSY_TOKEN, + CBM_DAEMON_RUNTIME_APPLICATION_BUSY); + } + if (busy_received) { + cancel_sent = runtime_test_raw_application_cancel(raw, FIRST_TOKEN); + cancellation_received = + cancel_sent && runtime_test_raw_application_receive_status_token( + raw, FIRST_TOKEN, + CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); + } + if (cancellation_received) { + replay_sent = runtime_test_raw_application_send_token( + raw, BUSY_TOKEN, busy_request, (uint32_t)sizeof(busy_request), + (uint32_t)sizeof(busy_request)); + } + if (replay_sent) { + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + int received = cbm_daemon_ipc_receive_frame( + raw, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); + replay_rejected = received != 1; + free(payload); + } + cbm_daemon_ipc_connection_close(raw); + raw = NULL; + if (started) { + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(first_sent); + ASSERT_TRUE(first_started); + ASSERT_TRUE(busy_sent); + ASSERT_TRUE(busy_received); + ASSERT_TRUE(cancel_sent); + ASSERT_TRUE(cancellation_received); + ASSERT_TRUE(replay_sent); + ASSERT_TRUE(replay_rejected); + ASSERT_TRUE(exited); + ASSERT_EQ(atomic_load(&context.requests), 1); + ASSERT_EQ(atomic_load(&context.request_cancels), 1); + PASS(); +} + +TEST(daemon_runtime_close_begin_retains_storage_and_rejects_late_exchange) { + static const uint8_t request[] = {'t', 'o', 'o', '-', 'l', 'a', 't', 'e'}; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_application_context_t context; + runtime_application_context_init(&context, false); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_application( + &fixture, "application-close-gap", &identity, &context); + cbm_daemon_runtime_connect_result_t result = {0}; + cbm_daemon_runtime_client_t *client = NULL; + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t status = + CBM_DAEMON_RUNTIME_APPLICATION_OK; + bool close_begun = false; + bool duplicate_begin_rejected = false; + bool close_acknowledged = false; + bool exited = false; + + if (started) { + client = cbm_daemon_runtime_client_connect( + fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &result); + } + if (client) { + close_begun = cbm_daemon_runtime_client_close_begin(client); + duplicate_begin_rejected = + close_begun && !cbm_daemon_runtime_client_close_begin(client); + } + if (close_begun) { + /* Deterministically models the frontend boundary where close begins + * after a worker claims an item but before it enters the runtime API. + * The retained allocation must reject the call without touching IPC. */ + status = cbm_daemon_runtime_client_application_request( + client, request, (uint32_t)sizeof(request), &response, + &response_length, RUNTIME_TEST_TIMEOUT_MS); + close_acknowledged = cbm_daemon_runtime_client_close_finish( + client, RUNTIME_TEST_TIMEOUT_MS); + client = NULL; + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + + free(response); + if (client) { + if (close_begun) { + (void)cbm_daemon_runtime_client_close_finish( + client, RUNTIME_TEST_TIMEOUT_MS); + } else { + (void)cbm_daemon_runtime_client_close(client, + RUNTIME_TEST_TIMEOUT_MS); + } + } + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(close_begun); + ASSERT_TRUE(duplicate_begin_rejected); + ASSERT_EQ(status, CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR); + ASSERT_NULL(response); + ASSERT_EQ(response_length, 0); + ASSERT_TRUE(close_acknowledged); + ASSERT_TRUE(exited); + ASSERT_EQ(atomic_load(&context.opened), 1); + ASSERT_EQ(atomic_load(&context.requests), 0); + ASSERT_EQ(atomic_load(&context.cancelled), 1); + ASSERT_EQ(atomic_load(&context.closed), 1); + PASS(); +} + +TEST(daemon_runtime_disconnect_cancels_blocked_application_before_exit) { + static const uint8_t request[] = {'b', 'l', 'o', 'c', 'k'}; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_application_context_t context; + runtime_application_context_init(&context, true); + atomic_bool request_thread_completed; + atomic_init(&request_thread_completed, false); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_application( + &fixture, "application-cancel", &identity, &context); + cbm_daemon_runtime_client_t *client = NULL; + cbm_daemon_runtime_connect_result_t result = {0}; + runtime_application_client_call_t call = { + .request = request, + .request_length = (uint32_t)sizeof(request), + .completed = &request_thread_completed, + .status = CBM_DAEMON_RUNTIME_APPLICATION_OK, + }; + cbm_thread_t request_thread; + int request_thread_create_rc = -1; + int request_thread_join_rc = -1; + bool request_thread_join_attempted = false; + bool request_thread_started = false; + bool callback_started = false; + bool close_begun = false; + bool close_acknowledged = false; + bool request_thread_joined = false; + bool request_interrupted = false; + bool exited = false; + + if (started) { + client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, + &result); + } + if (client) { + call.client = client; + request_thread_create_rc = cbm_thread_create( + &request_thread, 128U * 1024U, + runtime_application_client_request_thread, &call); + request_thread_started = request_thread_create_rc == 0; + if (!request_thread_started) { + printf(" runtime helper thread create failed: rc=%d\n", + request_thread_create_rc); + } + callback_started = request_thread_started && + runtime_test_wait_atomic_bool( + &context.first_request_started, + RUNTIME_TEST_TIMEOUT_MS); + } + if (callback_started) { + close_begun = cbm_daemon_runtime_client_close_begin(client); + } + if (client && request_thread_started && !close_begun) { + close_begun = cbm_daemon_runtime_client_close_begin(client); + } + if (request_thread_started && close_begun) { + request_thread_join_attempted = true; + request_thread_join_rc = cbm_thread_join(&request_thread); + request_thread_joined = request_thread_join_rc == 0; + if (request_thread_joined) { + request_thread_started = false; + } else { + printf(" runtime helper thread join failed: rc=%d\n", + request_thread_join_rc); + } + } + + if (request_thread_started) { + /* A join error does not prove the helper stopped. Wait for its final + * release-store before touching call/client/fixture state. This is + * intentionally unbounded, matching the successful join path's + * semantics while keeping an exceptional cleanup path free of UAF. */ + while (!atomic_load_explicit(&request_thread_completed, + memory_order_acquire)) { + struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + (void)cbm_nanosleep(&pause, NULL); + } + if (!request_thread_join_attempted) { + request_thread_join_attempted = true; + request_thread_join_rc = cbm_thread_join(&request_thread); + request_thread_joined = request_thread_join_rc == 0; + if (request_thread_joined) { + request_thread_started = false; + } else { + printf(" runtime helper thread join failed: rc=%d\n", + request_thread_join_rc); + } + } + } + + if (client) { + close_acknowledged = close_begun + ? cbm_daemon_runtime_client_close_finish( + client, RUNTIME_TEST_TIMEOUT_MS) + : cbm_daemon_runtime_client_close( + client, RUNTIME_TEST_TIMEOUT_MS); + client = NULL; + } + if (started) { + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + request_interrupted = + call.status == CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR && + call.response == NULL && call.response_length == 0; + free(call.response); + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(request_thread_create_rc, 0); + ASSERT_TRUE(request_thread_join_attempted); + ASSERT_EQ(request_thread_join_rc, 0); + ASSERT_TRUE(request_thread_joined); + ASSERT_TRUE(callback_started); + ASSERT_TRUE(close_begun); + /* close_begin intentionally interrupted the in-flight transport, so the + * two-phase close cannot also receive a DISCONNECT acknowledgement. EOF + * still closes the server session and must cancel the request/tree. */ + ASSERT_FALSE(close_acknowledged); + ASSERT_TRUE(request_interrupted); + ASSERT_TRUE(exited); + ASSERT_EQ(atomic_load(&context.opened), 1); + ASSERT_EQ(atomic_load(&context.requests), 1); + ASSERT_EQ(atomic_load(&context.cancelled), 1); + ASSERT_EQ(atomic_load(&context.closed), 1); + PASS(); +} + +TEST(daemon_runtime_noncooperative_callback_does_not_detach_or_unbound_stop) { + static const uint8_t request[] = {'i', 'g', 'n', 'o', 'r', 'e'}; + enum { STOP_BOUND_MS = 50, STOP_OBSERVED_MAX_MS = 500 }; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_application_context_t context; + runtime_application_context_init(&context, true); + atomic_store_explicit(&context.ignore_first_request_cancel, true, + memory_order_release); + atomic_bool request_thread_completed; + atomic_init(&request_thread_completed, false); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_application( + &fixture, "application-noncooperative", &identity, &context); + cbm_daemon_runtime_connect_result_t result = {0}; + cbm_daemon_runtime_client_t *client = NULL; + runtime_application_client_call_t call = { + .request = request, + .request_length = (uint32_t)sizeof(request), + .completed = &request_thread_completed, + .status = CBM_DAEMON_RUNTIME_APPLICATION_OK, + }; + cbm_thread_t request_thread; + int request_thread_create_rc = -1; + int request_thread_join_rc = -1; + bool request_thread_started = false; + bool callback_started = false; + bool close_begun = false; + bool stop_returned = true; + uint64_t stop_elapsed_ms = UINT64_MAX; + bool exited_before_release = false; + bool close_finished = false; + bool exited_after_release = false; + + if (started) { + client = cbm_daemon_runtime_client_connect( + fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &result); + } + if (client) { + call.client = client; + request_thread_create_rc = cbm_thread_create( + &request_thread, 128U * 1024U, + runtime_application_client_request_thread, &call); + request_thread_started = request_thread_create_rc == 0; + callback_started = request_thread_started && + runtime_test_wait_atomic_bool( + &context.first_request_started, + RUNTIME_TEST_TIMEOUT_MS); + } + if (callback_started) { + close_begun = cbm_daemon_runtime_client_close_begin(client); + uint64_t stop_started_ms = cbm_now_ms(); + stop_returned = cbm_daemon_runtime_service_stop( + fixture.service, STOP_BOUND_MS); + stop_elapsed_ms = cbm_now_ms() - stop_started_ms; + exited_before_release = + cbm_daemon_runtime_service_state(fixture.service) == + CBM_DAEMON_RUNTIME_SERVICE_EXITED; + } + + /* A bounded stop failure retains every callback/session allocation. The + * test supplies eventual cooperation so the runner can prove clean join + * and teardown after observing the production host's force boundary. */ + atomic_store_explicit(&context.release_first_request, true, + memory_order_release); + if (request_thread_started) { + request_thread_join_rc = cbm_thread_join(&request_thread); + if (request_thread_join_rc == 0) { + request_thread_started = false; + } + } + if (request_thread_started) { + while (!atomic_load_explicit(&request_thread_completed, + memory_order_acquire)) { + struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + (void)cbm_nanosleep(&pause, NULL); + } + } + if (client) { + close_finished = close_begun + ? cbm_daemon_runtime_client_close_finish( + client, RUNTIME_TEST_TIMEOUT_MS) + : cbm_daemon_runtime_client_close( + client, RUNTIME_TEST_TIMEOUT_MS); + client = NULL; + } + if (started) { + exited_after_release = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + free(call.response); + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(result.status, CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED); + ASSERT_EQ(request_thread_create_rc, 0); + ASSERT_TRUE(callback_started); + ASSERT_TRUE(close_begun); + ASSERT_FALSE(stop_returned); + ASSERT_TRUE(stop_elapsed_ms <= STOP_OBSERVED_MAX_MS); + ASSERT_FALSE(exited_before_release); + ASSERT_EQ(request_thread_join_rc, 0); + /* The interrupted transport cannot receive a final disconnect ACK. */ + ASSERT_FALSE(close_finished); + ASSERT_TRUE(exited_after_release); + ASSERT_EQ(atomic_load(&context.opened), 1); + ASSERT_EQ(atomic_load(&context.requests), 1); + ASSERT_EQ(atomic_load(&context.cancelled), 1); + ASSERT_EQ(atomic_load(&context.closed), 1); + PASS(); +} + +TEST(daemon_runtime_application_busy_cap_and_malformed_are_isolated) { + static const uint8_t blocking_request[] = {'f', 'i', 'r', 's', 't'}; + static const uint8_t busy_request[] = {'b', 'u', 's', 'y'}; + static const uint8_t malformed_request[] = {'x', 'y'}; + static const uint8_t valid_request[] = {0xde, 0xad, 0x00, 0xbe, 0xef}; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_application_context_t context; + runtime_application_context_init(&context, true); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_application( + &fixture, "application-isolation", &identity, &context); + cbm_daemon_runtime_client_t *owner = NULL; + cbm_daemon_runtime_connect_result_t owner_result = {0}; + cbm_daemon_ipc_connection_t *raw = NULL; + bool first_sent = false; + bool callback_started = false; + bool busy_sent = false; + bool busy_rejected = false; + bool malformed_sent = false; + bool bad_peer_released = false; + uint8_t sentinel = 0x5a; + uint8_t *oversize_response = (uint8_t *)&sentinel; + uint32_t oversize_response_length = UINT32_MAX; + cbm_daemon_runtime_application_status_t oversize_status = + CBM_DAEMON_RUNTIME_APPLICATION_OK; + uint8_t *valid_response = NULL; + uint32_t valid_response_length = 0; + cbm_daemon_runtime_application_status_t valid_status = + CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + bool owner_survived = false; + bool exited = false; + + if (started) { + owner = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, + &owner_result); + raw = runtime_test_raw_client_connect(fixture.endpoint, &identity); + } + if (owner && raw) { + first_sent = runtime_test_raw_application_send( + raw, blocking_request, (uint32_t)sizeof(blocking_request), + (uint32_t)sizeof(blocking_request)); + callback_started = first_sent && runtime_test_wait_atomic_bool( + &context.first_request_started, + RUNTIME_TEST_TIMEOUT_MS); + } + if (callback_started) { + busy_sent = runtime_test_raw_application_send( + raw, busy_request, (uint32_t)sizeof(busy_request), + (uint32_t)sizeof(busy_request)); + busy_rejected = busy_sent && + runtime_test_raw_application_receive_status( + raw, CBM_DAEMON_RUNTIME_APPLICATION_BUSY); + } + if (busy_rejected) { + malformed_sent = runtime_test_raw_application_send( + raw, malformed_request, (uint32_t)sizeof(malformed_request), + (uint32_t)sizeof(malformed_request) + 1U); + } + cbm_daemon_ipc_connection_close(raw); + raw = NULL; + if (malformed_sent) { + bad_peer_released = cbm_daemon_runtime_service_wait_for_clients( + fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); + } + if (owner && bad_peer_released) { + oversize_status = cbm_daemon_runtime_client_application_request( + owner, &sentinel, + CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX + 1U, + &oversize_response, &oversize_response_length, + RUNTIME_TEST_TIMEOUT_MS); + valid_status = cbm_daemon_runtime_client_application_request( + owner, valid_request, (uint32_t)sizeof(valid_request), + &valid_response, &valid_response_length, + RUNTIME_TEST_TIMEOUT_MS); + owner_survived = + oversize_status == + CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR && + oversize_response == NULL && oversize_response_length == 0 && + valid_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + valid_response_length == sizeof(valid_request) && valid_response && + memcmp(valid_response, valid_request, sizeof(valid_request)) == 0 && + cbm_daemon_runtime_client_heartbeat(owner, + RUNTIME_TEST_TIMEOUT_MS); + free(valid_response); + valid_response = NULL; + (void)cbm_daemon_runtime_client_close(owner, + RUNTIME_TEST_TIMEOUT_MS); + owner = NULL; + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + + free(valid_response); + if (owner) { + (void)cbm_daemon_runtime_client_close(owner, + RUNTIME_TEST_TIMEOUT_MS); + } + cbm_daemon_ipc_connection_close(raw); + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(first_sent); + ASSERT_TRUE(callback_started); + ASSERT_TRUE(busy_sent); + ASSERT_TRUE(busy_rejected); + ASSERT_TRUE(malformed_sent); + ASSERT_TRUE(bad_peer_released); + ASSERT_TRUE(owner_survived); + ASSERT_TRUE(exited); + ASSERT_EQ(atomic_load(&context.opened), 2); + ASSERT_EQ(atomic_load(&context.requests), 2); + ASSERT_EQ(atomic_load(&context.cancelled), 2); + ASSERT_EQ(atomic_load(&context.closed), 2); + PASS(); +} + +TEST(daemon_runtime_malformed_and_zero_cancel_close_only_offending_connections) { + static const uint8_t valid_request[] = {'s', 'u', 'r', 'v', 'i', 'v', 'e'}; + static const uint8_t malformed_cancel[7] = {1}; + static const uint8_t zero_token_cancel[8] = {0}; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_application_context_t context; + runtime_application_context_init(&context, false); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start_application( + &fixture, "application-cancel-isolation", &identity, &context); + cbm_daemon_runtime_connect_result_t owner_result = {0}; + cbm_daemon_runtime_client_t *owner = NULL; + cbm_daemon_ipc_connection_t *raw = NULL; + bool malformed_connected = false; + bool malformed_sent = false; + bool malformed_released = false; + bool malformed_closed = false; + bool zero_connected = false; + bool zero_sent = false; + bool zero_released = false; + bool zero_closed = false; + uint8_t *valid_response = NULL; + uint32_t valid_response_length = 0; + cbm_daemon_runtime_application_status_t valid_status = + CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + bool owner_survived = false; + bool owner_closed = false; + bool exited = false; + + if (started) { + owner = cbm_daemon_runtime_client_connect( + fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, + &owner_result); + } + if (owner) { + raw = runtime_test_raw_client_connect(fixture.endpoint, &identity); + malformed_connected = raw != NULL; + } + if (raw) { + malformed_sent = cbm_daemon_ipc_send_frame( + raw, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, malformed_cancel, + (uint32_t)sizeof(malformed_cancel)); + } + if (malformed_sent) { + malformed_released = cbm_daemon_runtime_service_wait_for_clients( + fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); + } + if (malformed_released) { + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + int received = cbm_daemon_ipc_receive_frame( + raw, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); + malformed_closed = received != 1; + free(payload); + } + cbm_daemon_ipc_connection_close(raw); + raw = NULL; + + if (owner && malformed_closed) { + raw = runtime_test_raw_client_connect(fixture.endpoint, &identity); + zero_connected = raw != NULL; + } + if (raw) { + zero_sent = cbm_daemon_ipc_send_frame( + raw, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, zero_token_cancel, + (uint32_t)sizeof(zero_token_cancel)); + } + if (zero_sent) { + zero_released = cbm_daemon_runtime_service_wait_for_clients( + fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); + } + if (zero_released) { + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + int received = cbm_daemon_ipc_receive_frame( + raw, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); + zero_closed = received != 1; + free(payload); + } + cbm_daemon_ipc_connection_close(raw); + raw = NULL; + + if (owner && zero_closed) { + valid_status = cbm_daemon_runtime_client_application_request( + owner, valid_request, (uint32_t)sizeof(valid_request), + &valid_response, &valid_response_length, + RUNTIME_TEST_TIMEOUT_MS); + owner_survived = + valid_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + valid_response_length == sizeof(valid_request) && valid_response && + memcmp(valid_response, valid_request, sizeof(valid_request)) == 0 && + cbm_daemon_runtime_client_heartbeat(owner, + RUNTIME_TEST_TIMEOUT_MS); + } + free(valid_response); + if (owner) { + owner_closed = cbm_daemon_runtime_client_close( + owner, RUNTIME_TEST_TIMEOUT_MS); + owner = NULL; + } + if (started) { + exited = cbm_daemon_runtime_service_wait_exited( + fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + cbm_daemon_ipc_connection_close(raw); + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(malformed_connected); + ASSERT_TRUE(malformed_sent); + ASSERT_TRUE(malformed_released); + ASSERT_TRUE(malformed_closed); + ASSERT_TRUE(zero_connected); + ASSERT_TRUE(zero_sent); + ASSERT_TRUE(zero_released); + ASSERT_TRUE(zero_closed); + ASSERT_TRUE(owner_survived); + ASSERT_TRUE(owner_closed); + ASSERT_TRUE(exited); + ASSERT_EQ(atomic_load(&context.opened), 3); + ASSERT_EQ(atomic_load(&context.requests), 1); + ASSERT_EQ(atomic_load(&context.request_cancels), 0); + ASSERT_EQ(atomic_load(&context.cancelled), 3); + ASSERT_EQ(atomic_load(&context.closed), 3); + PASS(); +} + +TEST(daemon_runtime_kernel_process_fingerprint_is_stable_and_fail_closed) { + char first[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char repeated[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char invalid[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + bool first_ok = cbm_daemon_runtime_process_build_fingerprint( + runtime_test_process_id(), first); + bool repeated_ok = cbm_daemon_runtime_process_build_fingerprint( + runtime_test_process_id(), repeated); + bool invalid_rejected = !cbm_daemon_runtime_process_build_fingerprint( + UINT64_MAX, invalid); + + ASSERT_TRUE(first_ok); + ASSERT_TRUE(repeated_ok); + ASSERT_TRUE(runtime_test_is_fingerprint(first)); + ASSERT_STR_EQ(first, repeated); + ASSERT_TRUE(invalid_rejected); + ASSERT_STR_EQ(invalid, ""); + PASS(); +} + +#ifdef __APPLE__ +/* RED: the former macOS fast path accepted the daemon vnode in any RX mapping, + * even when a differently fingerprinted main executable owned the connection. + * Re-signing the changed copy keeps the probe runnable on arm64 while ensuring + * that only the deliberately injected mapping has the active identity. */ +TEST(daemon_runtime_mac_fast_path_rejects_foreign_main_image_mapping_active) { + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + char directory[RUNTIME_TEST_PATH_CAP] = {0}; + char active_image[RUNTIME_TEST_PATH_CAP] = {0}; + char foreign_image[RUNTIME_TEST_PATH_CAP] = {0}; + char foreign_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + int directory_written = snprintf( + directory, sizeof(directory), "%s/cbm-runtime-mapped-image-XXXXXX", + cbm_tmpdir()); + bool directory_created = + directory_written > 0 && directory_written < (int)sizeof(directory) && + cbm_mkdtemp(directory) != NULL; + int foreign_written = + directory_created + ? snprintf(foreign_image, sizeof(foreign_image), "%s/foreign-client", + directory) + : -1; + bool active_resolved = runtime_test_self_image_path(active_image); + bool foreign_copied = + active_resolved && foreign_written > 0 && + foreign_written < (int)sizeof(foreign_image) && + runtime_test_copy_executable(active_image, foreign_image); + /* A distinct signing identifier changes the Mach-O signature bytes while + * keeping the copied executable valid under macOS strict validation. */ + bool foreign_changed = foreign_copied; + bool foreign_signed = + foreign_changed && runtime_test_mac_ad_hoc_sign(foreign_image); + bool foreign_fingerprinted = + foreign_signed && cbm_daemon_build_fingerprint_file( + foreign_image, foreign_fingerprint); + bool fingerprint_differs = + foreign_fingerprinted && + strcmp(foreign_fingerprint, identity.build_fingerprint) != 0; + + runtime_test_fixture_t fixture; + memset(&fixture, 0, sizeof(fixture)); + bool fixture_attempted = directory_created && active_resolved && + fingerprint_differs; + bool started = fixture_attempted && runtime_test_fixture_start( + &fixture, "mapped-foreign", &identity); + int foreign_exit = -1; + bool foreign_ran = + started && runtime_test_run_mapped_hello_image( + foreign_image, active_image, &fixture, &identity, + &foreign_exit); + + if (fixture_attempted) { + runtime_test_fixture_finish(&fixture); + } + if (foreign_written > 0 && + foreign_written < (int)sizeof(foreign_image)) { + (void)cbm_unlink(foreign_image); + } + if (directory_created) { + (void)cbm_rmdir(directory); + } + + ASSERT_TRUE(directory_created); + ASSERT_TRUE(active_resolved); + ASSERT_TRUE(foreign_copied); + ASSERT_TRUE(foreign_changed); + ASSERT_TRUE(foreign_signed); + ASSERT_TRUE(foreign_fingerprinted); + ASSERT_TRUE(fingerprint_differs); + ASSERT_TRUE(started); + ASSERT_TRUE(foreign_ran); + ASSERT_EQ(foreign_exit, 26); + PASS(); +} +#endif + +TEST(daemon_runtime_copied_image_fallback_accepts_identical_and_rejects_changed) { + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t identical_fixture; + bool identical_started = runtime_test_fixture_start( + &identical_fixture, "copied-identical", &identity); + char identical_path[RUNTIME_TEST_PATH_CAP] = {0}; + int identical_path_written = + identical_started +#ifdef _WIN32 + ? snprintf(identical_path, sizeof(identical_path), + "%s/client-copy.exe", identical_fixture.parent) +#else + ? snprintf(identical_path, sizeof(identical_path), "%s/client-copy", + identical_fixture.parent) +#endif + : -1; + bool identical_copied = identical_path_written > 0 && + identical_path_written < (int)sizeof(identical_path) && + runtime_test_copy_self_image(identical_path); + char identical_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + bool identical_bytes = + identical_copied && cbm_daemon_build_fingerprint_file( + identical_path, identical_fingerprint) && + strcmp(identical_fingerprint, identity.build_fingerprint) == 0; + int identical_exit = -1; + bool identical_ran = identical_bytes && runtime_test_run_hello_image( + identical_path, + &identical_fixture, &identity, + &identical_exit); + bool identical_exited = + identical_ran && identical_exit == 0 && + cbm_daemon_runtime_service_wait_exited( + identical_fixture.service, RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_unlink(identical_path); + runtime_test_fixture_finish(&identical_fixture); + +#ifdef __linux__ + runtime_test_fixture_t changed_fixture; + bool changed_started = runtime_test_fixture_start( + &changed_fixture, "copied-changed", &identity); + char changed_path[RUNTIME_TEST_PATH_CAP] = {0}; + int changed_path_written = + changed_started + ? snprintf(changed_path, sizeof(changed_path), "%s/client-copy", + changed_fixture.parent) + : -1; + bool changed_copied = + changed_path_written > 0 && + changed_path_written < (int)sizeof(changed_path) && + runtime_test_copy_self_image(changed_path) && + runtime_test_append_image_marker(changed_path); + char changed_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + bool changed_bytes = + changed_copied && cbm_daemon_build_fingerprint_file( + changed_path, changed_fingerprint) && + strcmp(changed_fingerprint, identity.build_fingerprint) != 0; + int changed_exit = -1; + bool changed_ran = changed_bytes && runtime_test_run_hello_image( + changed_path, &changed_fixture, + &identity, &changed_exit); + (void)cbm_unlink(changed_path); + runtime_test_fixture_finish(&changed_fixture); +#endif + + ASSERT_TRUE(identical_started); + ASSERT_TRUE(identical_copied); + ASSERT_TRUE(identical_bytes); + ASSERT_TRUE(identical_ran); + ASSERT_EQ(identical_exit, 0); + ASSERT_TRUE(identical_exited); +#ifdef __linux__ + ASSERT_TRUE(changed_started); + ASSERT_TRUE(changed_copied); + ASSERT_TRUE(changed_bytes); + ASSERT_TRUE(changed_ran); + ASSERT_EQ(changed_exit, 26); +#endif + PASS(); +} + +#ifdef _WIN32 +TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path) { + char directory[RUNTIME_TEST_PATH_CAP] = {0}; + char image_path[RUNTIME_TEST_PATH_CAP] = {0}; + char replacement_path[RUNTIME_TEST_PATH_CAP] = {0}; + char event_name[128] = {0}; + int directory_written = snprintf(directory, sizeof(directory), + "%s/cbm-runtime-image-XXXXXX", + cbm_tmpdir()); + bool setup = directory_written > 0 && + directory_written < (int)sizeof(directory) && + cbm_mkdtemp(directory) != NULL; + int image_written = + setup ? snprintf(image_path, sizeof(image_path), "%s/image.exe", + directory) + : -1; + int replacement_written = + setup ? snprintf(replacement_path, sizeof(replacement_path), + "%s/replacement.exe", directory) + : -1; + int event_written = snprintf( + event_name, sizeof(event_name), "Local\\cbm-runtime-image-%lu-%llu", + (unsigned long)GetCurrentProcessId(), + (unsigned long long)GetTickCount64()); + setup = setup && image_written > 0 && + image_written < (int)sizeof(image_path) && + replacement_written > 0 && + replacement_written < (int)sizeof(replacement_path) && + event_written > 0 && event_written < (int)sizeof(event_name) && + runtime_test_windows_copy_self(image_path); + + FILE *replacement_file = setup ? cbm_fopen(replacement_path, "wb") : NULL; + bool replacement_written_ok = + replacement_file && + fputs("cbm-windows-replacement-image", replacement_file) >= 0; + if (replacement_file) { + replacement_written_ok = fclose(replacement_file) == 0 && + replacement_written_ok; + } + setup = setup && replacement_written_ok; + + char original[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char replacement[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char observed[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + setup = setup && cbm_daemon_build_fingerprint_file(image_path, original) && + cbm_daemon_build_fingerprint_file(replacement_path, replacement) && + strcmp(original, replacement) != 0; + + HANDLE ready_event = + setup ? CreateEventA(NULL, TRUE, FALSE, event_name) : NULL; + bool event_private = ready_event && GetLastError() != ERROR_ALREADY_EXISTS; + PROCESS_INFORMATION process; + memset(&process, 0, sizeof(process)); + bool spawned = event_private && runtime_test_windows_spawn_image_holder( + image_path, event_name, &process); + bool ready = spawned && + WaitForSingleObject(ready_event, 5000) == WAIT_OBJECT_0; + bool replaced = ready && runtime_test_windows_posix_replace( + replacement_path, image_path); + bool fingerprinted = + ready && cbm_daemon_runtime_process_build_fingerprint( + (uint64_t)process.dwProcessId, observed); + bool replacement_safe = + replaced ? (!fingerprinted || strcmp(observed, original) == 0) + : (fingerprinted && strcmp(observed, original) == 0); + + bool stopped = !spawned; + if (spawned) { + DWORD exit_code = 0; + bool running = GetExitCodeProcess(process.hProcess, &exit_code) != 0 && + exit_code == STILL_ACTIVE; + bool termination_requested = + !running || TerminateProcess(process.hProcess, 26) != 0; + stopped = termination_requested && + WaitForSingleObject(process.hProcess, 5000) == WAIT_OBJECT_0; + (void)CloseHandle(process.hProcess); + } + if (ready_event) { + (void)CloseHandle(ready_event); + } + (void)cbm_unlink(replacement_path); + (void)cbm_unlink(image_path); + (void)cbm_rmdir(directory); + + ASSERT_TRUE(setup); + ASSERT_TRUE(event_private); + ASSERT_TRUE(spawned); + ASSERT_TRUE(ready); + ASSERT_TRUE(stopped); + ASSERT_TRUE(replacement_safe); + ASSERT_TRUE(!fingerprinted || strcmp(observed, replacement) != 0); + PASS(); +} +#elif defined(__APPLE__) || defined(__linux__) +TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path) { + char directory[RUNTIME_TEST_PATH_CAP] = {0}; + char image_path[RUNTIME_TEST_PATH_CAP] = {0}; + char replacement_path[RUNTIME_TEST_PATH_CAP] = {0}; + int directory_written = snprintf(directory, sizeof(directory), + "%s/cbm-runtime-image-XXXXXX", + cbm_tmpdir()); + bool setup = directory_written > 0 && + directory_written < (int)sizeof(directory) && + cbm_mkdtemp(directory) != NULL; + int image_written = setup + ? snprintf(image_path, sizeof(image_path), "%s/image", + directory) + : -1; + int replacement_written = setup + ? snprintf(replacement_path, + sizeof(replacement_path), + "%s/replacement", directory) + : -1; + setup = setup && image_written > 0 && + image_written < (int)sizeof(image_path) && + replacement_written > 0 && + replacement_written < (int)sizeof(replacement_path) && + runtime_test_copy_executable("/bin/cat", image_path) && + runtime_test_copy_executable("/bin/echo", replacement_path); + + char original[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char replacement[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char observed[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + setup = setup && cbm_daemon_build_fingerprint_file(image_path, original); + int release_fd = -1; + pid_t child = setup + ? runtime_test_spawn_blocked_executable(image_path, + &release_fd) + : -1; + setup = setup && child > 0 && rename(replacement_path, image_path) == 0 && + cbm_daemon_build_fingerprint_file(image_path, replacement) && + strcmp(original, replacement) != 0; + bool fingerprinted = + setup && cbm_daemon_runtime_process_build_fingerprint((uint64_t)child, + observed); + + runtime_test_stop_blocked_executable(child, release_fd); + (void)unlink(replacement_path); + (void)unlink(image_path); + (void)rmdir(directory); + + ASSERT_TRUE(setup); +#ifdef __linux__ + /* /proc//exe is an openable kernel link to the mapped inode, even + * after that inode has been unlinked by the atomic replacement. */ + ASSERT_TRUE(fingerprinted); + ASSERT_STR_EQ(observed, original); +#else + /* macOS exposes mapped vnode identity but not a public handle to the + * mapped executable. If the old vnode no longer has an openable path we + * must fail closed; resolving and hashing the new path is forbidden. */ + ASSERT_TRUE(!fingerprinted || strcmp(observed, original) == 0); +#endif + ASSERT_TRUE(!fingerprinted || strcmp(observed, replacement) != 0); + PASS(); +} +#endif + +SUITE(daemon_runtime) { +#ifndef _WIN32 + RUN_TEST(daemon_host_failed_listener_reservation_starts_no_background_work); + RUN_TEST(daemon_host_persistent_cleanup_release_failure_is_process_bounded); + RUN_TEST(daemon_host_forced_shutdown_is_logged_flushed_and_process_bounded); +#endif + RUN_TEST(daemon_runtime_kernel_process_fingerprint_is_stable_and_fail_closed); +#ifdef __APPLE__ + RUN_TEST(daemon_runtime_mac_fast_path_rejects_foreign_main_image_mapping_active); +#endif + RUN_TEST(daemon_runtime_copied_image_fallback_accepts_identical_and_rejects_changed); +#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) + RUN_TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path); +#endif + RUN_TEST(daemon_runtime_convenience_service_owns_participant_guard); + RUN_TEST(daemon_runtime_rendezvous_layout_is_frozen_and_detailed_abi_independent); + RUN_TEST(daemon_runtime_exact_hello_issues_connection_bound_identity); + RUN_TEST(daemon_runtime_activation_rejects_forged_and_malformed_without_stop); + RUN_TEST(daemon_runtime_activation_ack_snapshots_then_interrupts_all_clients); +#if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) + RUN_TEST(daemon_runtime_activation_accepts_authenticated_different_build); +#endif + RUN_TEST(daemon_runtime_future_generation_gets_stable_explicit_conflict); + RUN_TEST(daemon_runtime_matching_clients_share_one_service_endpoint); + RUN_TEST(daemon_runtime_same_version_different_build_is_visible_and_logged); + RUN_TEST(daemon_runtime_conflict_log_failure_uses_operation_log_fallback); + RUN_TEST(daemon_runtime_disconnect_releases_only_connection_subscriptions); + RUN_TEST(daemon_runtime_final_disconnect_automatically_exits_within_bound); + RUN_TEST(daemon_runtime_authenticated_idle_connection_outlives_lease_interval); + RUN_TEST(daemon_runtime_connection_cap_covers_slow_hello_and_stopping_is_terminal); + RUN_TEST(daemon_runtime_rejects_forged_identity_extension); + RUN_TEST(daemon_runtime_application_response_roundtrip_is_byte_exact); + RUN_TEST(daemon_runtime_final_disconnect_rejects_blocked_provisional_session); + RUN_TEST(daemon_runtime_request_cancel_is_exact_and_session_remains_usable); + RUN_TEST(daemon_runtime_presend_request_cancel_is_sticky_and_nonterminal); + RUN_TEST(daemon_runtime_allows_only_one_unstarted_application_token); + RUN_TEST(daemon_runtime_consumes_busy_application_token_before_response); + RUN_TEST(daemon_runtime_close_begin_retains_storage_and_rejects_late_exchange); + RUN_TEST(daemon_runtime_disconnect_cancels_blocked_application_before_exit); + RUN_TEST(daemon_runtime_noncooperative_callback_does_not_detach_or_unbound_stop); + RUN_TEST(daemon_runtime_application_busy_cap_and_malformed_are_isolated); + RUN_TEST(daemon_runtime_malformed_and_zero_cancel_close_only_offending_connections); +} diff --git a/tests/test_daemon_smoke.py b/tests/test_daemon_smoke.py new file mode 100644 index 000000000..20ba4635a --- /dev/null +++ b/tests/test_daemon_smoke.py @@ -0,0 +1,2288 @@ +#!/usr/bin/env python3 +"""Real-binary smoke test for the mandatory per-account CBM daemon. + +This is intentionally a separate POSIX smoke test rather than part of the C +unit suite. It owns the account-wide rendezvous point while it runs. Direct +invocations may skip an occupied endpoint; the Make target requires a clean +rendezvous and fails instead of reporting a false-green skip. +Linux and macOS are covered; Windows named-pipe/mutex behavior remains covered +by the deterministic C IPC/runtime tests. +""" + +import hashlib +import json +import os +from pathlib import Path +import platform +import shutil +import signal +import socket +import stat +import struct +import subprocess +import sys +import tarfile +import tempfile +import threading +import time + +try: + import fcntl +except ImportError: # Windows reaches the explicit platform skip in main(). + fcntl = None + + +RENDEZVOUS_KEY = "c888acc3ae367a1e" +RENDEZVOUS_REQUEST_SIZE = 133 +RENDEZVOUS_RESPONSE_SIZE = 798 +RENDEZVOUS_VERSION_CAP = 64 +RENDEZVOUS_BUILD_CAP = 65 +RENDEZVOUS_MESSAGE_CAP = 512 +START_TIMEOUT = 30.0 +OPERATION_TIMEOUT = 30.0 +SHUTDOWN_TIMEOUT = 35.0 +RESTART_GUIDANCE = ( + "Please restart your coding-agent sessions to properly take this into " + "account." +) + + +class SmokeFailure(RuntimeError): + pass + + +def check(condition, message): + if not condition: + raise SmokeFailure(message) + + +def wait_until(predicate, timeout, description): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(0.1) + raise SmokeFailure("timed out waiting for " + description) + + +def sha256_file(path): + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def lock_status(path, record_lock): + """Return free, held, or error without creating a missing lock file.""" + if not path.exists(): + return "free" + flags = os.O_RDWR | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0) + try: + fd = os.open(str(path), flags) + except OSError: + return "error" + try: + status = os.fstat(fd) + if ( + not stat.S_ISREG(status.st_mode) + or status.st_uid != os.geteuid() + or status.st_nlink != 1 + or stat.S_IMODE(status.st_mode) != 0o600 + ): + return "error" + try: + if record_lock: + fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.lockf(fd, fcntl.LOCK_UN) + else: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + fcntl.flock(fd, fcntl.LOCK_UN) + return "free" + except BlockingIOError: + return "held" + except OSError: + return "error" + finally: + os.close(fd) + + +def process_gone_or_zombie(pid): + try: + os.kill(pid, 0) + except ProcessLookupError: + return True + except PermissionError: + return False + result = subprocess.run( + ["ps", "-p", str(pid), "-o", "stat="], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + return result.returncode != 0 or result.stdout.strip().startswith("Z") + + +def process_identity(pid): + """Return (ppid, pgid, state) for a live non-zombie process.""" + result = subprocess.run( + [ + "ps", + "-p", + str(pid), + "-o", + "ppid=", + "-o", + "pgid=", + "-o", + "stat=", + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + check=False, + ) + if result.returncode != 0: + return None + try: + ppid, pgid, state = result.stdout.split(None, 2) + if state.startswith("Z"): + return None + return int(ppid), int(pgid), state + except (TypeError, ValueError): + return None + + +def worker_tree_identity_matches( + supervisor_pid, worker_pid, worker_pgid, descendant_pid +): + """Revalidate the exact test-owned worker tree before signaling it.""" + if ( + not worker_pid + or not worker_pgid + or worker_pid <= 1 + or worker_pgid != worker_pid + or not descendant_pid + or descendant_pid <= 1 + ): + return False + worker = process_identity(worker_pid) + descendant = process_identity(descendant_pid) + if not worker or not descendant: + return False + worker_ppid, current_worker_pgid, _ = worker + descendant_ppid, current_descendant_pgid, _ = descendant + return ( + current_worker_pgid == worker_pgid + and descendant_ppid == worker_pid + and current_descendant_pgid == worker_pgid + and (supervisor_pid is None or worker_ppid == supervisor_pid) + ) + + +def worker_group_contains_descendant(worker_pgid, descendant_pid): + """Validate the preserved group through its test-created descendant.""" + if not worker_pgid or worker_pgid <= 1 or not descendant_pid: + return False + descendant = process_identity(descendant_pid) + return bool(descendant and descendant[1] == worker_pgid) + + +def worker_is_stopped(worker_pid, worker_pgid): + identity = process_identity(worker_pid) + return bool( + identity + and identity[1] == worker_pgid + and identity[2].startswith("T") + ) + + +def read_private_pid_fields(path, field_count): + """Read a small owner-only marker, returning None while it is incomplete.""" + flags = ( + os.O_RDONLY + | getattr(os, "O_CLOEXEC", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + try: + fd = os.open(str(path), flags) + except OSError: + return None + try: + status = os.fstat(fd) + if ( + not stat.S_ISREG(status.st_mode) + or status.st_uid != os.geteuid() + or status.st_nlink != 1 + or stat.S_IMODE(status.st_mode) != 0o600 + or status.st_size > 128 + ): + return None + payload = os.read(fd, 129) + except OSError: + return None + finally: + os.close(fd) + if len(payload) > 128: + return None + try: + fields = payload.decode("ascii").split() + values = tuple(int(field) for field in fields) + except (UnicodeDecodeError, ValueError): + return None + if len(values) != field_count or any(value <= 1 for value in values): + return None + return values + + +def json_events(path, event): + return [record for record in json_records(path) if record.get("event") == event] + + +def json_records(path): + if not path.exists(): + return [] + found = [] + for line in path.read_text(encoding="utf-8", errors="replace").splitlines(): + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(record, dict): + found.append(record) + return found + + +def daemon_lifecycle_sequence(path): + return [ + record.get("event") + for record in json_records(path) + if record.get("event") in ("daemon.start", "daemon.stop") + ] + + +def fixed_wire_text(value, capacity): + encoded = value.encode("ascii") + check(len(encoded) < capacity, "rendezvous text exceeds fixed capacity") + return encoded + b"\0" * (capacity - len(encoded)) + + +def decode_fixed_wire_text(payload, offset, capacity): + field = payload[offset : offset + capacity] + check(len(field) == capacity, "truncated rendezvous text field") + terminator = field.find(b"\0") + check(terminator >= 0, "unterminated rendezvous text field") + check(not any(field[terminator + 1 :]), "non-canonical rendezvous text padding") + return field[:terminator].decode("ascii") + + +def recv_exact(stream, length): + chunks = [] + remaining = length + while remaining: + chunk = stream.recv(remaining) + check(chunk, "daemon closed during rendezvous response") + chunks.append(chunk) + remaining -= len(chunk) + return b"".join(chunks) + + +def probe_future_generation_rendezvous( + socket_path, active_version, active_build, requested_version, requested_build +): + """Use only the permanent envelope; future detailed wire bytes never run.""" + request = ( + struct.pack(">I", 1) + + fixed_wire_text(requested_version, RENDEZVOUS_VERSION_CAP) + + fixed_wire_text(requested_build, RENDEZVOUS_BUILD_CAP) + ) + check(len(request) == RENDEZVOUS_REQUEST_SIZE, "bad rendezvous request fixture") + header = struct.pack( + ">4sBBHI", b"CBMD", 1, 1, 1, RENDEZVOUS_REQUEST_SIZE + ) + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as stream: + stream.settimeout(10) + stream.connect(str(socket_path)) + stream.sendall(header + request) + response_header = recv_exact(stream, 12) + magic, frame_version, frame_type, operation, length = struct.unpack( + ">4sBBHI", response_header + ) + check(magic == b"CBMD", "future probe received wrong frame magic") + check(frame_version == 1, "future probe received unstable frame version") + check(frame_type == 2 and operation == 1, "future probe received wrong response kind") + check(length == RENDEZVOUS_RESPONSE_SIZE, "future probe received resized response") + response = recv_exact(stream, length) + + connect_status, hello_status, client_id, process_id, conflict_status = struct.unpack_from( + ">IIQQI", response, 0 + ) + check(connect_status == 2, "future generation was not rejected as a conflict") + check(hello_status == 2 and conflict_status == 2, "future version conflict reason was lost") + check(client_id == 0 and process_id == 0, "rejected generation received an identity") + check( + decode_fixed_wire_text(response, 28, RENDEZVOUS_VERSION_CAP) == active_version, + "future probe active version mismatch", + ) + check( + decode_fixed_wire_text(response, 92, RENDEZVOUS_BUILD_CAP) == active_build, + "future probe active build mismatch", + ) + check( + decode_fixed_wire_text(response, 157, RENDEZVOUS_VERSION_CAP) == requested_version, + "future probe requested version mismatch", + ) + check( + decode_fixed_wire_text(response, 221, RENDEZVOUS_BUILD_CAP) == requested_build, + "future probe requested build mismatch", + ) + expected_message = ( + "CBM could not start because a conflicting CBM process is active " + "(version; active version {}, build {}; requested version {}, build {}). " + "Close all CBM sessions and commands, then retry." + ).format(active_version, active_build, requested_version, requested_build) + check( + decode_fixed_wire_text(response, 286, RENDEZVOUS_MESSAGE_CAP) == expected_message, + "future probe explicit diagnostic mismatch", + ) + + +class McpClient: + def __init__(self, binary, env, stderr_path): + self.stderr_path = stderr_path + self.stderr_stream = stderr_path.open("w", encoding="utf-8") + self.process = subprocess.Popen( + [str(binary)], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=self.stderr_stream, + text=True, + bufsize=1, + env=env, + ) + self.condition = threading.Condition() + self.responses = {} + self.other_output = [] + self.stdout_closed = False + self.reader = threading.Thread(target=self._read_stdout, daemon=True) + self.reader.start() + + def _read_stdout(self): + assert self.process.stdout is not None + for raw_line in self.process.stdout: + line = raw_line.strip() + if not line: + continue + try: + value = json.loads(line) + except json.JSONDecodeError: + value = None + with self.condition: + if isinstance(value, dict) and "id" in value: + self.responses[value["id"]] = value + else: + self.other_output.append(line) + self.condition.notify_all() + with self.condition: + self.stdout_closed = True + self.condition.notify_all() + + def send(self, value): + check(self.process.stdin is not None, "client stdin is already closed") + payload = json.dumps(value, separators=(",", ":")) + try: + self.process.stdin.write(payload + "\n") + self.process.stdin.flush() + except (BrokenPipeError, OSError) as exc: + raise SmokeFailure("thin client closed while sending: " + str(exc)) from exc + + def wait_response(self, request_id, timeout=START_TIMEOUT): + deadline = time.monotonic() + timeout + with self.condition: + while request_id not in self.responses: + remaining = deadline - time.monotonic() + if remaining <= 0 or self.stdout_closed: + break + self.condition.wait(remaining) + if request_id in self.responses: + return self.responses[request_id] + raise SmokeFailure( + "no response for request {} (exit={}, stderr={!r}, other={!r})".format( + request_id, + self.process.poll(), + self.stderr_text(), + self.other_output, + ) + ) + + def has_response(self, request_id): + with self.condition: + return request_id in self.responses + + def close_input(self): + if self.process.stdin is not None and not self.process.stdin.closed: + try: + self.process.stdin.close() + except OSError: + pass + + def wait(self, timeout): + return self.process.wait(timeout=timeout) + + def stderr_text(self): + self.stderr_stream.flush() + try: + return self.stderr_path.read_text(encoding="utf-8", errors="replace") + except OSError: + return "" + + def cleanup(self): + self.close_input() + try: + self.process.wait(timeout=8) + except subprocess.TimeoutExpired: + self.process.terminate() + try: + self.process.wait(timeout=3) + except subprocess.TimeoutExpired: + self.process.kill() + self.process.wait(timeout=3) + self.reader.join(timeout=1) + self.stderr_stream.close() + + +def assert_rpc_success(response, label): + check("error" not in response, label + " returned JSON-RPC error: " + repr(response)) + check("result" in response, label + " returned no result: " + repr(response)) + + +def assert_rpc_cancelled(response, label): + error = response.get("error") + check( + isinstance(error, dict) and error.get("code") == -32800, + label + " did not return JSON-RPC request-cancelled: " + repr(response), + ) + check("result" not in response, label + " returned both error and result") + + +def start_ready_mcp_client( + binary, env, stderr_path, clients, initialize_params, request_base, label +): + client = McpClient(binary, env, stderr_path) + clients.append(client) + client.send( + { + "jsonrpc": "2.0", + "id": request_base, + "method": "initialize", + "params": initialize_params, + } + ) + assert_rpc_success(client.wait_response(request_base), label + " initialize") + client.send( + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + } + ) + client.send( + { + "jsonrpc": "2.0", + "id": request_base + 1, + "method": "tools/call", + "params": {"name": "list_projects", "arguments": {}}, + } + ) + assert_rpc_success( + client.wait_response(request_base + 1), label + " list_projects" + ) + return client + + +def mcp_result_json_payloads(result): + content = result.get("content") if isinstance(result, dict) else None + payloads = [] + if isinstance(content, list): + for item in content: + text = item.get("text") if isinstance(item, dict) else None + if not isinstance(text, str): + continue + try: + payload = json.loads(text) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + payloads.append(payload) + return payloads + + +def tool_json_payloads(response): + return mcp_result_json_payloads(response.get("result")) + + +def assert_indexed_tool_response(response, label): + assert_rpc_success(response, label) + statuses = [payload.get("status") for payload in tool_json_payloads(response)] + check( + "indexed" in statuses, + label + " did not complete a clean index: " + repr(response), + ) + + +def run_successful_activation( + binary, + env, + tmpdir, + activation_log, + label, + action, + arguments, + expected_source_build, + minimum_daemon_clients=1, +): + """Run one native activation and bind its durable audit trail to its PID.""" + records_before = len(json_records(activation_log)) + process = subprocess.Popen( + [str(binary)] + arguments, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + stdout, stderr = process.communicate(timeout=SHUTDOWN_TIMEOUT) + except subprocess.TimeoutExpired: + process.terminate() + try: + process.communicate(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=3) + raise + (tmpdir / (label + ".out")).write_text(stdout, encoding="utf-8") + (tmpdir / (label + ".err")).write_text(stderr, encoding="utf-8") + check( + process.returncode == 0, + label + " failed: stdout={!r}, stderr={!r}".format(stdout, stderr), + ) + check( + "Stopping active CBM sessions and operations for {}...".format(action) + in stdout, + label + " did not show coordination progress: " + repr(stdout), + ) + check( + RESTART_GUIDANCE in stdout, + label + " did not print the exact restart guidance: " + repr(stdout), + ) + + records = json_records(activation_log)[records_before:] + records = [ + record + for record in records + if record.get("event") == "cbm.activation" + and record.get("requester_pid") == process.pid + ] + phases = [record.get("phase") for record in records] + check( + phases == ["requested", "daemon_stopped", "completed"], + label + " activation audit sequence mismatch: " + repr(records), + ) + for record in records: + check(record.get("action") == action, label + " audit action mismatch") + check( + record.get("source_build") == expected_source_build, + label + " audit source build mismatch", + ) + check( + isinstance(record.get("source_version"), str) + and record["source_version"], + label + " audit source version missing", + ) + check( + isinstance(record.get("timestamp_unix_s"), int) + and record["timestamp_unix_s"] > 0, + label + " audit timestamp missing", + ) + check( + record.get("restart_required") is True, + label + " audit restart marker missing", + ) + if action in ("install", "update"): + target_build = record.get("target_build") + check( + isinstance(target_build, str) + and len(target_build) == 64 + and all(character in "0123456789abcdef" for character in target_build), + label + " did not audit the pre-staged target build", + ) + if action == "install": + check( + record.get("target_version") == record.get("source_version"), + label + " install target version audit mismatch", + ) + stopped = records[1] + check( + stopped.get("daemon_active_clients", 0) >= minimum_daemon_clients, + label + " audit did not record the drained daemon clients: " + repr(stopped), + ) + check( + stopped.get("daemon_active_connections", 0) >= minimum_daemon_clients, + label + " audit did not record the drained daemon connections: " + + repr(stopped), + ) + + log_status = os.lstat(activation_log) + check(stat.S_ISREG(log_status.st_mode), "activation log is not a regular file") + check(log_status.st_uid == os.geteuid(), "activation log has wrong owner") + check(log_status.st_nlink == 1, "activation log has multiple hard links") + check(stat.S_IMODE(log_status.st_mode) == 0o600, "activation log is not 0600") + return records + + +def assert_coordination_idle(socket_path, lock_specs, description): + def is_idle(): + return not os.path.lexists(str(socket_path)) and all( + lock_status(path, record_lock) == "free" + for path, record_lock in lock_specs + ) + + wait_until(is_idle, SHUTDOWN_TIMEOUT, description) + for path, record_lock in lock_specs: + status = lock_status(path, record_lock) + check(status == "free", "{} remained {} after {}".format(path, status, description)) + + +def create_local_update_release(release_dir, candidate): + """Create the exact platform archive/checksum pair consumed by update.""" + release_dir.mkdir(parents=True, exist_ok=True) + release_dir.chmod(0o700) + if sys.platform == "darwin": + os_name = "darwin" + portable = "" + else: + os_name = "linux" + portable = "-portable" + machine = platform.machine().lower() + arch = "arm64" if machine in ("arm64", "aarch64") else "amd64" + asset_name = "codebase-memory-mcp-{}-{}{}.tar.gz".format( + os_name, arch, portable + ) + archive = release_dir / asset_name + with tarfile.open(archive, "w:gz") as stream: + stream.add(str(candidate), arcname="codebase-memory-mcp", recursive=False) + digest = sha256_file(archive) + (release_dir / "checksums.txt").write_text( + "{} {}\n".format(digest, asset_name), encoding="ascii" + ) + return "file://" + str(release_dir) + + +def assert_no_activation_artifacts(directory, label): + artifacts = ( + sorted(path.name for path in directory.iterdir() if path.name.startswith(".cbm-")) + if directory.exists() + else [] + ) + check(not artifacts, label + " left transaction artifacts: " + repr(artifacts)) + + +def main(): + if os.name == "nt" or sys.platform.startswith(("cygwin", "msys")): + if os.environ.get("CBM_DAEMON_SMOKE_REQUIRE_RUN") == "1": + raise SmokeFailure( + "required daemon smoke is POSIX-only; Windows needs a separate " + "real-binary named-pipe/Job Object smoke" + ) + print("SKIP: daemon smoke currently validates POSIX socket/lock lifecycle only") + return 0 + + root = Path(__file__).resolve().parent.parent + binary = Path(sys.argv[1] if len(sys.argv) > 1 else root / "build/c/codebase-memory-mcp") + binary = binary.resolve() + check(binary.is_file() and os.access(binary, os.X_OK), "missing executable: " + str(binary)) + + runtime_parent = Path("/private/tmp" if sys.platform == "darwin" else "/tmp") + runtime_dir = runtime_parent / ("cbm-daemon-" + str(os.geteuid())) + socket_path = runtime_dir / ("cbm-" + RENDEZVOUS_KEY + ".sock") + startup_lock = runtime_dir / ("cbm-" + RENDEZVOUS_KEY + ".lock") + lifetime_lock = runtime_dir / ("cbm-" + RENDEZVOUS_KEY + ".lifetime.lock") + cohort_admission_lock = runtime_dir / "cbm-version-cohort-admission-v1.lock" + cohort_lifetime_lock = runtime_dir / "cbm-version-cohort-lifetime-v1.lock" + cohort_maintenance_lock = runtime_dir / "cbm-version-cohort-maintenance-v1.lock" + cohort_daemon_lock = runtime_dir / "cbm-version-cohort-daemon-v1.lock" + coordination_locks = ( + (startup_lock, False), + (lifetime_lock, True), + (cohort_admission_lock, True), + (cohort_lifetime_lock, True), + (cohort_maintenance_lock, True), + (cohort_daemon_lock, True), + ) + + coordination_before = [ + (path, lock_status(path, record_lock)) + for path, record_lock in coordination_locks + ] + if any(status == "held" for _, status in coordination_before): + if os.environ.get("CBM_DAEMON_SMOKE_REQUIRE_RUN") == "1": + raise SmokeFailure( + "another CBM process or activation is active or starting; " + "required smoke needs a clean account-wide rendezvous" + ) + print("SKIP: another CBM process or activation is active or starting") + return 0 + for path, status in coordination_before: + check(status == "free", "unsafe coordination lock {}: {}".format(path, status)) + + clients = [] + with tempfile.TemporaryDirectory(prefix="cbm-daemon-smoke-") as raw_tmpdir: + tmpdir = Path(raw_tmpdir) + home = tmpdir / "home" + cache = tmpdir / "cache" + repo = tmpdir / "repo" + success_repo = tmpdir / "success-repo" + target_dir = home / ".local/bin" + for directory in (home, cache, repo, success_repo, target_dir): + directory.mkdir(parents=True, exist_ok=True) + (repo / "hang_me.py").write_text("def smoke():\n return 1\n", encoding="utf-8") + (success_repo / "tiny.py").write_text( + "def daemon_index_smoke():\n return 1\n", encoding="utf-8" + ) + target_binary = target_dir / "codebase-memory-mcp" + target_binary.write_bytes(b"installed binary sentinel\n") + index_path = cache / "smoke-project.db" + index_path.write_bytes(b"index sentinel\n") + descendant_pid_path = tmpdir / "worker-descendant.pid" + daemon_log = cache / "logs/cbm-daemon.log" + conflict_log = cache / "logs/daemon-conflicts.ndjson" + activation_log = cache / "logs/activation-events.ndjson" + + env = os.environ.copy() + env.update( + { + "HOME": str(home), + "CBM_CACHE_DIR": str(cache), + "CBM_LOG_LEVEL": "info", + "CBM_LOG_FORMAT": "json", + "CBM_TEST_HANG_ON": "hang_me", + "CBM_TEST_WORKER_DESCENDANT_PID_FILE": str(descendant_pid_path), + } + ) + + cli_first_process = None + cli_first_descendant_pid = None + cli_first_worker_pid = None + cli_first_worker_pgid = None + cli_first_worker_stopped = False + cli_first_competitor = None + cli_first_competitor_stderr = None + activation_local_process = None + activation_local_descendant_pid = None + activation_local_worker_pid = None + activation_local_worker_pgid = None + try: + version_result = subprocess.run( + [str(binary), "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + timeout=10, + check=False, + ) + check(version_result.returncode == 0, "--version failed: " + version_result.stderr) + version_prefix = "codebase-memory-mcp " + check(version_result.stdout.startswith(version_prefix), "unexpected --version output") + semantic_version = version_result.stdout.strip()[len(version_prefix) :] + + local_cli = subprocess.run( + [str(binary), "cli", "--progress", "--json", "list_projects"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + timeout=15, + check=False, + ) + check(local_cli.returncode == 0, "local CLI failed: " + local_cli.stderr) + try: + json.loads(local_cli.stdout) + except json.JSONDecodeError as exc: + raise SmokeFailure("local CLI polluted JSON stdout: " + repr(local_cli.stdout)) from exc + check( + "Preparing one-shot local CBM command" in local_cli.stderr, + "local CLI did not emit coordination feedback: " + + repr(local_cli.stderr), + ) + check( + "Running list_projects locally" in local_cli.stderr, + "local CLI did not emit immediate progress feedback: " + repr(local_cli.stderr), + ) + check( + "Completed list_projects" in local_cli.stderr, + "local CLI did not emit completion feedback: " + repr(local_cli.stderr), + ) + check(not socket_path.exists(), "standalone CLI created a daemon socket") + check( + lock_status(lifetime_lock, record_lock=True) == "free", + "standalone CLI retained a daemon lifetime reservation", + ) + check(not daemon_log.exists(), "standalone CLI started the coordination daemon") + + c1 = McpClient(binary, env, tmpdir / "client-1.err") + clients.append(c1) + c2 = McpClient(binary, env, tmpdir / "client-2.err") + clients.append(c2) + + initialize_params = { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "cbm-daemon-smoke", "version": "1"}, + } + c1.send( + {"jsonrpc": "2.0", "id": 101, "method": "initialize", "params": initialize_params} + ) + c2.send( + {"jsonrpc": "2.0", "id": 201, "method": "initialize", "params": initialize_params} + ) + assert_rpc_success(c1.wait_response(101), "client 1 initialize") + assert_rpc_success(c2.wait_response(201), "client 2 initialize") + c1.send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}) + c2.send({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}) + c1.send( + { + "jsonrpc": "2.0", + "id": 102, + "method": "tools/call", + "params": {"name": "list_projects", "arguments": {}}, + } + ) + c2.send( + { + "jsonrpc": "2.0", + "id": 202, + "method": "tools/call", + "params": {"name": "list_projects", "arguments": {}}, + } + ) + assert_rpc_success(c1.wait_response(102), "client 1 list_projects") + assert_rpc_success(c2.wait_response(202), "client 2 list_projects") + + # A cancellation notification is request-scoped control, not a + # shorthand for closing the whole MCP session. A stale target + # while idle must be ignored and the same frontend must continue + # serving subsequent requests. + c1.send( + { + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": {"requestId": 999999}, + } + ) + c1.send({"jsonrpc": "2.0", "id": 104, "method": "ping", "params": {}}) + assert_rpc_success( + c1.wait_response(104), "request after stale cancellation" + ) + check( + c1.process.poll() is None and c2.process.poll() is None, + "thin client exited early", + ) + + wait_until( + lambda: socket_path.exists() and lock_status(lifetime_lock, True) == "held", + START_TIMEOUT, + "daemon endpoint and lifetime reservation", + ) + runtime_status = os.stat(runtime_dir) + socket_status = os.lstat(socket_path) + check(runtime_status.st_uid == os.geteuid(), "runtime directory has wrong owner") + check(stat.S_IMODE(runtime_status.st_mode) == 0o700, "runtime directory is not 0700") + check(stat.S_ISSOCK(socket_status.st_mode), "daemon endpoint is not a Unix socket") + check(socket_status.st_uid == os.geteuid(), "daemon endpoint has wrong owner") + wait_until( + lambda: len(json_events(daemon_log, "daemon.start")) == 1, + START_TIMEOUT, + "one daemon.start event", + ) + check( + len(json_events(daemon_log, "daemon.start")) == 1, + "two simultaneous clients spawned more than one daemon", + ) + + # A real daemon-backed index must cross the physical-worker + # boundary and finish. This is the regression for the former + # self-deadlock where the daemon parent held the same project lock + # its worker was required to acquire. + c2.send( + { + "jsonrpc": "2.0", + "id": 204, + "method": "tools/call", + "params": { + "name": "index_repository", + "arguments": { + "repo_path": str(success_repo), + "name": "smoke-daemon-success", + "mode": "fast", + }, + }, + } + ) + assert_indexed_tool_response( + c2.wait_response(204, timeout=OPERATION_TIMEOUT), + "daemon-backed tiny index", + ) + wait_until( + descendant_pid_path.exists, + OPERATION_TIMEOUT, + "successful daemon worker containment probe", + ) + successful_descendant_pid = int( + descendant_pid_path.read_text(encoding="ascii").strip() + ) + wait_until( + lambda: process_gone_or_zombie(successful_descendant_pid), + 20, + "successful daemon worker tree to quiesce", + ) + descendant_pid_path.unlink(missing_ok=True) + c2.send( + { + "jsonrpc": "2.0", + "id": 207, + "method": "tools/call", + "params": {"name": "list_projects", "arguments": {}}, + } + ) + persisted_projects = c2.wait_response(207) + assert_rpc_success( + persisted_projects, "daemon-backed persisted project listing" + ) + check( + any( + any( + isinstance(project, dict) + and project.get("name") == "smoke-daemon-success" + for project in payload.get("projects", []) + ) + for payload in tool_json_payloads(persisted_projects) + if isinstance(payload.get("projects"), list) + ), + "daemon-backed index was not persisted in list_projects: " + + repr(persisted_projects), + ) + + active_local_cli = subprocess.run( + [str(binary), "cli", "--json", "list_projects"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + timeout=15, + check=False, + ) + check( + active_local_cli.returncode == 0, + "same-build local CLI did not remain independent beside the daemon: " + + active_local_cli.stderr, + ) + try: + json.loads(active_local_cli.stdout) + except json.JSONDecodeError as exc: + raise SmokeFailure( + "same-build local CLI polluted JSON stdout: " + + repr(active_local_cli.stdout) + ) from exc + check( + len(json_events(daemon_log, "daemon.start")) == 1, + "same-build local CLI restarted the daemon", + ) + + active_fingerprint = sha256_file(binary) + future_version = "9.0.0-future-wire-v2" + future_fingerprint = "c" * 64 + probe_future_generation_rendezvous( + socket_path, + semantic_version, + active_fingerprint, + future_version, + future_fingerprint, + ) + wait_until( + lambda: any( + item.get("reason") == "version" + and item.get("requested_version") == future_version + and item.get("requested_build") == future_fingerprint + for item in json_events(conflict_log, "daemon.version_conflict") + ), + 10, + "future-generation stable-envelope conflict log", + ) + + conflict_binary = tmpdir / "codebase-memory-mcp-conflict" + shutil.copy2(binary, conflict_binary) + if sys.platform == "darwin": + # Current Apple linkers ad-hoc sign arm64 executables. Appending + # bytes corrupts that Mach-O before codesign can replace the + # signature, so create a valid exact-build mismatch by signing + # the copy with a distinct identifier instead. + signed = subprocess.run( + [ + "codesign", + "--force", + "--sign", + "-", + "--identifier", + "com.deusdata.cbm.daemon-smoke-conflict", + str(conflict_binary), + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=20, + check=False, + ) + check( + signed.returncode == 0, + "could not ad-hoc sign conflict binary: " + signed.stderr, + ) + else: + # ELF permits trailing bytes, giving the fixture a different + # exact build fingerprint while preserving executability. + with conflict_binary.open("ab") as stream: + stream.write(b"\x00cbm-daemon-smoke-conflicting-build") + requested_fingerprint = sha256_file(conflict_binary) + check( + active_fingerprint != requested_fingerprint, + "conflict fixture hash did not change", + ) + expected_conflict = ( + "CBM could not start because a conflicting CBM process is active " + "(build; active version {}, build {}; requested version {}, build {}). " + "Close all CBM sessions and commands, then retry." + ).format( + semantic_version, + active_fingerprint, + semantic_version, + requested_fingerprint, + ) + conflicts_before_cli = len( + [ + item + for item in json_events(conflict_log, "daemon.version_conflict") + if item.get("reason") == "build" + and item.get("requested_build") == requested_fingerprint + ] + ) + conflict_cli_result = subprocess.run( + [str(conflict_binary), "cli", "--progress", "--json", "list_projects"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + timeout=15, + check=False, + ) + check( + conflict_cli_result.returncode != 0, + "conflicting one-shot CLI unexpectedly ran beside the active daemon", + ) + check( + "codebase-memory-mcp: " + expected_conflict + in conflict_cli_result.stderr, + "conflicting one-shot CLI did not emit the exact visible diagnostic: " + + repr(conflict_cli_result.stderr), + ) + wait_until( + lambda: len( + [ + item + for item in json_events(conflict_log, "daemon.version_conflict") + if item.get("reason") == "build" + and item.get("requested_build") == requested_fingerprint + ] + ) + > conflicts_before_cli, + 10, + "one-shot CLI durable conflict log", + ) + conflicts_before_mcp = len( + [ + item + for item in json_events(conflict_log, "daemon.version_conflict") + if item.get("reason") == "build" + and item.get("requested_build") == requested_fingerprint + ] + ) + conflict_result = subprocess.run( + [str(conflict_binary)], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + timeout=15, + check=False, + ) + check(conflict_result.returncode != 0, "conflicting build unexpectedly connected") + check( + expected_conflict in conflict_result.stderr, + "conflicting build did not emit exact visible diagnostic: " + + repr(conflict_result.stderr), + ) + wait_until( + lambda: len( + [ + item + for item in json_events( + conflict_log, "daemon.version_conflict" + ) + if item.get("reason") == "build" + and item.get("requested_build") == requested_fingerprint + ] + ) + > conflicts_before_mcp, + 10, + "MCP durable conflict log increment", + ) + conflict_records = json_events(conflict_log, "daemon.version_conflict") + check( + any( + item.get("reason") == "build" + and item.get("active_build") == active_fingerprint + and item.get("requested_build") == requested_fingerprint + for item in conflict_records + ), + "conflict log did not bind the exact active/requested builds", + ) + conflict_status = os.stat(conflict_log) + check(conflict_status.st_uid == os.geteuid(), "conflict log has wrong owner") + check(stat.S_IMODE(conflict_status.st_mode) == 0o600, "conflict log is not 0600") + + c1.send( + { + "jsonrpc": "2.0", + "id": 103, + "method": "tools/call", + "params": { + "name": "index_repository", + "arguments": {"repo_path": str(repo), "mode": "fast"}, + }, + } + ) + wait_until(descendant_pid_path.exists, OPERATION_TIMEOUT, "session-owned index worker") + descendant_pid = int(descendant_pid_path.read_text(encoding="ascii").strip()) + check( + not process_gone_or_zombie(descendant_pid), + "index worker descendant exited before cancellation", + ) + check( + not c1.has_response(103), + "index request completed instead of reaching hang injector", + ) + check(c2.process.poll() is None, "second session exited during first session operation") + + # A wrong request id must not cancel the active operation or close + # the session. The exact request id must then promptly tear down + # this session's supervised worker tree while c2 remains alive. + c1.send( + { + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": {"requestId": 999999}, + } + ) + # This black-box notification has no acknowledgement, so this is + # only a production sanity window. Deterministic wrong-token + # routing and completion barriers live in daemon_runtime tests. + wrong_cancel_deadline = time.monotonic() + 0.5 + while time.monotonic() < wrong_cancel_deadline: + check( + c1.process.poll() is None, + "wrong request-id cancellation closed the frontend", + ) + check( + not process_gone_or_zombie(descendant_pid), + "wrong request-id cancellation stopped the worker", + ) + time.sleep(0.02) + c1.send( + { + "jsonrpc": "2.0", + "method": "notifications/cancelled", + "params": {"requestId": 103}, + } + ) + assert_rpc_cancelled( + c1.wait_response(103, timeout=20), + "matching request cancellation", + ) + wait_until( + lambda: process_gone_or_zombie(descendant_pid), + 20, + "request-id-cancelled session worker tree to exit", + ) + c1.send({"jsonrpc": "2.0", "id": 105, "method": "ping", "params": {}}) + assert_rpc_success( + c1.wait_response(105), "request after matching cancellation" + ) + check( + c1.process.poll() is None, + "matching request cancellation closed the whole MCP session", + ) + + # EOF remains the terminal ownership boundary. Start a second + # session-owned operation, close this frontend, and require its + # independent worker tree to be contained without affecting c2. + descendant_pid_path.unlink(missing_ok=True) + c1.send( + { + "jsonrpc": "2.0", + "id": 106, + "method": "tools/call", + "params": { + "name": "index_repository", + "arguments": { + "repo_path": str(repo), + "name": "smoke-session-close", + "mode": "fast", + }, + }, + } + ) + wait_until( + descendant_pid_path.exists, + OPERATION_TIMEOUT, + "session-close index worker", + ) + close_descendant_pid = int( + descendant_pid_path.read_text(encoding="ascii").strip() + ) + check( + not process_gone_or_zombie(close_descendant_pid), + "session-close worker exited before EOF", + ) + c1.close_input() + check(c1.wait(timeout=15) == 0, "EOF-cancelled frontend exited nonzero") + wait_until( + lambda: process_gone_or_zombie(close_descendant_pid), + 20, + "EOF-cancelled session worker tree to exit", + ) + c2.send( + { + "jsonrpc": "2.0", + "id": 203, + "method": "tools/call", + "params": {"name": "list_projects", "arguments": {}}, + } + ) + assert_rpc_success(c2.wait_response(203), "surviving client list_projects") + check( + lock_status(lifetime_lock, True) == "held", + "daemon stopped while one client remained", + ) + + c2.close_input() + check(c2.wait(timeout=15) == 0, "final generation-one frontend exited nonzero") + + # The final DISCONNECT acknowledgement is sent only after the old + # runtime has entered STOPPING. Launch the next MCP frontend + # immediately: the same bootstrap attempt must wait for cleanup, + # serialize, and become the first client of generation two. + turnover_client = McpClient( + binary, env, tmpdir / "turnover-client.err" + ) + clients.append(turnover_client) + turnover_client.send( + { + "jsonrpc": "2.0", + "id": 205, + "method": "initialize", + "params": initialize_params, + } + ) + assert_rpc_success( + turnover_client.wait_response(205), + "immediate post-shutdown client initialize", + ) + turnover_client.send( + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + } + ) + turnover_client.send( + { + "jsonrpc": "2.0", + "id": 206, + "method": "tools/call", + "params": {"name": "list_projects", "arguments": {}}, + } + ) + assert_rpc_success( + turnover_client.wait_response(206), + "immediate post-shutdown client list_projects", + ) + wait_until( + lambda: len(json_events(daemon_log, "daemon.start")) == 2, + START_TIMEOUT, + "second daemon generation after immediate turnover", + ) + turnover_client.close_input() + check( + turnover_client.wait(timeout=15) == 0, + "turnover generation frontend exited nonzero", + ) + wait_until( + lambda: not socket_path.exists() and lock_status(lifetime_lock, True) == "free", + SHUTDOWN_TIMEOUT, + "turnover generation endpoint and reservation cleanup", + ) + wait_until( + lambda: len(json_events(daemon_log, "daemon.stop")) == 2, + 10, + "two daemon.stop events", + ) + check( + len(json_events(daemon_log, "daemon.start")) == 2, + "immediate turnover did not start exactly one replacement daemon", + ) + check( + len(json_events(daemon_log, "daemon.stop")) == 2, + "turnover generations did not each stop exactly once", + ) + check( + daemon_lifecycle_sequence(daemon_log)[:4] + == ["daemon.start", "daemon.stop", "daemon.start", "daemon.stop"], + "daemon turnover lifecycle overlapped or reordered: " + + repr(daemon_lifecycle_sequence(daemon_log)), + ) + wait_until( + lambda: lock_status(startup_lock, record_lock=False) == "free", + SHUTDOWN_TIMEOUT, + "turnover generation startup/participant guard release", + ) + check( + process_gone_or_zombie(descendant_pid), + "cancelled worker tree survived daemon shutdown", + ) + + # Reverse the version-admission order: a one-shot CLI and its + # supervised worker own the exact-build cohort first. A different + # ordinary executable must fail before it can spawn/connect a + # daemon. Native activation is exercised separately below because + # it deliberately publishes maintenance and cancels this work. + cli_first_pid_path = tmpdir / "cli-first-worker-descendant.pid" + cli_first_lock_owner_path = ( + tmpdir / "cli-first-worker-project-lock.pid" + ) + cli_first_env = env.copy() + cli_first_env["CBM_TEST_WORKER_DESCENDANT_PID_FILE"] = str( + cli_first_pid_path + ) + cli_first_env["CBM_TEST_WORKER_PROJECT_LOCK_PID_FILE"] = str( + cli_first_lock_owner_path + ) + cli_first_process = subprocess.Popen( + [ + str(binary), + "cli", + "--progress", + "--json", + "index_repository", + "--repo-path", + str(repo), + "--name", + "smoke-cli-first", + "--mode", + "fast", + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=cli_first_env, + ) + wait_until( + lambda: read_private_pid_fields(cli_first_pid_path, 1) + is not None, + OPERATION_TIMEOUT, + "CLI-first supervised worker", + ) + wait_until( + lambda: read_private_pid_fields(cli_first_lock_owner_path, 2) + is not None, + OPERATION_TIMEOUT, + "physical worker to acquire the native project lock", + ) + (cli_first_descendant_pid,) = read_private_pid_fields( + cli_first_pid_path, 1 + ) + cli_first_worker_pid, cli_first_worker_pgid = ( + read_private_pid_fields(cli_first_lock_owner_path, 2) + ) + check( + cli_first_process.poll() is None + and not process_gone_or_zombie(cli_first_descendant_pid) + and worker_tree_identity_matches( + cli_first_process.pid, + cli_first_worker_pid, + cli_first_worker_pgid, + cli_first_descendant_pid, + ), + "CLI-first worker ownership marker did not identify the " + "supervisor's isolated physical worker tree", + ) + check(not socket_path.exists(), "CLI-first operation created a daemon socket") + check( + lock_status(lifetime_lock, True) == "free", + "CLI-first operation acquired the daemon lifetime reservation", + ) + check( + lock_status(startup_lock, record_lock=False) == "held", + "CLI-first operation did not retain the daemon-start transition", + ) + + cli_first_conflicts_before = len( + [ + item + for item in json_events(conflict_log, "daemon.version_conflict") + if item.get("requested_build") == requested_fingerprint + ] + ) + cli_first_conflict = subprocess.run( + [str(conflict_binary)], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + timeout=15, + check=False, + ) + check( + cli_first_conflict.returncode != 0, + "conflicting MCP started while a different CLI build was active", + ) + check( + "CBM could not start because a conflicting CBM process is active" + in cli_first_conflict.stderr, + "CLI-first conflict did not emit a visible diagnostic: " + + repr(cli_first_conflict.stderr), + ) + check( + not socket_path.exists() and lock_status(lifetime_lock, True) == "free", + "rejected CLI-first conflict spawned a daemon generation", + ) + wait_until( + lambda: len( + [ + item + for item in json_events(conflict_log, "daemon.version_conflict") + if item.get("reason") == "build" + and item.get("requested_build") == requested_fingerprint + ] + ) + > cli_first_conflicts_before, + 10, + "CLI-first durable conflict log", + ) + # A same-build MCP session must still be able to start the shared + # daemon while an unrelated one-shot CLI operation is active. The + # daemon then stops with its final MCP session; the standalone CLI + # neither becomes a daemon session nor keeps that daemon alive. + overlap_client = McpClient( + binary, cli_first_env, tmpdir / "cli-first-overlap-client.err" + ) + clients.append(overlap_client) + overlap_client.send( + { + "jsonrpc": "2.0", + "id": 301, + "method": "initialize", + "params": initialize_params, + } + ) + assert_rpc_success( + overlap_client.wait_response(301), + "CLI-first overlap client initialize", + ) + overlap_client.send( + { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + } + ) + overlap_client.send( + { + "jsonrpc": "2.0", + "id": 302, + "method": "tools/call", + "params": {"name": "list_projects", "arguments": {}}, + } + ) + assert_rpc_success( + overlap_client.wait_response(302), + "CLI-first overlap client list_projects", + ) + wait_until( + lambda: socket_path.exists() + and lock_status(lifetime_lock, True) == "held", + START_TIMEOUT, + "same-build daemon startup during local CLI work", + ) + wait_until( + lambda: len(json_events(daemon_log, "daemon.start")) == 3, + START_TIMEOUT, + "third daemon generation during local CLI work", + ) + check( + cli_first_process.poll() is None + and not process_gone_or_zombie(cli_first_descendant_pid), + "daemon startup terminated the independent local CLI operation", + ) + + overlap_client.close_input() + check( + overlap_client.wait(timeout=15) == 0, + "CLI-overlap daemon frontend exited nonzero", + ) + wait_until( + lambda: not socket_path.exists() + and lock_status(lifetime_lock, True) == "free", + SHUTDOWN_TIMEOUT, + "overlap daemon shutdown after its final MCP session", + ) + wait_until( + lambda: len(json_events(daemon_log, "daemon.stop")) == 3, + 10, + "third daemon.stop event", + ) + check( + daemon_lifecycle_sequence(daemon_log)[:6] + == [ + "daemon.start", + "daemon.stop", + "daemon.start", + "daemon.stop", + "daemon.start", + "daemon.stop", + ], + "three daemon generations overlapped or reordered: " + + repr(daemon_lifecycle_sequence(daemon_log)), + ) + check( + cli_first_process.poll() is None + and not process_gone_or_zombie(cli_first_descendant_pid), + "final MCP session shutdown cancelled independent local CLI work", + ) + check( + lock_status(startup_lock, record_lock=False) == "held", + "local CLI lost its legacy compatibility guard after daemon shutdown", + ) + + # The owner-only marker was published by the physical worker only + # after it acquired the native lease. Freeze that verified worker + # and require a same-project competitor to remain blocked, proving + # the lease stays at the worker boundary rather than its polling + # supervisor. Revalidate the complete ancestry/group immediately + # before signaling so a stale or reparented PID is never stopped. + check( + cli_first_process.poll() is None + and worker_tree_identity_matches( + cli_first_process.pid, + cli_first_worker_pid, + cli_first_worker_pgid, + cli_first_descendant_pid, + ), + "CLI-first physical worker identity changed before SIGSTOP", + ) + try: + os.kill(cli_first_worker_pid, signal.SIGSTOP) + except OSError as exc: + raise SmokeFailure( + "could not stop the verified CLI-first worker: " + str(exc) + ) from exc + cli_first_worker_stopped = True + wait_until( + lambda: worker_is_stopped( + cli_first_worker_pid, cli_first_worker_pgid + ), + 5, + "verified CLI-first worker to stop", + ) + competitor_stderr_path = tmpdir / "cli-first-competitor.err" + cli_first_competitor_stderr = competitor_stderr_path.open( + "w", encoding="utf-8" + ) + cli_first_competitor = subprocess.Popen( + [ + str(binary), + "cli", + "--progress", + "--json", + "delete_project", + "--project", + "smoke-cli-first", + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=cli_first_competitor_stderr, + text=True, + env=cli_first_env, + ) + + def competitor_waiting_or_exited(): + try: + text = competitor_stderr_path.read_text( + encoding="utf-8", errors="replace" + ) + except OSError: + text = "" + return ( + "Waiting for another CBM mutation of smoke-cli-first" in text + or cli_first_competitor.poll() is not None + ) + + wait_until( + competitor_waiting_or_exited, + 15, + "same-project competitor to reach worker-owned project lock", + ) + cli_first_competitor_stderr.flush() + competitor_stderr = competitor_stderr_path.read_text( + encoding="utf-8", errors="replace" + ) + check( + cli_first_competitor.poll() is None + and "Waiting for another CBM mutation of smoke-cli-first" + in competitor_stderr, + "worker did not retain same-project exclusion while frozen: " + + competitor_stderr, + ) + + # SIGTERM would ask the CLI supervisor to perform an orderly + # cancellation. SIGKILL models an abrupt supervisor crash. POSIX + # may immediately SIGHUP/SIGCONT the newly orphaned stopped group; + # if it has not, resume only the still-verified worker so its parent + # watchdog can quiesce the group and release the competitor. + cli_first_process.kill() + try: + cli_first_process.wait(timeout=5) + except subprocess.TimeoutExpired: + cli_first_process.kill() + cli_first_process.wait(timeout=5) + if worker_tree_identity_matches( + None, + cli_first_worker_pid, + cli_first_worker_pgid, + cli_first_descendant_pid, + ): + try: + os.kill(cli_first_worker_pid, signal.SIGCONT) + except OSError: + pass + cli_first_worker_stopped = False + wait_until( + lambda: process_gone_or_zombie(cli_first_descendant_pid), + 20, + "CLI-first worker tree to exit after supervisor termination", + ) + try: + competitor_stdout, _ = cli_first_competitor.communicate(timeout=15) + except subprocess.TimeoutExpired: + cli_first_competitor.kill() + competitor_stdout, _ = cli_first_competitor.communicate(timeout=5) + try: + competitor_result = json.loads(competitor_stdout) + except json.JSONDecodeError as exc: + raise SmokeFailure( + "post-worker mutation polluted JSON stdout: " + + repr(competitor_stdout) + ) from exc + competitor_statuses = { + payload.get("status") + for payload in mcp_result_json_payloads(competitor_result) + } + check( + bool(competitor_statuses & {"deleted", "not_found"}), + "same-project mutation did not acquire the released worker lock: " + + repr(competitor_result) + + " stderr: " + + competitor_stderr_path.read_text( + encoding="utf-8", errors="replace" + ), + ) + if "deleted" in competitor_statuses: + check( + cli_first_competitor.returncode == 0, + "successful post-worker deletion exited nonzero", + ) + else: + check( + cli_first_competitor.returncode != 0, + "post-worker not_found tool error reported success", + ) + cli_first_competitor = None + cli_first_competitor_stderr.close() + cli_first_competitor_stderr = None + wait_until( + lambda: lock_status(startup_lock, record_lock=False) == "free", + 10, + "CLI-first daemon-start transition to release", + ) + + # Once the final participant exits, the cohort is crash-released + # and a different exact build may become the next generation. + turnover = subprocess.run( + [str(conflict_binary), "cli", "--json", "list_projects"], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + timeout=15, + check=False, + ) + check( + turnover.returncode == 0, + "exact-build cohort did not turn over after CLI exit: " + + turnover.stderr, + ) + try: + json.loads(turnover.stdout) + except json.JSONDecodeError as exc: + raise SmokeFailure( + "post-turnover CLI polluted JSON stdout: " + repr(turnover.stdout) + ) from exc + check( + not socket_path.exists() + and lock_status(lifetime_lock, record_lock=True) == "free" + and lock_status(startup_lock, record_lock=False) == "free", + "post-turnover CLI left daemon/startup ownership behind", + ) + check( + len(json_events(daemon_log, "daemon.start")) == 3 + and len(json_events(daemon_log, "daemon.stop")) == 3, + "final smoke state did not contain exactly three clean daemon generations", + ) + check( + daemon_lifecycle_sequence(daemon_log) + == [ + "daemon.start", + "daemon.stop", + "daemon.start", + "daemon.stop", + "daemon.start", + "daemon.stop", + ], + "unexpected final daemon lifecycle sequence: " + + repr(daemon_lifecycle_sequence(daemon_log)), + ) + + # Native activation is the deliberate exception to exact-build + # admission. Exercise that exception with a different-build + # candidate while both kinds of coordinated work are live: one + # MCP daemon session and one hanging local CLI worker tree. The + # candidate must authenticate to the old daemon, publish + # maintenance, cancel both, wait for their leases, and only then + # install into the explicit temporary directory. + source_binary_before = sha256_file(binary) + conflict_binary_before = sha256_file(conflict_binary) + check( + source_binary_before == active_fingerprint, + "source binary changed before activation smoke", + ) + check( + conflict_binary_before == requested_fingerprint, + "different-build candidate changed before activation smoke", + ) + install_dir = tmpdir / "activation-install-bin" + install_dir.mkdir(mode=0o700) + install_target = install_dir / "codebase-memory-mcp" + install_pid_path = tmpdir / "activation-install-worker-descendant.pid" + install_lock_owner_path = tmpdir / "activation-install-worker-lock.pid" + activation_local_env = env.copy() + activation_local_env["CBM_TEST_WORKER_DESCENDANT_PID_FILE"] = str( + install_pid_path + ) + activation_local_env["CBM_TEST_WORKER_PROJECT_LOCK_PID_FILE"] = str( + install_lock_owner_path + ) + + install_starts = len(json_events(daemon_log, "daemon.start")) + install_stops = len(json_events(daemon_log, "daemon.stop")) + install_client = start_ready_mcp_client( + binary, + env, + tmpdir / "activation-install-client.err", + clients, + initialize_params, + 401, + "activation install client", + ) + wait_until( + lambda: len(json_events(daemon_log, "daemon.start")) + == install_starts + 1, + START_TIMEOUT, + "install activation daemon generation", + ) + activation_local_process = subprocess.Popen( + [ + str(binary), + "cli", + "--progress", + "--json", + "index_repository", + "--repo-path", + str(repo), + "--name", + "smoke-activation-install", + "--mode", + "fast", + ], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=activation_local_env, + ) + wait_until( + lambda: read_private_pid_fields(install_pid_path, 1) is not None, + OPERATION_TIMEOUT, + "activation-cancelled CLI worker descendant", + ) + wait_until( + lambda: read_private_pid_fields(install_lock_owner_path, 2) + is not None, + OPERATION_TIMEOUT, + "activation-cancelled CLI worker lock owner", + ) + (activation_local_descendant_pid,) = read_private_pid_fields( + install_pid_path, 1 + ) + activation_local_worker_pid, activation_local_worker_pgid = ( + read_private_pid_fields(install_lock_owner_path, 2) + ) + check( + activation_local_process.poll() is None + and worker_tree_identity_matches( + activation_local_process.pid, + activation_local_worker_pid, + activation_local_worker_pgid, + activation_local_descendant_pid, + ), + "activation CLI marker did not identify its isolated worker tree", + ) + + install_activation_records = run_successful_activation( + conflict_binary, + env, + tmpdir, + activation_log, + "activation-install", + "install", + [ + "install", + "--force", + "--yes", + "--skip-config", + "--dir", + str(install_dir), + ], + requested_fingerprint, + ) + local_stdout, local_stderr = activation_local_process.communicate( + timeout=10 + ) + (tmpdir / "activation-local-cli.out").write_text( + local_stdout, encoding="utf-8" + ) + (tmpdir / "activation-local-cli.err").write_text( + local_stderr, encoding="utf-8" + ) + check( + activation_local_process.returncode != 0, + "maintenance-cancelled local CLI reported success", + ) + check( + "active CLI command is stopping for install/update/uninstall" + in local_stderr, + "local CLI did not report maintenance cancellation: " + + repr(local_stderr), + ) + wait_until( + lambda: process_gone_or_zombie(activation_local_descendant_pid), + 20, + "maintenance-cancelled local CLI worker tree", + ) + check( + install_client.wait(timeout=15) >= 0, + "install activation MCP frontend was killed by a signal", + ) + wait_until( + lambda: len(json_events(daemon_log, "daemon.stop")) + == install_stops + 1, + 10, + "install activation daemon.stop", + ) + assert_coordination_idle( + socket_path, + coordination_locks, + "install activation coordination cleanup", + ) + assert_no_activation_artifacts(install_dir, "install activation") + check(install_target.is_file(), "install activation did not publish target") + install_status = os.lstat(install_target) + check(stat.S_ISREG(install_status.st_mode), "installed target is not regular") + check(install_status.st_uid == os.geteuid(), "installed target has wrong owner") + check(install_status.st_nlink == 1, "installed target has multiple links") + check( + stat.S_IMODE(install_status.st_mode) & 0o022 == 0, + "installed target is group/world writable", + ) + installed_version = subprocess.run( + [str(install_target), "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + timeout=10, + check=False, + ) + check( + installed_version.returncode == 0 + and installed_version.stdout.strip() + == "codebase-memory-mcp " + semantic_version, + "different-build install target is not executable: " + + installed_version.stderr, + ) + installed_build = sha256_file(install_target) + check( + installed_build != active_fingerprint, + "different-build activation installed the old active build", + ) + check( + install_activation_records[-1].get("target_build") + == installed_build, + "install audit target build does not match the published binary", + ) + check( + target_binary.read_bytes() == b"installed binary sentinel\n", + "custom-dir install mutated the canonical binary", + ) + check( + index_path.read_bytes() == b"index sentinel\n", + "install without --reset-indexes removed an index", + ) + check( + sha256_file(binary) == source_binary_before + and sha256_file(conflict_binary) == conflict_binary_before, + "install activation modified a source executable", + ) + + # Build the complete update payload and checksum before starting + # the next daemon. CBM_DOWNLOAD_URL points at this file:// fixture, + # so the smoke cannot contact the network. Update must then drain + # its live MCP generation before replacing only the temp HOME + # installation and performing its documented index reset. + update_release = tmpdir / "activation-update-release" + update_env = env.copy() + update_env["CBM_DOWNLOAD_URL"] = create_local_update_release( + update_release, conflict_binary + ) + update_starts = len(json_events(daemon_log, "daemon.start")) + update_stops = len(json_events(daemon_log, "daemon.stop")) + update_client = start_ready_mcp_client( + binary, + update_env, + tmpdir / "activation-update-client.err", + clients, + initialize_params, + 501, + "activation update client", + ) + wait_until( + lambda: len(json_events(daemon_log, "daemon.start")) + == update_starts + 1, + START_TIMEOUT, + "update activation daemon generation", + ) + update_activation_records = run_successful_activation( + binary, + update_env, + tmpdir, + activation_log, + "activation-update", + "update", + ["update", "--force", "--standard", "--yes"], + active_fingerprint, + ) + check( + update_client.wait(timeout=15) >= 0, + "update activation MCP frontend was killed by a signal", + ) + wait_until( + lambda: len(json_events(daemon_log, "daemon.stop")) + == update_stops + 1, + 10, + "update activation daemon.stop", + ) + assert_coordination_idle( + socket_path, + coordination_locks, + "update activation coordination cleanup", + ) + assert_no_activation_artifacts(target_dir, "update activation") + updated_version = subprocess.run( + [str(target_binary), "--version"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + timeout=10, + check=False, + ) + check( + updated_version.returncode == 0 + and updated_version.stdout.strip() + == "codebase-memory-mcp " + semantic_version, + "updated target is not executable: " + updated_version.stderr, + ) + updated_status = os.lstat(target_binary) + check(stat.S_ISREG(updated_status.st_mode), "updated target is not regular") + check(updated_status.st_uid == os.geteuid(), "updated target has wrong owner") + check(updated_status.st_nlink == 1, "updated target has multiple links") + check( + stat.S_IMODE(updated_status.st_mode) & 0o022 == 0, + "updated target is group/world writable", + ) + updated_build = sha256_file(target_binary) + check( + updated_build != active_fingerprint, + "update republished the old active build", + ) + check( + update_activation_records[-1].get("target_build") + == updated_build, + "update audit target build does not match the published binary", + ) + check(not index_path.exists(), "update did not clear the opted-in index") + check( + sha256_file(install_target) == installed_build, + "update mutated the separate custom-dir installation", + ) + check( + sha256_file(binary) == source_binary_before + and sha256_file(conflict_binary) == conflict_binary_before, + "update activation modified a source executable", + ) + + # Launch the activated target itself as the final daemon build, + # then uninstall it from the original executable. This is another + # authenticated cross-build drain and proves removal waits until + # no process is still executing the target. + uninstall_starts = len(json_events(daemon_log, "daemon.start")) + uninstall_stops = len(json_events(daemon_log, "daemon.stop")) + uninstall_client = start_ready_mcp_client( + target_binary, + env, + tmpdir / "activation-uninstall-client.err", + clients, + initialize_params, + 601, + "activation uninstall client", + ) + wait_until( + lambda: len(json_events(daemon_log, "daemon.start")) + == uninstall_starts + 1, + START_TIMEOUT, + "uninstall activation daemon generation", + ) + uninstall_activation_records = run_successful_activation( + binary, + env, + tmpdir, + activation_log, + "activation-uninstall", + "uninstall", + ["uninstall", "--yes"], + active_fingerprint, + ) + check( + all( + "target_build" not in record + for record in uninstall_activation_records + ), + "uninstall audit claimed a replacement target build", + ) + check( + uninstall_client.wait(timeout=15) >= 0, + "uninstall activation MCP frontend was killed by a signal", + ) + wait_until( + lambda: len(json_events(daemon_log, "daemon.stop")) + == uninstall_stops + 1, + 10, + "uninstall activation daemon.stop", + ) + assert_coordination_idle( + socket_path, + coordination_locks, + "uninstall activation coordination cleanup", + ) + assert_no_activation_artifacts(target_dir, "uninstall activation") + assert_no_activation_artifacts(install_dir, "uninstall activation") + check(not target_binary.exists(), "uninstall did not remove its target") + check( + install_target.exists() + and sha256_file(install_target) == installed_build, + "uninstall mutated the separate custom-dir installation", + ) + check( + sha256_file(binary) == source_binary_before + and sha256_file(conflict_binary) == conflict_binary_before, + "uninstall activation modified a source executable", + ) + check( + len(json_events(daemon_log, "daemon.start")) + == len(json_events(daemon_log, "daemon.stop")), + "activation smoke left an unmatched daemon generation", + ) + + print( + "ok: shared daemon lifecycle, bidirectional version conflicts, " + "coordinated install/update/uninstall, CLI maintenance cancellation, " + "and session cancellation passed" + ) + return 0 + finally: + failed = sys.exc_info()[0] is not None + if cli_first_competitor and cli_first_competitor.poll() is None: + cli_first_competitor.terminate() + try: + cli_first_competitor.wait(timeout=3) + except subprocess.TimeoutExpired: + cli_first_competitor.kill() + cli_first_competitor.wait(timeout=3) + if cli_first_competitor_stderr: + cli_first_competitor_stderr.close() + if ( + cli_first_worker_stopped + and cli_first_worker_pid + and worker_tree_identity_matches( + cli_first_process.pid + if cli_first_process + and cli_first_process.poll() is None + else None, + cli_first_worker_pid, + cli_first_worker_pgid, + cli_first_descendant_pid, + ) + ): + try: + os.kill(cli_first_worker_pid, signal.SIGCONT) + cli_first_worker_stopped = False + except OSError: + pass + if cli_first_process and cli_first_process.poll() is None: + cli_first_process.terminate() + try: + cli_first_process.wait(timeout=5) + except subprocess.TimeoutExpired: + cli_first_process.kill() + cli_first_process.wait(timeout=5) + if ( + cli_first_descendant_pid + and not process_gone_or_zombie(cli_first_descendant_pid) + and worker_group_contains_descendant( + cli_first_worker_pgid, cli_first_descendant_pid + ) + ): + try: + os.killpg(cli_first_worker_pgid, signal.SIGKILL) + except OSError: + pass + if activation_local_process and activation_local_process.poll() is None: + activation_local_process.terminate() + try: + activation_local_process.wait(timeout=5) + except subprocess.TimeoutExpired: + activation_local_process.kill() + activation_local_process.wait(timeout=5) + if ( + activation_local_descendant_pid + and not process_gone_or_zombie(activation_local_descendant_pid) + and worker_group_contains_descendant( + activation_local_worker_pgid, + activation_local_descendant_pid, + ) + ): + try: + os.killpg(activation_local_worker_pgid, signal.SIGKILL) + except OSError: + pass + for client in clients: + client.cleanup() + if failed: + diagnostics = [] + for path in ( + daemon_log, + conflict_log, + tmpdir / "client-1.err", + tmpdir / "client-2.err", + tmpdir / "turnover-client.err", + tmpdir / "cli-first-overlap-client.err", + activation_log, + tmpdir / "activation-install-client.err", + tmpdir / "activation-update-client.err", + tmpdir / "activation-uninstall-client.err", + tmpdir / "activation-local-cli.err", + tmpdir / "activation-install.out", + tmpdir / "activation-install.err", + tmpdir / "activation-update.out", + tmpdir / "activation-update.err", + tmpdir / "activation-uninstall.out", + tmpdir / "activation-uninstall.err", + ): + if not path.exists(): + continue + try: + contents = path.read_text( + encoding="utf-8", errors="replace" + ) + except OSError as exc: + contents = "".format(exc) + diagnostics.append("{}:\n{}".format(path.name, contents)) + if diagnostics: + print( + "daemon smoke diagnostics before cleanup:\n" + + "\n".join(diagnostics), + file=sys.stderr, + ) + # Closing all thin clients is the supported cleanup path. Give a + # test-owned daemon a final chance to finish before the temp cache + # directory disappears, without enumerating or killing processes. + if lock_status(lifetime_lock, record_lock=True) == "held": + deadline = time.monotonic() + SHUTDOWN_TIMEOUT + while time.monotonic() < deadline: + if lock_status(lifetime_lock, record_lock=True) != "held": + break + time.sleep(0.1) + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (SmokeFailure, subprocess.TimeoutExpired, ValueError) as exc: + print("FAIL: " + str(exc), file=sys.stderr) + raise SystemExit(1) diff --git a/tests/test_daemon_version.c b/tests/test_daemon_version.c new file mode 100644 index 000000000..fbdb96286 --- /dev/null +++ b/tests/test_daemon_version.c @@ -0,0 +1,641 @@ +/* + * test_daemon_version.c — RED contract for one-version daemon rendezvous. + * + * These tests intentionally target the service boundary that will be exposed + * by daemon/service.h. They remain a separate suite so implementation can be + * developed without mixing version policy into transport or job-lifecycle + * tests. + */ +#include "test_framework.h" + +#include "daemon/daemon.h" +#include "daemon/service.h" +#include "daemon/service_internal.h" +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/compat_thread.h" + +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#endif + +enum { + VERSION_TEST_PATH_CAP = 1024, + VERSION_TEST_FILE_CAP = 8192, +}; + +static const char BUILD_A[] = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +static const char BUILD_B[] = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +static const char BUILD_C[] = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +static cbm_daemon_build_identity_t version_test_identity(const char *version, const char *build) { + cbm_daemon_build_identity_t identity = { + .semantic_version = version, + .build_fingerprint = build, + .protocol_abi = 3, + .store_abi = 11, + .feature_abi = 7, + }; + return identity; +} + +static bool version_test_temp_dir(char out[VERSION_TEST_PATH_CAP], const char *tag) { + int written = snprintf(out, VERSION_TEST_PATH_CAP, "%s/cbm-daemon-%s-XXXXXX", cbm_tmpdir(), + tag); + return written > 0 && written < VERSION_TEST_PATH_CAP && cbm_mkdtemp(out) != NULL; +} + +static bool version_test_child_path(char out[VERSION_TEST_PATH_CAP], const char *dir, + const char *name) { + int written = snprintf(out, VERSION_TEST_PATH_CAP, "%s/%s", dir, name); + return written > 0 && written < VERSION_TEST_PATH_CAP; +} + +static long version_test_file_size(const char *path) { + FILE *file = cbm_fopen(path, "rb"); + if (!file) { + return -1; + } + if (fseek(file, 0, SEEK_END) != 0) { + (void)fclose(file); + return -1; + } + long size = ftell(file); + (void)fclose(file); + return size; +} + +static bool version_test_read_file(const char *path, char out[VERSION_TEST_FILE_CAP]) { + FILE *file = cbm_fopen(path, "rb"); + if (!file) { + return false; + } + size_t used = fread(out, 1, VERSION_TEST_FILE_CAP - 1, file); + bool complete = !ferror(file) && feof(file); + out[used] = '\0'; + (void)fclose(file); + return complete; +} + +static bool version_test_write_file(const char *path, const char *contents) { + FILE *file = cbm_fopen(path, "wb"); + if (!file) { + return false; + } + size_t length = strlen(contents); + bool wrote = fwrite(contents, 1, length, file) == length; + bool closed = fclose(file) == 0; + return wrote && closed; +} + +static bool version_test_is_sha256(const char *value) { + if (!value || strlen(value) != CBM_DAEMON_BUILD_FINGERPRINT_SIZE - 1) { + return false; + } + for (size_t i = 0; i < CBM_DAEMON_BUILD_FINGERPRINT_SIZE - 1; i++) { + char ch = value[i]; + if (!((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f'))) { + return false; + } + } + return true; +} + +static void version_test_cleanup(const char *dir, const char *path, const char *rotated, + const char *victim) { + char lock_path[VERSION_TEST_PATH_CAP]; + if (path) { + int written = snprintf(lock_path, sizeof(lock_path), "%s.lock", path); + if (written > 0 && written < (int)sizeof(lock_path)) { + (void)cbm_unlink(lock_path); + } + } + if (path) { + (void)cbm_unlink(path); + } + if (rotated) { + (void)cbm_unlink(rotated); + } + if (victim) { + (void)cbm_unlink(victim); + } + if (dir) { + (void)cbm_rmdir(dir); + } +} + +#ifndef _WIN32 +typedef struct { + atomic_int lock_attempts; + atomic_bool first_lock_acquired; +} version_test_rotation_race_t; + +typedef struct { + const char *path; + const cbm_daemon_conflict_t *conflict; + size_t cap_bytes; + bool result; +} version_test_append_call_t; + +static bool version_test_wait_atomic_bool(atomic_bool *value) { + struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + for (int i = 0; i < 2000; i++) { + if (atomic_load_explicit(value, memory_order_acquire)) { + return true; + } + (void)cbm_nanosleep(&pause, NULL); + } + return atomic_load_explicit(value, memory_order_acquire); +} + +static void version_test_rotation_race_hook( + void *opaque, cbm_daemon_conflict_log_test_stage_t stage) { + version_test_rotation_race_t *race = opaque; + if (stage == CBM_DAEMON_CONFLICT_LOG_BEFORE_SERIALIZATION_LOCK) { + (void)atomic_fetch_add_explicit(&race->lock_attempts, 1, + memory_order_acq_rel); + return; + } + if (stage != CBM_DAEMON_CONFLICT_LOG_AFTER_SERIALIZATION_LOCK || + atomic_exchange_explicit(&race->first_lock_acquired, true, + memory_order_acq_rel)) { + return; + } + struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + for (int i = 0; i < 2000 && + atomic_load_explicit(&race->lock_attempts, + memory_order_acquire) < 2; + i++) { + (void)cbm_nanosleep(&pause, NULL); + } +} + +static void *version_test_append_thread(void *opaque) { + version_test_append_call_t *call = opaque; + call->result = cbm_daemon_conflict_log_append( + call->path, call->conflict, call->cap_bytes); + return NULL; +} +#endif + +static cbm_daemon_hello_status_t version_test_compare( + const cbm_daemon_build_identity_t *active, const cbm_daemon_build_identity_t *requested, + cbm_daemon_conflict_t *conflict) { + memset(conflict, 0, sizeof(*conflict)); + return cbm_daemon_hello_compare(active, requested, conflict); +} + +static bool version_test_conflict_identity_matches(const cbm_daemon_conflict_t *conflict, + const char *active_version, + const char *active_build, + const char *requested_version, + const char *requested_build) { + return conflict && strcmp(conflict->active_version, active_version) == 0 && + strcmp(conflict->active_build_fingerprint, active_build) == 0 && + strcmp(conflict->requested_version, requested_version) == 0 && + strcmp(conflict->requested_build_fingerprint, requested_build) == 0; +} + +TEST(daemon_rendezvous_key_is_stable_and_version_independent) { + char first[CBM_DAEMON_KEY_SIZE]; + char after_upgrade[CBM_DAEMON_KEY_SIZE]; + + /* Version, executable path, build fingerprint, cache directory, and ABI + * values are deliberately absent from this API. Every build and cache + * domain must rendezvous at the same product endpoint before HELLO decides + * whether it may proceed. The IPC layer supplies the trusted OS-account + * scope through its owner-only runtime directory / current-user ACL. */ + ASSERT_TRUE(cbm_daemon_rendezvous_key(first)); + ASSERT_TRUE(cbm_daemon_rendezvous_key(after_upgrade)); + + ASSERT_STR_EQ(first, after_upgrade); + PASS(); +} + +TEST(daemon_build_fingerprint_hashes_exact_executable_bytes) { + char dir[VERSION_TEST_PATH_CAP] = {0}; + char first_path[VERSION_TEST_PATH_CAP] = {0}; + char second_path[VERSION_TEST_PATH_CAP] = {0}; + char first[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + char repeated[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + char second[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + bool setup_ok = version_test_temp_dir(dir, "fingerprint") && + version_test_child_path(first_path, dir, "build-a.bin") && + version_test_child_path(second_path, dir, "build-b.bin") && + version_test_write_file(first_path, "same-version-build-a") && + version_test_write_file(second_path, "same-version-build-b"); + if (!setup_ok) { + version_test_cleanup(dir, first_path, second_path, NULL); + FAIL("could not create fingerprint fixtures"); + } + + ASSERT_TRUE(cbm_daemon_build_fingerprint_file(first_path, first)); + ASSERT_TRUE(cbm_daemon_build_fingerprint_file(first_path, repeated)); + ASSERT_TRUE(cbm_daemon_build_fingerprint_file(second_path, second)); + ASSERT_TRUE(version_test_is_sha256(first)); + ASSERT_TRUE(version_test_is_sha256(second)); + ASSERT_STR_EQ(first, repeated); + ASSERT_STR_NEQ(first, second); + + version_test_cleanup(dir, first_path, second_path, NULL); + PASS(); +} + +TEST(daemon_hello_accepts_only_the_exact_active_build_identity) { + cbm_daemon_build_identity_t active = version_test_identity("2.4.0", BUILD_A); + cbm_daemon_build_identity_t exact = version_test_identity("2.4.0", BUILD_A); + cbm_daemon_conflict_t conflict; + + ASSERT_EQ(version_test_compare(&active, &exact, &conflict), CBM_DAEMON_HELLO_COMPATIBLE); + + cbm_daemon_build_identity_t rebuilt = version_test_identity("2.4.0", BUILD_B); + ASSERT_EQ(version_test_compare(&active, &rebuilt, &conflict), + CBM_DAEMON_HELLO_BUILD_CONFLICT); + ASSERT_TRUE( + version_test_conflict_identity_matches(&conflict, "2.4.0", BUILD_A, "2.4.0", BUILD_B)); + PASS(); +} + +TEST(daemon_hello_version_conflict_exposes_active_and_requested_builds) { + cbm_daemon_build_identity_t active = version_test_identity("2.3.1", BUILD_A); + cbm_daemon_build_identity_t requested = version_test_identity("2.4.0", BUILD_B); + cbm_daemon_conflict_t conflict; + char visible[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; + + ASSERT_EQ(version_test_compare(&active, &requested, &conflict), + CBM_DAEMON_HELLO_VERSION_CONFLICT); + ASSERT_TRUE( + version_test_conflict_identity_matches(&conflict, "2.3.1", BUILD_A, "2.4.0", BUILD_B)); + ASSERT_TRUE(cbm_daemon_conflict_format(&conflict, visible, sizeof(visible))); + ASSERT_NOT_NULL(strstr(visible, "could not start")); + ASSERT_NOT_NULL(strstr(visible, "conflicting")); + ASSERT_NOT_NULL(strstr(visible, "2.3.1")); + ASSERT_NOT_NULL(strstr(visible, BUILD_A)); + ASSERT_NOT_NULL(strstr(visible, "2.4.0")); + ASSERT_NOT_NULL(strstr(visible, BUILD_B)); + PASS(); +} + +TEST(daemon_hello_rejects_each_abi_mismatch) { + cbm_daemon_build_identity_t active = version_test_identity("2.4.0", BUILD_A); + cbm_daemon_build_identity_t requested = active; + cbm_daemon_conflict_t conflict; + + requested.protocol_abi++; + ASSERT_EQ(version_test_compare(&active, &requested, &conflict), + CBM_DAEMON_HELLO_PROTOCOL_ABI_CONFLICT); + ASSERT_TRUE( + version_test_conflict_identity_matches(&conflict, "2.4.0", BUILD_A, "2.4.0", BUILD_A)); + + requested = active; + requested.store_abi++; + ASSERT_EQ(version_test_compare(&active, &requested, &conflict), + CBM_DAEMON_HELLO_STORE_ABI_CONFLICT); + ASSERT_TRUE( + version_test_conflict_identity_matches(&conflict, "2.4.0", BUILD_A, "2.4.0", BUILD_A)); + + requested = active; + requested.feature_abi++; + ASSERT_EQ(version_test_compare(&active, &requested, &conflict), + CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT); + ASSERT_TRUE( + version_test_conflict_identity_matches(&conflict, "2.4.0", BUILD_A, "2.4.0", BUILD_A)); + PASS(); +} + +TEST(daemon_hello_fails_closed_without_an_exact_build_fingerprint) { + cbm_daemon_build_identity_t active = version_test_identity("2.4.0", BUILD_A); + cbm_daemon_build_identity_t missing = version_test_identity("2.4.0", NULL); + cbm_daemon_build_identity_t malformed = version_test_identity("2.4.0", "not-a-sha256"); + cbm_daemon_conflict_t conflict; + + ASSERT_EQ(version_test_compare(&active, &missing, &conflict), CBM_DAEMON_HELLO_INVALID); + ASSERT_EQ(version_test_compare(&active, &malformed, &conflict), CBM_DAEMON_HELLO_INVALID); + PASS(); +} + +TEST(daemon_conflict_log_is_durable_private_and_rotates) { + char dir[VERSION_TEST_PATH_CAP] = {0}; + char path[VERSION_TEST_PATH_CAP] = {0}; + char rotated[VERSION_TEST_PATH_CAP] = {0}; + char lock_path[VERSION_TEST_PATH_CAP] = {0}; + char current_data[VERSION_TEST_FILE_CAP]; + char rotated_data[VERSION_TEST_FILE_CAP]; + cbm_daemon_build_identity_t active = version_test_identity("2.3.1", BUILD_A); + cbm_daemon_build_identity_t first_requested = version_test_identity("2.4.0", BUILD_B); + cbm_daemon_build_identity_t second_requested = version_test_identity("2.5.0", BUILD_C); + cbm_daemon_conflict_t first_conflict; + cbm_daemon_conflict_t second_conflict; + bool setup_ok = version_test_temp_dir(dir, "log") && + version_test_child_path(path, dir, "daemon.log") && + version_test_child_path(rotated, dir, "daemon.log.1") && + version_test_child_path(lock_path, dir, "daemon.log.lock"); + if (!setup_ok) { + version_test_cleanup(dir, path, rotated, NULL); + FAIL("could not create isolated daemon-log directory"); + } + + ASSERT_EQ(version_test_compare(&active, &first_requested, &first_conflict), + CBM_DAEMON_HELLO_VERSION_CONFLICT); + ASSERT_TRUE(cbm_daemon_conflict_log_append(path, &first_conflict, 4096)); + long first_size = version_test_file_size(path); + ASSERT_GT(first_size, 0); + +#ifndef _WIN32 + struct stat status; + ASSERT_EQ(lstat(path, &status), 0); + ASSERT_TRUE(S_ISREG(status.st_mode)); + ASSERT_EQ(status.st_mode & 0777, 0600); + ASSERT_EQ(lstat(lock_path, &status), 0); + ASSERT_TRUE(S_ISREG(status.st_mode)); + ASSERT_EQ(status.st_uid, geteuid()); + ASSERT_EQ(status.st_mode & 0777, 0600); +#endif + + /* The second complete record would cross this cap, so the old complete + * generation moves to .1 and the new record becomes the active log. */ + ASSERT_EQ(version_test_compare(&active, &second_requested, &second_conflict), + CBM_DAEMON_HELLO_VERSION_CONFLICT); + ASSERT_TRUE(cbm_daemon_conflict_log_append(path, &second_conflict, (size_t)first_size + 1)); + ASSERT_TRUE(version_test_read_file(path, current_data)); + ASSERT_TRUE(version_test_read_file(rotated, rotated_data)); + + ASSERT_NOT_NULL(strstr(rotated_data, "\"event\":\"daemon.version_conflict\"")); + ASSERT_NOT_NULL(strstr(rotated_data, "\"active_version\":\"2.3.1\"")); + ASSERT_NOT_NULL(strstr(rotated_data, "\"requested_version\":\"2.4.0\"")); + ASSERT_NOT_NULL(strstr(rotated_data, BUILD_A)); + ASSERT_NOT_NULL(strstr(rotated_data, BUILD_B)); + ASSERT_NOT_NULL(strstr(current_data, "\"requested_version\":\"2.5.0\"")); + ASSERT_NOT_NULL(strstr(current_data, BUILD_C)); + + version_test_cleanup(dir, path, rotated, NULL); + PASS(); +} + +#ifndef _WIN32 +TEST(daemon_conflict_log_rotation_serializes_on_stable_sidecar) { + enum { ROTATION_CAP = 4096, PADDED_LOG_SIZE = 4000 }; + char dir[VERSION_TEST_PATH_CAP] = {0}; + char path[VERSION_TEST_PATH_CAP] = {0}; + char rotated[VERSION_TEST_PATH_CAP] = {0}; + char padded[PADDED_LOG_SIZE + 1]; + char current_data[VERSION_TEST_FILE_CAP] = {0}; + cbm_daemon_build_identity_t active = version_test_identity("2.3.1", BUILD_A); + cbm_daemon_build_identity_t seed_requested = version_test_identity("2.4.0", BUILD_B); + cbm_daemon_build_identity_t first_requested = version_test_identity("2.5.0", BUILD_B); + cbm_daemon_build_identity_t second_requested = version_test_identity("2.6.0", BUILD_C); + cbm_daemon_conflict_t seed_conflict; + cbm_daemon_conflict_t first_conflict; + cbm_daemon_conflict_t second_conflict; + version_test_rotation_race_t race = {0}; + version_test_append_call_t first_call = {0}; + version_test_append_call_t second_call = {0}; + cbm_thread_t first_thread; + cbm_thread_t second_thread; + bool first_started = false; + bool second_started = false; + + bool setup_ok = version_test_temp_dir(dir, "log-rotation-race") && + version_test_child_path(path, dir, "daemon.log") && + version_test_child_path(rotated, dir, "daemon.log.1") && + version_test_compare(&active, &seed_requested, &seed_conflict) == + CBM_DAEMON_HELLO_VERSION_CONFLICT && + version_test_compare(&active, &first_requested, &first_conflict) == + CBM_DAEMON_HELLO_VERSION_CONFLICT && + version_test_compare(&active, &second_requested, &second_conflict) == + CBM_DAEMON_HELLO_VERSION_CONFLICT && + cbm_daemon_conflict_log_append(path, &seed_conflict, 8192); + memset(padded, 'x', PADDED_LOG_SIZE); + padded[PADDED_LOG_SIZE - 1] = '\n'; + padded[PADDED_LOG_SIZE] = '\0'; + setup_ok = setup_ok && version_test_write_file(path, padded); + if (!setup_ok) { + version_test_cleanup(dir, path, rotated, NULL); + FAIL("could not create rotation-race fixture"); + } + + first_call.path = path; + first_call.conflict = &first_conflict; + first_call.cap_bytes = ROTATION_CAP; + second_call.path = path; + second_call.conflict = &second_conflict; + second_call.cap_bytes = ROTATION_CAP; + + cbm_daemon_conflict_log_set_test_hook(version_test_rotation_race_hook, &race); + first_started = cbm_thread_create(&first_thread, 0, + version_test_append_thread, + &first_call) == 0; + bool first_locked = first_started && + version_test_wait_atomic_bool(&race.first_lock_acquired); + if (first_locked) { + second_started = cbm_thread_create(&second_thread, 0, + version_test_append_thread, + &second_call) == 0; + } + if (first_started) { + (void)cbm_thread_join(&first_thread); + } + if (second_started) { + (void)cbm_thread_join(&second_thread); + } + cbm_daemon_conflict_log_set_test_hook(NULL, NULL); + + bool complete = version_test_read_file(path, current_data); + bool first_present = strstr(current_data, "\"requested_version\":\"2.5.0\"") != NULL; + bool second_present = strstr(current_data, "\"requested_version\":\"2.6.0\"") != NULL; + version_test_cleanup(dir, path, rotated, NULL); + + ASSERT_TRUE(first_started); + ASSERT_TRUE(first_locked); + ASSERT_TRUE(second_started); + ASSERT_TRUE(first_call.result); + ASSERT_TRUE(second_call.result); + ASSERT_TRUE(complete); + ASSERT_TRUE(first_present); + ASSERT_TRUE(second_present); + PASS(); +} + +TEST(daemon_conflict_log_rejects_symlink_destination) { + char dir[VERSION_TEST_PATH_CAP] = {0}; + char path[VERSION_TEST_PATH_CAP] = {0}; + char victim[VERSION_TEST_PATH_CAP] = {0}; + char victim_data[VERSION_TEST_FILE_CAP]; + cbm_daemon_build_identity_t active = version_test_identity("2.3.1", BUILD_A); + cbm_daemon_build_identity_t requested = version_test_identity("2.4.0", BUILD_B); + cbm_daemon_conflict_t conflict; + bool setup_ok = version_test_temp_dir(dir, "log-link") && + version_test_child_path(path, dir, "daemon.log") && + version_test_child_path(victim, dir, "victim.txt"); + if (!setup_ok) { + version_test_cleanup(dir, path, NULL, victim); + FAIL("could not create isolated symlink-test directory"); + } + + FILE *file = cbm_fopen(victim, "wb"); + bool victim_written = false; + if (file) { + victim_written = fputs("must-remain-unchanged", file) >= 0 && fclose(file) == 0; + file = NULL; + } + if (!victim_written) { + if (file) { + (void)fclose(file); + } + version_test_cleanup(dir, path, NULL, victim); + FAIL("could not create symlink victim"); + } + if (symlink(victim, path) != 0) { + version_test_cleanup(dir, path, NULL, victim); + FAIL("could not create daemon-log symlink"); + } + + ASSERT_EQ(version_test_compare(&active, &requested, &conflict), + CBM_DAEMON_HELLO_VERSION_CONFLICT); + ASSERT_FALSE(cbm_daemon_conflict_log_append(path, &conflict, 4096)); + ASSERT_TRUE(version_test_read_file(victim, victim_data)); + ASSERT_STR_EQ(victim_data, "must-remain-unchanged"); + + version_test_cleanup(dir, path, NULL, victim); + PASS(); +} + +TEST(daemon_conflict_log_rejects_symlink_sidecar) { + char dir[VERSION_TEST_PATH_CAP] = {0}; + char path[VERSION_TEST_PATH_CAP] = {0}; + char lock_path[VERSION_TEST_PATH_CAP] = {0}; + char victim[VERSION_TEST_PATH_CAP] = {0}; + char victim_data[VERSION_TEST_FILE_CAP]; + cbm_daemon_build_identity_t active = version_test_identity("2.3.1", BUILD_A); + cbm_daemon_build_identity_t requested = version_test_identity("2.4.0", BUILD_B); + cbm_daemon_conflict_t conflict; + bool setup_ok = version_test_temp_dir(dir, "log-lock-link") && + version_test_child_path(path, dir, "daemon.log") && + version_test_child_path(lock_path, dir, "daemon.log.lock") && + version_test_child_path(victim, dir, "victim.txt") && + version_test_write_file(victim, "must-remain-unchanged") && + symlink(victim, lock_path) == 0; + if (!setup_ok) { + version_test_cleanup(dir, path, NULL, victim); + FAIL("could not create daemon-log lock symlink"); + } + + ASSERT_EQ(version_test_compare(&active, &requested, &conflict), + CBM_DAEMON_HELLO_VERSION_CONFLICT); + ASSERT_FALSE(cbm_daemon_conflict_log_append(path, &conflict, 4096)); + ASSERT_TRUE(version_test_read_file(victim, victim_data)); + ASSERT_STR_EQ(victim_data, "must-remain-unchanged"); + + version_test_cleanup(dir, path, NULL, victim); + PASS(); +} +#endif + +#ifdef _WIN32 +typedef struct { + const char *path; + const cbm_daemon_conflict_t *conflict; + atomic_bool *start; + bool result; +} version_test_windows_append_t; + +static void *version_test_windows_append_thread(void *opaque) { + version_test_windows_append_t *call = opaque; + struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; + while (!atomic_load_explicit(call->start, memory_order_acquire)) { + (void)cbm_nanosleep(&pause, NULL); + } + call->result = cbm_daemon_conflict_log_append( + call->path, call->conflict, 64U * 1024U); + return NULL; +} + +TEST(daemon_conflict_log_windows_concurrent_appends_are_not_dropped) { + enum { APPEND_COUNT = 8 }; + char dir[VERSION_TEST_PATH_CAP] = {0}; + char path[VERSION_TEST_PATH_CAP] = {0}; + char rotated[VERSION_TEST_PATH_CAP] = {0}; + char current_data[VERSION_TEST_FILE_CAP] = {0}; + cbm_daemon_build_identity_t active = version_test_identity("2.3.1", BUILD_A); + cbm_daemon_build_identity_t requested = version_test_identity("2.4.0", BUILD_B); + cbm_daemon_conflict_t conflict; + cbm_thread_t threads[APPEND_COUNT]; + version_test_windows_append_t calls[APPEND_COUNT]; + atomic_bool start = false; + size_t started = 0; + + bool setup_ok = version_test_temp_dir(dir, "log-windows-concurrent") && + version_test_child_path(path, dir, "daemon.log") && + version_test_child_path(rotated, dir, "daemon.log.1") && + version_test_compare(&active, &requested, &conflict) == + CBM_DAEMON_HELLO_VERSION_CONFLICT; + if (!setup_ok) { + version_test_cleanup(dir, path, rotated, NULL); + FAIL("could not create Windows concurrent-log fixture"); + } + + memset(calls, 0, sizeof(calls)); + for (; started < APPEND_COUNT; started++) { + calls[started].path = path; + calls[started].conflict = &conflict; + calls[started].start = &start; + if (cbm_thread_create(&threads[started], 0, + version_test_windows_append_thread, + &calls[started]) != 0) { + break; + } + } + atomic_store_explicit(&start, true, memory_order_release); + for (size_t i = 0; i < started; i++) { + (void)cbm_thread_join(&threads[i]); + } + + bool complete = version_test_read_file(path, current_data); + size_t records = 0; + const char *cursor = current_data; + static const char event[] = "\"event\":\"daemon.version_conflict\""; + while ((cursor = strstr(cursor, event)) != NULL) { + records++; + cursor += sizeof(event) - 1; + } + bool all_succeeded = true; + for (size_t i = 0; i < started; i++) { + all_succeeded = all_succeeded && calls[i].result; + } + version_test_cleanup(dir, path, rotated, NULL); + + ASSERT_EQ(started, APPEND_COUNT); + ASSERT_TRUE(all_succeeded); + ASSERT_TRUE(complete); + ASSERT_EQ(records, APPEND_COUNT); + PASS(); +} +#endif + +SUITE(daemon_version) { + RUN_TEST(daemon_rendezvous_key_is_stable_and_version_independent); + RUN_TEST(daemon_build_fingerprint_hashes_exact_executable_bytes); + RUN_TEST(daemon_hello_accepts_only_the_exact_active_build_identity); + RUN_TEST(daemon_hello_version_conflict_exposes_active_and_requested_builds); + RUN_TEST(daemon_hello_rejects_each_abi_mismatch); + RUN_TEST(daemon_hello_fails_closed_without_an_exact_build_fingerprint); + RUN_TEST(daemon_conflict_log_is_durable_private_and_rotates); +#ifndef _WIN32 + RUN_TEST(daemon_conflict_log_rotation_serializes_on_stable_sidecar); + RUN_TEST(daemon_conflict_log_rejects_symlink_destination); + RUN_TEST(daemon_conflict_log_rejects_symlink_sidecar); +#endif +#ifdef _WIN32 + RUN_TEST(daemon_conflict_log_windows_concurrent_appends_are_not_dropped); +#endif +} diff --git a/tests/test_httpd.c b/tests/test_httpd.c index cceea3628..5067231ce 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -26,6 +26,7 @@ #include #include +#include #include #include #ifndef _WIN32 @@ -431,6 +432,79 @@ static int th_server_start_with_watcher(th_server_t *ts, cbm_watcher_t *watcher) return 0; } +typedef struct { + atomic_int calls; + char root_path[512]; + char project_name[256]; +} th_ui_index_executor_t; + +static int th_ui_index_executor(void *opaque, const char *root_path, + const char *project_name) { + th_ui_index_executor_t *executor = opaque; + snprintf(executor->root_path, sizeof(executor->root_path), "%s", root_path); + snprintf(executor->project_name, sizeof(executor->project_name), "%s", + project_name ? project_name : ""); + atomic_fetch_add(&executor->calls, 1); + return 0; +} + +static bool th_wait_atomic_int(atomic_int *value, int expected, uint32_t timeout_ms) { + uint64_t deadline = cbm_now_ms() + timeout_ms; + while (cbm_now_ms() < deadline) { + if (atomic_load(value) == expected) { + return true; + } + cbm_usleep(1000); + } + return atomic_load(value) == expected; +} + +typedef struct { + atomic_int begin_calls; + atomic_int end_calls; + bool allow; + char begin_project[256]; + char end_project[256]; +} th_ui_mutation_guard_t; + +static void th_ui_mutation_guard_init(th_ui_mutation_guard_t *guard, bool allow) { + memset(guard, 0, sizeof(*guard)); + atomic_init(&guard->begin_calls, 0); + atomic_init(&guard->end_calls, 0); + guard->allow = allow; +} + +static bool th_ui_mutation_begin(void *opaque, const char *project) { + th_ui_mutation_guard_t *guard = opaque; + snprintf(guard->begin_project, sizeof(guard->begin_project), "%s", + project ? project : ""); + atomic_fetch_add(&guard->begin_calls, 1); + return guard->allow; +} + +static void th_ui_mutation_end(void *opaque, const char *project) { + th_ui_mutation_guard_t *guard = opaque; + snprintf(guard->end_project, sizeof(guard->end_project), "%s", + project ? project : ""); + atomic_fetch_add(&guard->end_calls, 1); +} + +static int th_server_start_with_mutation_guard(th_server_t *ts, cbm_watcher_t *watcher, + th_ui_mutation_guard_t *guard) { + ts->srv = cbm_http_server_new(0); + if (!ts->srv) + return -1; + if (watcher) + cbm_http_server_set_watcher(ts->srv, watcher); + cbm_http_server_set_project_mutation_guard(ts->srv, th_ui_mutation_begin, + th_ui_mutation_end, guard); + if (cbm_thread_create(&ts->tid, 0, th_server_thread, ts->srv) != 0) { + cbm_http_server_free(ts->srv); + return -1; + } + return 0; +} + static void th_server_stop(th_server_t *ts) { cbm_http_server_stop(ts->srv); cbm_thread_join(&ts->tid); @@ -507,6 +581,65 @@ static int ui_delete_request(th_server_t *ts, const char *target, char *resp, si return th_http(cbm_http_server_port(ts->srv), req, resp, respsz); } +static int ui_adr_post_request(th_server_t *ts, const char *project, const char *content, + char *resp, size_t respsz) { + char body[2048]; + int body_len = snprintf(body, sizeof(body), + "{\"project\":\"%s\",\"content\":\"%s\"}", project, + content); + if (body_len < 0 || (size_t)body_len >= sizeof(body)) + return 0; + + char req[2304]; + int req_len = snprintf(req, sizeof(req), + "POST /api/adr HTTP/1.1\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %d\r\n\r\n%s", + body_len, body); + if (req_len < 0 || (size_t)req_len >= sizeof(req)) + return 0; + return th_http(cbm_http_server_port(ts->srv), req, resp, respsz); +} + +static int ui_adr_get_request(th_server_t *ts, const char *project, char *resp, + size_t respsz) { + char req[512]; + int req_len = snprintf(req, sizeof(req), + "GET /api/adr?project=%s HTTP/1.1\r\n\r\n", project); + if (req_len < 0 || (size_t)req_len >= sizeof(req)) + return 0; + return th_http(cbm_http_server_port(ts->srv), req, resp, respsz); +} + +static int ui_adr_seed(const ui_delete_fixture_t *fx, const char *project, + const char *content) { + char db_path[1024]; + ui_delete_db_path(fx, project, db_path, sizeof(db_path)); + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) + return CBM_STORE_ERR; + int rc = cbm_store_adr_store(store, project, content); + cbm_store_close(store); + return rc; +} + +static bool ui_adr_equals(const ui_delete_fixture_t *fx, const char *project, + const char *expected) { + char db_path[1024]; + ui_delete_db_path(fx, project, db_path, sizeof(db_path)); + cbm_store_t *store = cbm_store_open_path_query(db_path); + if (!store) + return false; + + cbm_adr_t adr = {0}; + int rc = cbm_store_adr_get(store, project, &adr); + bool equal = rc == CBM_STORE_OK && adr.content && strcmp(adr.content, expected) == 0; + if (rc == CBM_STORE_OK) + cbm_store_adr_free(&adr); + cbm_store_close(store); + return equal; +} + TEST(ui_server_unknown_path_404) { th_server_t ts; ASSERT_EQ(th_server_start(&ts), 0); @@ -524,6 +657,67 @@ TEST(ui_server_unknown_path_404) { PASS(); } +/* Security regression: process IDs are not cancellation capabilities. The UI + * must never expose an endpoint that accepts an arbitrary PID and reaches an + * OS process-termination API (the former Windows path accepted every PID but + * self). Daemon-owned jobs are cancelled by opaque, owner-bound subscription + * handles instead, so this legacy route must be completely absent. */ +TEST(ui_server_process_kill_route_is_unavailable) { + th_server_t ts; + ASSERT_EQ(th_server_start(&ts), 0); + int port = cbm_http_server_port(ts.srv); + + static const char body[] = "{\"pid\":2147483646}"; + char request[512]; + snprintf(request, sizeof(request), + "POST /api/process-kill HTTP/1.1\r\n" + "Content-Type: application/json\r\n" + "Content-Length: %zu\r\n\r\n%s", + strlen(body), body); + char resp[4096]; + int n = th_http(port, request, resp, sizeof(resp)); + + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), 404); + ASSERT_NULL(strstr(resp, "\"killed\"")); + th_server_stop(&ts); + PASS(); +} + +TEST(ui_server_routes_indexing_through_joinable_daemon_executor) { + char *root = th_mktempdir("cbm_httpd_daemon_index"); + ASSERT_NOT_NULL(root); + th_ui_index_executor_t executor = {0}; + atomic_init(&executor.calls, 0); + th_server_t ts; + ts.srv = cbm_http_server_new(0); + ASSERT_NOT_NULL(ts.srv); + cbm_http_server_set_index_executor(ts.srv, th_ui_index_executor, &executor); + ASSERT_EQ(cbm_thread_create(&ts.tid, 0, th_server_thread, ts.srv), 0); + + char body[1024]; + snprintf(body, sizeof(body), + "{\"root_path\":\"%s\",\"project_name\":\"ui-project\"}", root); + char request[1400]; + snprintf(request, sizeof(request), + "POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n" + "Content-Length: %zu\r\n\r\n%s", + strlen(body), body); + char response[4096]; + int response_length = th_http(cbm_http_server_port(ts.srv), request, + response, sizeof(response)); + bool called = th_wait_atomic_int(&executor.calls, 1, 2000); + + th_server_stop(&ts); + ASSERT_GT(response_length, 0); + ASSERT_EQ(th_status(response), 202); + ASSERT_TRUE(called); + ASSERT_STR_EQ(executor.root_path, root); + ASSERT_STR_EQ(executor.project_name, "ui-project"); + th_cleanup(root); + PASS(); +} + TEST(ui_server_root_serves_stub_404) { /* Test binary links embedded_stub.c → no frontend → 404 with marker */ th_server_t ts; @@ -650,6 +844,143 @@ TEST(ui_server_browse_traversal_probe) { PASS(); } +TEST(ui_server_adr_mutation_guard_busy_preserves_existing_adr) { + static const char *project = "ui-guard-adr-busy"; + static const char *original = "original architecture"; + ui_delete_fixture_t fx; + ASSERT_EQ(ui_delete_fixture_init(&fx), 0); + ASSERT_EQ(ui_adr_seed(&fx, project, original), CBM_STORE_OK); + + th_ui_mutation_guard_t guard; + th_ui_mutation_guard_init(&guard, false); + th_server_t ts; + ASSERT_EQ(th_server_start_with_mutation_guard(&ts, NULL, &guard), 0); + + char resp[4096]; + int n = ui_adr_post_request(&ts, project, "replacement architecture", resp, + sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), 423); + ASSERT_NOT_NULL(strstr(resp, "project is busy; retry after indexing")); + ASSERT_EQ(atomic_load(&guard.begin_calls), 1); + ASSERT_EQ(atomic_load(&guard.end_calls), 0); + ASSERT_STR_EQ(guard.begin_project, project); + + /* A rejected mutation must leave the published DB queryable and unchanged. + * GET is a query operation and therefore must not enter the mutation guard. */ + n = ui_adr_get_request(&ts, project, resp, sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), 200); + ASSERT_NOT_NULL(strstr(resp, "\"has_adr\":true")); + ASSERT_NOT_NULL(strstr(resp, original)); + ASSERT_EQ(atomic_load(&guard.begin_calls), 1); + ASSERT_EQ(atomic_load(&guard.end_calls), 0); + ASSERT_TRUE(ui_adr_equals(&fx, project, original)); + + th_server_stop(&ts); + ui_delete_fixture_cleanup(&fx); + PASS(); +} + +TEST(ui_server_adr_mutation_guard_balances_success) { + static const char *project = "ui-guard-adr-success"; + static const char *content = "coordinated architecture"; + ui_delete_fixture_t fx; + ASSERT_EQ(ui_delete_fixture_init(&fx), 0); + + th_ui_mutation_guard_t guard; + th_ui_mutation_guard_init(&guard, true); + th_server_t ts; + ASSERT_EQ(th_server_start_with_mutation_guard(&ts, NULL, &guard), 0); + + char resp[4096]; + int n = ui_adr_post_request(&ts, project, content, resp, sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), 200); + ASSERT_NOT_NULL(strstr(resp, "{\"saved\":true}")); + ASSERT_EQ(atomic_load(&guard.begin_calls), 1); + ASSERT_EQ(atomic_load(&guard.end_calls), 1); + ASSERT_STR_EQ(guard.begin_project, project); + ASSERT_STR_EQ(guard.end_project, project); + ASSERT_TRUE(ui_adr_equals(&fx, project, content)); + + th_server_stop(&ts); + ui_delete_fixture_cleanup(&fx); + PASS(); +} + +TEST(ui_server_delete_mutation_guard_busy_preserves_project) { + static const char *project = "ui-guard-delete-busy"; + ui_delete_fixture_t fx; + ASSERT_EQ(ui_delete_fixture_init(&fx), 0); + ASSERT_EQ(ui_delete_make_db_file(&fx, project), 0); + ASSERT_EQ(ui_delete_make_sidecars(&fx, project), 0); + cbm_watcher_watch(fx.watcher, project, fx.root_dir); + ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); + + th_ui_mutation_guard_t guard; + th_ui_mutation_guard_init(&guard, false); + th_server_t ts; + ASSERT_EQ(th_server_start_with_mutation_guard(&ts, fx.watcher, &guard), 0); + + char resp[4096]; + int n = ui_delete_request(&ts, "/api/project?name=ui-guard-delete-busy", resp, + sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), 423); + ASSERT_NOT_NULL(strstr(resp, "project is busy; retry after indexing")); + ASSERT_EQ(atomic_load(&guard.begin_calls), 1); + ASSERT_EQ(atomic_load(&guard.end_calls), 0); + ASSERT_STR_EQ(guard.begin_project, project); + + char db_path[1024], wal_path[1040], shm_path[1040]; + ui_delete_db_path(&fx, project, db_path, sizeof(db_path)); + snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); + snprintf(shm_path, sizeof(shm_path), "%s-shm", db_path); + ASSERT_TRUE(cbm_file_exists(db_path)); + ASSERT_TRUE(cbm_file_exists(wal_path)); + ASSERT_TRUE(cbm_file_exists(shm_path)); + ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); + + th_server_stop(&ts); + ui_delete_fixture_cleanup(&fx); + PASS(); +} + +TEST(ui_server_delete_mutation_guard_balances_success) { + static const char *project = "ui-guard-delete-success"; + ui_delete_fixture_t fx; + ASSERT_EQ(ui_delete_fixture_init(&fx), 0); + ASSERT_EQ(ui_delete_make_db_file(&fx, project), 0); + cbm_watcher_watch(fx.watcher, project, fx.root_dir); + ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 1); + + th_ui_mutation_guard_t guard; + th_ui_mutation_guard_init(&guard, true); + th_server_t ts; + ASSERT_EQ(th_server_start_with_mutation_guard(&ts, fx.watcher, &guard), 0); + + char resp[4096]; + int n = ui_delete_request(&ts, "/api/project?name=ui-guard-delete-success", resp, + sizeof(resp)); + ASSERT_GT(n, 0); + ASSERT_EQ(th_status(resp), 200); + ASSERT_NOT_NULL(strstr(resp, "{\"deleted\":true}")); + ASSERT_EQ(atomic_load(&guard.begin_calls), 1); + ASSERT_EQ(atomic_load(&guard.end_calls), 1); + ASSERT_STR_EQ(guard.begin_project, project); + ASSERT_STR_EQ(guard.end_project, project); + + char db_path[1024]; + ui_delete_db_path(&fx, project, db_path, sizeof(db_path)); + ASSERT_FALSE(cbm_file_exists(db_path)); + ASSERT_EQ(cbm_watcher_watch_count(fx.watcher), 0); + + th_server_stop(&ts); + ui_delete_fixture_cleanup(&fx); + PASS(); +} + TEST(ui_server_delete_project_unwatches_after_delete) { ui_delete_fixture_t fx; ASSERT_EQ(ui_delete_fixture_init(&fx), 0); @@ -1199,6 +1530,8 @@ SUITE(httpd) { /* Full UI server */ RUN_TEST(ui_server_rejects_non_loopback_host); RUN_TEST(ui_server_unknown_path_404); + RUN_TEST(ui_server_process_kill_route_is_unavailable); + RUN_TEST(ui_server_routes_indexing_through_joinable_daemon_executor); RUN_TEST(ui_server_root_serves_stub_404); RUN_TEST(ui_server_cors_localhost_reflected); RUN_TEST(ui_server_cors_evil_origin_not_reflected); @@ -1207,6 +1540,10 @@ SUITE(httpd) { RUN_TEST(ui_server_encoded_slash_not_routed); RUN_TEST(ui_server_nul_in_target_rejected); RUN_TEST(ui_server_browse_traversal_probe); + RUN_TEST(ui_server_adr_mutation_guard_busy_preserves_existing_adr); + RUN_TEST(ui_server_adr_mutation_guard_balances_success); + RUN_TEST(ui_server_delete_mutation_guard_busy_preserves_project); + RUN_TEST(ui_server_delete_mutation_guard_balances_success); RUN_TEST(ui_server_delete_project_unwatches_after_delete); RUN_TEST(ui_server_delete_project_unwatches_missing_db); RUN_TEST(ui_server_delete_project_no_watcher_still_deletes); diff --git a/tests/test_index_supervisor.c b/tests/test_index_supervisor.c new file mode 100644 index 000000000..7614dca8a --- /dev/null +++ b/tests/test_index_supervisor.c @@ -0,0 +1,700 @@ +/* RED contract for daemon-owned asynchronous index workers. */ +#include "test_framework.h" +#include "test_helpers.h" + +#include "cli/progress_sink.h" +#include "daemon/bootstrap.h" +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/platform.h" +#include "foundation/profile.h" +#include "mcp/index_supervisor.h" + +#include +#include +#include +#include +#include +#include + +enum { + INDEX_SUPERVISOR_TEST_PATH_CAP = 1024, + INDEX_SUPERVISOR_TEST_TERMINAL_MS = 5000, + INDEX_SUPERVISOR_TEST_BACKLOG_LINES = 1024, +}; + +static void index_supervisor_test_pause(void) { + const struct timespec pause = {0, 10000000L}; + (void)cbm_nanosleep(&pause, NULL); +} + +static bool index_supervisor_test_poll_terminal(cbm_index_worker_handle_t *handle, + uint32_t timeout_ms, + const cbm_index_worker_result_t **result_out) { + uint64_t deadline = cbm_now_ms() + timeout_ms; + do { + cbm_index_worker_poll_t state = cbm_index_worker_poll(handle, result_out); + if (state == CBM_INDEX_WORKER_POLL_TERMINAL) { + return result_out && *result_out; + } + if (state == CBM_INDEX_WORKER_POLL_ERROR) { + return false; + } + index_supervisor_test_pause(); + } while (cbm_now_ms() < deadline); + return cbm_index_worker_poll(handle, result_out) == CBM_INDEX_WORKER_POLL_TERMINAL; +} + +static bool index_supervisor_test_wait_file(cbm_index_worker_handle_t *handle, const char *path, + char *out, size_t out_size) { + uint64_t deadline = cbm_now_ms() + 3000; + do { + FILE *file = cbm_fopen(path, "rb"); + if (file) { + size_t used = fread(out, 1, out_size - 1, file); + out[used] = '\0'; + (void)fclose(file); + if (used > 0) { + return true; + } + } + const cbm_index_worker_result_t *unexpected = NULL; + if (cbm_index_worker_poll(handle, &unexpected) != CBM_INDEX_WORKER_POLL_RUNNING) { + return false; + } + index_supervisor_test_pause(); + } while (cbm_now_ms() < deadline); + return false; +} + +static bool index_supervisor_test_wait_file_text(const char *path, const char *needle, + uint32_t timeout_ms) { + uint64_t deadline = cbm_now_ms() + timeout_ms; + do { + FILE *file = cbm_fopen(path, "rb"); + if (file) { + char content[4096] = {0}; + size_t used = fread(content, 1, sizeof(content) - 1, file); + content[used] = '\0'; + (void)fclose(file); + if (strstr(content, needle)) { + return true; + } + } + index_supervisor_test_pause(); + } while (cbm_now_ms() < deadline); + return false; +} + +static bool index_supervisor_test_append_log(const char *path, const char *line) { + FILE *file = cbm_fopen(path, "ab"); + if (!file) { + return false; + } + bool written = fputs(line, file) >= 0 && fflush(file) == 0; + return fclose(file) == 0 && written; +} + +static bool index_supervisor_test_append_terminal_backlog(const char *path) { + FILE *file = cbm_fopen(path, "ab"); + if (!file) { + return false; + } + bool written = + fputs("level=info msg=pipeline.discover files=7\n", file) >= 0 && + fputs("{\"level\":\"info\",\"event\":\"pass.start\",\"pass\":\"structure\"}\n", + file) >= 0; + for (int i = 0; written && i < INDEX_SUPERVISOR_TEST_BACKLOG_LINES; i++) { + written = fprintf(file, "terminal-backlog-%04d\n", i) > 0; + } + written = written && fputs("terminal-backlog-sentinel\n", file) >= 0 && + fflush(file) == 0; + return fclose(file) == 0 && written; +} + +static void index_supervisor_test_cleanup_handle(cbm_index_worker_handle_t *handle) { + if (!handle) { + return; + } + (void)cbm_index_worker_request_cancel(handle); + const cbm_index_worker_result_t *result = NULL; + if (index_supervisor_test_poll_terminal(handle, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &result)) { + cbm_index_worker_destroy(handle); + } +} + +static void index_supervisor_test_restore_env(const char *name, char *saved) { + if (saved) { + (void)cbm_setenv(name, saved, 1); + } else { + (void)cbm_unsetenv(name); + } + free(saved); +} + +static bool index_supervisor_test_owner_file(const char *path) { +#ifdef _WIN32 + return cbm_file_size(path) >= 0; +#else + struct stat status; + return path && stat(path, &status) == 0 && S_ISREG(status.st_mode) && + (status.st_mode & 0777) == 0600; +#endif +} + +typedef struct { + int delivered; + bool saw_clean_probe; + bool saw_request_a; + bool saw_request_b; + bool saw_structured_text; + bool saw_structured_json; + bool saw_backlog_sentinel; + int backlog_lines; + bool render_progress; +} index_supervisor_log_capture_t; + +static void index_supervisor_test_capture_log(const char *line, void *context) { + index_supervisor_log_capture_t *capture = context; + if (!capture || !line) { + return; + } + capture->delivered++; + if (strstr(line, "async worker clean probe")) { + capture->saw_clean_probe = true; + } + if (strcmp(line, "request-a-only") == 0) { + capture->saw_request_a = true; + } + if (strcmp(line, "request-b-only") == 0) { + capture->saw_request_b = true; + } + if (strcmp(line, "level=info msg=pipeline.discover files=7") == 0) { + capture->saw_structured_text = true; + } + if (strcmp(line, + "{\"level\":\"info\",\"event\":\"pass.start\",\"pass\":\"structure\"}") == 0) { + capture->saw_structured_json = true; + } + if (strncmp(line, "terminal-backlog-", strlen("terminal-backlog-")) == 0) { + capture->backlog_lines++; + } + if (strcmp(line, "terminal-backlog-sentinel") == 0) { + capture->saw_backlog_sentinel = true; + } + if (capture->render_progress) { + cbm_progress_sink_fn(line); + } +} + +TEST(index_supervisor_worker_argv_requires_exact_build_bound_grammar) { + const char *captured = cbm_index_supervisor_build_fingerprint(); + ASSERT_NOT_NULL(captured); + ASSERT_EQ(strlen(captured), CBM_INDEX_WORKER_BUILD_FINGERPRINT_LENGTH); + + char mismatched[CBM_INDEX_WORKER_BUILD_FINGERPRINT_SIZE]; + (void)snprintf(mismatched, sizeof(mismatched), "%s", captured); + mismatched[0] = mismatched[0] == '0' ? '1' : '0'; + + char *valid[] = { + "test-runner", + "cli", + "--index-worker", + CBM_INDEX_WORKER_BUILD_ARG, + (char *)captured, + "index_repository", + "{\"__cbm_test_worker\":\"hang-tree\"}", + "--response-out", + "/tmp/r", + CBM_INDEX_WORKER_MEMORY_BUDGET_ARG, + "1024", + CBM_INDEX_WORKER_SINGLE_THREAD_ARG, + CBM_INDEX_WORKER_MARKER_ARG, + "/tmp/m", + CBM_INDEX_WORKER_QUARANTINE_ARG, + "/tmp/q", + NULL, + }; + cbm_index_worker_invocation_t invocation; + ASSERT_EQ(cbm_index_worker_parse_process_argv(16, valid, &invocation), + CBM_INDEX_WORKER_ARGV_VALID); + ASSERT_EQ(cbm_daemon_process_role(16, valid), CBM_DAEMON_PROCESS_WORKER); + ASSERT_STR_EQ(invocation.args_json, valid[6]); + ASSERT_STR_EQ(invocation.response_out, valid[8]); + ASSERT_EQ(invocation.memory_budget_bytes, 1024); + ASSERT_TRUE(invocation.single_thread); + ASSERT_STR_EQ(invocation.marker_file, valid[13]); + ASSERT_STR_EQ(invocation.quarantine_file, valid[15]); + + char *missing_build[] = {"test-runner", "cli", "--index-worker", "index_repository", + "{}", "--response-out", "/tmp/r", NULL}; + char *wrong_build[] = {"test-runner", "cli", "--index-worker", + CBM_INDEX_WORKER_BUILD_ARG, mismatched, "index_repository", + "{}", "--response-out", "/tmp/r", NULL}; + char *reordered[] = {"test-runner", "cli", "--index-worker", + CBM_INDEX_WORKER_BUILD_ARG, (char *)captured, "index_repository", + "{}", "--response-out", "/tmp/r", + CBM_INDEX_WORKER_QUARANTINE_ARG, "/tmp/q", + CBM_INDEX_WORKER_MARKER_ARG, "/tmp/m", NULL}; + char *trailing[] = {"test-runner", "cli", "--index-worker", + CBM_INDEX_WORKER_BUILD_ARG, (char *)captured, "index_repository", + "{}", "--response-out", "/tmp/r", "unexpected", NULL}; + char *zero_budget[] = {"test-runner", "cli", "--index-worker", + CBM_INDEX_WORKER_BUILD_ARG, (char *)captured, "index_repository", + "{}", "--response-out", "/tmp/r", + CBM_INDEX_WORKER_MEMORY_BUDGET_ARG, "0", NULL}; + char *overflow_budget[] = { + "test-runner", "cli", "--index-worker", CBM_INDEX_WORKER_BUILD_ARG, + (char *)captured, "index_repository", "{}", "--response-out", "/tmp/r", + CBM_INDEX_WORKER_MEMORY_BUDGET_ARG, "184467440737095516160", NULL}; + char *user_value[] = {"test-runner", "cli", "search_code", "--query", + "--index-worker", NULL}; + ASSERT_EQ(cbm_index_worker_parse_process_argv(7, missing_build, &invocation), + CBM_INDEX_WORKER_ARGV_INVALID); + ASSERT_EQ(cbm_daemon_process_role(7, missing_build), CBM_DAEMON_PROCESS_INVALID); + ASSERT_EQ(cbm_index_worker_parse_process_argv(9, wrong_build, &invocation), + CBM_INDEX_WORKER_ARGV_BUILD_MISMATCH); + ASSERT_EQ(cbm_index_worker_parse_process_argv(13, reordered, &invocation), + CBM_INDEX_WORKER_ARGV_INVALID); + ASSERT_EQ(cbm_index_worker_parse_process_argv(10, trailing, &invocation), + CBM_INDEX_WORKER_ARGV_INVALID); + ASSERT_EQ(cbm_index_worker_parse_process_argv(11, zero_budget, &invocation), + CBM_INDEX_WORKER_ARGV_INVALID); + ASSERT_EQ(cbm_daemon_process_role(11, zero_budget), CBM_DAEMON_PROCESS_INVALID); + ASSERT_EQ(cbm_index_worker_parse_process_argv(11, overflow_budget, &invocation), + CBM_INDEX_WORKER_ARGV_INVALID); + ASSERT_EQ(cbm_daemon_process_role(11, overflow_budget), CBM_DAEMON_PROCESS_INVALID); + ASSERT_EQ(cbm_index_worker_parse_process_argv(5, user_value, &invocation), + CBM_INDEX_WORKER_ARGV_INVALID); + ASSERT_EQ(cbm_daemon_process_role(5, user_value), CBM_DAEMON_PROCESS_INVALID); + PASS(); +} + +TEST(index_supervisor_async_jobs_are_isolated_cancellable_and_terminal_cached) { + const char *captured = cbm_index_supervisor_build_fingerprint(); + ASSERT_NOT_NULL(captured); + char *worker_argv[] = { + "test-runner", + "cli", + "--index-worker", + CBM_INDEX_WORKER_BUILD_ARG, + (char *)captured, + "index_repository", + "{\"__cbm_test_worker\":\"hang-tree\"}", + "--response-out", + "/tmp/r", + CBM_INDEX_WORKER_SINGLE_THREAD_ARG, + CBM_INDEX_WORKER_MARKER_ARG, + "/tmp/m", + CBM_INDEX_WORKER_QUARANTINE_ARG, + "/tmp/q", + NULL, + }; + ASSERT_EQ(cbm_daemon_process_role(14, worker_argv), CBM_DAEMON_PROCESS_WORKER); + + char cache[INDEX_SUPERVISOR_TEST_PATH_CAP]; + (void)snprintf(cache, sizeof(cache), "%s/cbm-index-async-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + char marker_a[INDEX_SUPERVISOR_TEST_PATH_CAP]; + char marker_b[INDEX_SUPERVISOR_TEST_PATH_CAP]; + char quarantine_a[INDEX_SUPERVISOR_TEST_PATH_CAP]; + char quarantine_b[INDEX_SUPERVISOR_TEST_PATH_CAP]; + (void)snprintf(marker_a, sizeof(marker_a), "%s/marker-a", cache); + (void)snprintf(marker_b, sizeof(marker_b), "%s/marker-b", cache); + (void)snprintf(quarantine_a, sizeof(quarantine_a), "%s/quarantine-a", cache); + (void)snprintf(quarantine_b, sizeof(quarantine_b), "%s/quarantine-b", cache); + + const char *old_cache = getenv("CBM_CACHE_DIR"); + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + const char *old_marker = getenv("CBM_INDEX_MARKER_FILE"); + const char *old_quarantine = getenv("CBM_INDEX_QUARANTINE_FILE"); + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + char *saved_single = old_single ? cbm_strdup(old_single) : NULL; + char *saved_marker = old_marker ? cbm_strdup(old_marker) : NULL; + char *saved_quarantine = old_quarantine ? cbm_strdup(old_quarantine) : NULL; + (void)cbm_setenv("CBM_CACHE_DIR", cache, 1); + (void)cbm_setenv("CBM_INDEX_SINGLE_THREAD", "parent-single", 1); + (void)cbm_setenv("CBM_INDEX_MARKER_FILE", "parent-marker", 1); + (void)cbm_setenv("CBM_INDEX_QUARANTINE_FILE", "parent-quarantine", 1); + + const char args[] = "{\"__cbm_test_worker\":\"hang-tree\"}"; + cbm_index_worker_handle_t *first = NULL; + cbm_index_worker_handle_t *second = NULL; + index_supervisor_log_capture_t first_capture = {0}; + index_supervisor_log_capture_t second_capture = {0}; + uint64_t start_before = cbm_now_ms(); + int first_rc = cbm_index_worker_start_with_log( + args, 4096, true, marker_a, quarantine_a, index_supervisor_test_capture_log, + &first_capture, &first); + int second_rc = cbm_index_worker_start_with_log( + args, 8192, true, marker_b, quarantine_b, index_supervisor_test_capture_log, + &second_capture, &second); + uint64_t start_elapsed = cbm_now_ms() - start_before; + + char response_a[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0}; + char response_b[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0}; + char log_a[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0}; + char log_b[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0}; + if (first) { + (void)snprintf(response_a, sizeof(response_a), "%s", cbm_index_worker_response_path(first)); + (void)snprintf(log_a, sizeof(log_a), "%s", cbm_index_worker_log_path(first)); + } + if (second) { + (void)snprintf(response_b, sizeof(response_b), "%s", + cbm_index_worker_response_path(second)); + (void)snprintf(log_b, sizeof(log_b), "%s", cbm_index_worker_log_path(second)); + } + bool unique = response_a[0] && response_b[0] && log_a[0] && log_b[0] && + strcmp(response_a, response_b) != 0 && strcmp(log_a, log_b) != 0 && + strcmp(response_a, log_a) != 0 && strcmp(response_b, log_b) != 0; + bool owner_only = index_supervisor_test_owner_file(response_a) && + index_supervisor_test_owner_file(response_b) && + index_supervisor_test_owner_file(log_a) && + index_supervisor_test_owner_file(log_b); + + char ready_a[2048] = {0}; + char ready_b[2048] = {0}; + bool first_ready = + first && index_supervisor_test_wait_file(first, marker_a, ready_a, sizeof(ready_a)); + bool second_ready = + second && index_supervisor_test_wait_file(second, marker_b, ready_b, sizeof(ready_b)); + bool child_options = strstr(ready_a, "single=1") && strstr(ready_a, marker_a) && + strstr(ready_a, quarantine_a) && strstr(ready_a, "budget=4096") && + strstr(ready_b, "single=1") && strstr(ready_b, marker_b) && + strstr(ready_b, quarantine_b) && strstr(ready_b, "budget=8192"); + bool parent_unchanged = strcmp(getenv("CBM_INDEX_SINGLE_THREAD"), "parent-single") == 0 && + strcmp(getenv("CBM_INDEX_MARKER_FILE"), "parent-marker") == 0 && + strcmp(getenv("CBM_INDEX_QUARANTINE_FILE"), "parent-quarantine") == 0; + + bool worker_logs_ready = index_supervisor_test_wait_file_text( + log_a, "async worker hang-tree probe", 3000) && + index_supervisor_test_wait_file_text( + log_b, "async worker hang-tree probe", 3000); + bool callback_lines_injected = worker_logs_ready && + index_supervisor_test_append_log(log_a, "request-a-only\n") && + index_supervisor_test_append_log(log_b, "request-b-only\n"); + + const cbm_index_worker_result_t *premature = (const cbm_index_worker_result_t *)1; + uint64_t poll_before = cbm_now_ms(); + cbm_index_worker_poll_t running = + first ? cbm_index_worker_poll(first, &premature) : CBM_INDEX_WORKER_POLL_ERROR; + uint64_t poll_elapsed = cbm_now_ms() - poll_before; + uint64_t cancel_before = cbm_now_ms(); + bool first_cancel = first && cbm_index_worker_request_cancel(first); + bool second_cancel = second && cbm_index_worker_request_cancel(second); + uint64_t cancel_elapsed = cbm_now_ms() - cancel_before; + + const cbm_index_worker_result_t *first_result = NULL; + const cbm_index_worker_result_t *second_result = NULL; + bool first_terminal = first && index_supervisor_test_poll_terminal( + first, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &first_result); + bool second_terminal = second && index_supervisor_test_poll_terminal( + second, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &second_result); + const cbm_index_worker_result_t *cached = NULL; + int first_callbacks_at_terminal = first_capture.delivered; + bool cached_result = first_terminal && + cbm_index_worker_poll(first, &cached) == CBM_INDEX_WORKER_POLL_TERMINAL && + cached == first_result; + bool callback_terminal_stable = first_capture.delivered == first_callbacks_at_terminal; + bool callback_isolation = callback_lines_injected && first_capture.saw_request_a && + !first_capture.saw_request_b && second_capture.saw_request_b && + !second_capture.saw_request_a; + bool forced_as_expected = true; +#ifndef _WIN32 + forced_as_expected = + first_result && second_result && first_result->forced && second_result->forced; +#endif + bool contained = first_result && second_result && first_result->cancellation_requested && + second_result->cancellation_requested && forced_as_expected && + first_result->tree_quiesced && second_result->tree_quiesced && + !first_result->supervision_failed && !second_result->supervision_failed; + bool terminal_cancel_rejected = first && !cbm_index_worker_request_cancel(first); + bool response_cleaned = cbm_file_size(response_a) < 0 && cbm_file_size(response_b) < 0; + bool failure_logs_kept = cbm_file_size(log_a) >= 0 && cbm_file_size(log_b) >= 0; + + if (first_terminal) { + cbm_index_worker_destroy(first); + } else { + index_supervisor_test_cleanup_handle(first); + } + if (second_terminal) { + cbm_index_worker_destroy(second); + } else { + index_supervisor_test_cleanup_handle(second); + } + (void)cbm_unlink(log_a); + (void)cbm_unlink(log_b); + (void)cbm_unlink(marker_a); + (void)cbm_unlink(marker_b); + index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache); + index_supervisor_test_restore_env("CBM_INDEX_SINGLE_THREAD", saved_single); + index_supervisor_test_restore_env("CBM_INDEX_MARKER_FILE", saved_marker); + index_supervisor_test_restore_env("CBM_INDEX_QUARANTINE_FILE", saved_quarantine); + (void)th_rmtree(cache); + + ASSERT_EQ(first_rc, 0); + ASSERT_EQ(second_rc, 0); + ASSERT_TRUE(start_elapsed < 2000); + ASSERT_TRUE(unique); + ASSERT_TRUE(owner_only); + ASSERT_TRUE(first_ready); + ASSERT_TRUE(second_ready); + ASSERT_TRUE(child_options); + ASSERT_TRUE(parent_unchanged); + ASSERT_EQ(running, CBM_INDEX_WORKER_POLL_RUNNING); + ASSERT_NULL(premature); + ASSERT_TRUE(poll_elapsed < 100); + ASSERT_TRUE(first_cancel); + ASSERT_TRUE(second_cancel); + ASSERT_TRUE(cancel_elapsed < 100); + ASSERT_TRUE(first_terminal); + ASSERT_TRUE(second_terminal); + ASSERT_TRUE(cached_result); + ASSERT_TRUE(callback_terminal_stable); + ASSERT_TRUE(callback_isolation); + ASSERT_TRUE(contained); + ASSERT_TRUE(terminal_cancel_rejected); + ASSERT_TRUE(response_cleaned); + ASSERT_TRUE(failure_logs_kept); + PASS(); +} + +TEST(index_supervisor_sync_wrapper_forwards_cancel_and_drains_tree) { + char cache[INDEX_SUPERVISOR_TEST_PATH_CAP]; + (void)snprintf(cache, sizeof(cache), + "%s/cbm-index-sync-cancel-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *old_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + (void)cbm_setenv("CBM_CACHE_DIR", cache, 1); + + atomic_int cancel_requested; + atomic_init(&cancel_requested, 1); + cbm_index_worker_result_t result = {0}; + int run_status = cbm_index_spawn_worker_with_log_cancel( + "{\"__cbm_test_worker\":\"hang-tree\"}", false, NULL, NULL, + NULL, NULL, &cancel_requested, &result); + bool contained = run_status == 0 && result.cancellation_requested && + result.tree_quiesced && !result.supervision_failed && + result.response == NULL; + cbm_index_worker_result_free(&result); + index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache); + (void)th_rmtree(cache); + + ASSERT_TRUE(contained); + PASS(); +} + +static bool index_supervisor_test_run_probe(const char *mode, bool profiling, + cbm_proc_outcome_t *outcome_out, bool *has_response_out, + bool *log_exists_out, bool *response_path_exists_out) { + char args[128]; + (void)snprintf(args, sizeof(args), "{\"__cbm_test_worker\":\"%s\"}", mode); + cbm_profile_active = profiling; + cbm_index_worker_handle_t *handle = NULL; + if (cbm_index_worker_start(args, 0, false, NULL, NULL, &handle) != 0 || !handle) { + return false; + } + char log_path[INDEX_SUPERVISOR_TEST_PATH_CAP]; + char response_path[INDEX_SUPERVISOR_TEST_PATH_CAP]; + (void)snprintf(log_path, sizeof(log_path), "%s", cbm_index_worker_log_path(handle)); + (void)snprintf(response_path, sizeof(response_path), "%s", + cbm_index_worker_response_path(handle)); + const cbm_index_worker_result_t *result = NULL; + bool terminal = + index_supervisor_test_poll_terminal(handle, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &result); + if (terminal && result) { + *outcome_out = result->outcome; + *has_response_out = result->response != NULL; + *log_exists_out = cbm_file_size(log_path) >= 0; + *response_path_exists_out = cbm_file_size(response_path) >= 0; + cbm_index_worker_destroy(handle); + } else { + index_supervisor_test_cleanup_handle(handle); + } + if (cbm_file_size(log_path) >= 0) { + (void)cbm_unlink(log_path); + } + return terminal; +} + +TEST(index_supervisor_terminal_log_lifecycle_matches_outcome_and_profiling) { + char cache[INDEX_SUPERVISOR_TEST_PATH_CAP]; + (void)snprintf(cache, sizeof(cache), "%s/cbm-index-logs-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *old_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + bool saved_profile = cbm_profile_active; + (void)cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_proc_outcome_t clean_outcome = CBM_PROC_SPAWN_FAILED; + cbm_proc_outcome_t profile_outcome = CBM_PROC_SPAWN_FAILED; + cbm_proc_outcome_t crash_outcome = CBM_PROC_SPAWN_FAILED; + bool clean_response = false; + bool clean_log = true; + bool clean_response_file = true; + bool profile_response = false; + bool profile_log = false; + bool profile_response_file = true; + bool crash_response = true; + bool crash_log = false; + bool crash_response_file = true; + bool clean_terminal = index_supervisor_test_run_probe( + "clean", false, &clean_outcome, &clean_response, &clean_log, &clean_response_file); + bool profile_terminal = index_supervisor_test_run_probe( + "clean", true, &profile_outcome, &profile_response, &profile_log, &profile_response_file); + bool crash_terminal = index_supervisor_test_run_probe( + "crash", false, &crash_outcome, &crash_response, &crash_log, &crash_response_file); +#ifdef _WIN32 + bool crash_classified_failure = crash_outcome != CBM_PROC_CLEAN; +#else + bool crash_classified_failure = crash_outcome == CBM_PROC_CRASH; +#endif + + cbm_profile_active = saved_profile; + index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache); + (void)th_rmtree(cache); + + ASSERT_TRUE(clean_terminal); + ASSERT_EQ(clean_outcome, CBM_PROC_CLEAN); + ASSERT_TRUE(clean_response); + ASSERT_FALSE(clean_log); + ASSERT_FALSE(clean_response_file); + ASSERT_TRUE(profile_terminal); + ASSERT_EQ(profile_outcome, CBM_PROC_CLEAN); + ASSERT_TRUE(profile_response); + ASSERT_TRUE(profile_log); + ASSERT_FALSE(profile_response_file); + ASSERT_TRUE(crash_terminal); + ASSERT_TRUE(crash_classified_failure); + ASSERT_FALSE(crash_response); + ASSERT_TRUE(crash_log); + ASSERT_FALSE(crash_response_file); + PASS(); +} + +TEST(index_supervisor_drains_terminal_backlog_into_request_progress_callback) { + char cache[INDEX_SUPERVISOR_TEST_PATH_CAP]; + (void)snprintf(cache, sizeof(cache), "%s/cbm-index-relay-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *old_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + (void)cbm_setenv("CBM_CACHE_DIR", cache, 1); + + FILE *progress = tmpfile(); + ASSERT_NOT_NULL(progress); + index_supervisor_log_capture_t capture = {.render_progress = true}; + cbm_progress_sink_init(progress); + cbm_index_worker_handle_t *handle = NULL; + int start_rc = cbm_index_worker_start_with_log( + "{\"__cbm_test_worker\":\"clean\"}", 0, false, NULL, NULL, + index_supervisor_test_capture_log, &capture, &handle); + char log_path[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0}; + if (handle) { + (void)snprintf(log_path, sizeof(log_path), "%s", cbm_index_worker_log_path(handle)); + } + bool worker_logged = log_path[0] && index_supervisor_test_wait_file_text( + log_path, "async worker clean probe", 3000); + if (worker_logged) { + /* Seeing the flushed probe places the child immediately before _Exit. + * Give it time to become waitable without polling: the regression is a + * backlog already present when the first terminal poll occurs. */ + cbm_usleep(250000); + } + bool backlog_written = worker_logged && + index_supervisor_test_append_terminal_backlog(log_path); + const cbm_index_worker_result_t *result = NULL; + bool terminal = handle && index_supervisor_test_poll_terminal( + handle, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &result); + cbm_proc_outcome_t outcome = result ? result->outcome : CBM_PROC_SPAWN_FAILED; + int callbacks_at_terminal = capture.delivered; + const cbm_index_worker_result_t *cached = NULL; + bool terminal_cached = terminal && + cbm_index_worker_poll(handle, &cached) == + CBM_INDEX_WORKER_POLL_TERMINAL && + cached == result && capture.delivered == callbacks_at_terminal; + cbm_progress_sink_fini(); + bool progress_rewound = fseek(progress, 0, SEEK_SET) == 0; + char rendered[1024] = {0}; + size_t rendered_size = progress_rewound + ? fread(rendered, 1, sizeof(rendered) - 1, progress) + : 0; + (void)fclose(progress); + if (terminal) { + cbm_index_worker_destroy(handle); + } else { + index_supervisor_test_cleanup_handle(handle); + } + + index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache); + (void)th_rmtree(cache); + + ASSERT_EQ(start_rc, 0); + ASSERT_TRUE(worker_logged); + ASSERT_TRUE(backlog_written); + ASSERT_TRUE(terminal); + ASSERT_EQ(outcome, CBM_PROC_CLEAN); + ASSERT_TRUE(terminal_cached); + ASSERT_TRUE(capture.saw_clean_probe); + ASSERT_TRUE(capture.saw_structured_text); + ASSERT_TRUE(capture.saw_structured_json); + ASSERT_EQ(capture.backlog_lines, INDEX_SUPERVISOR_TEST_BACKLOG_LINES + 1); + ASSERT_TRUE(capture.saw_backlog_sentinel); + ASSERT_TRUE(progress_rewound); + ASSERT_TRUE(rendered_size > 0); + ASSERT_NOT_NULL(strstr(rendered, "Discovering files (7 found)")); + ASSERT_NOT_NULL(strstr(rendered, "[1/9] Building file structure")); + PASS(); +} + +TEST(index_supervisor_oversized_response_is_contained_and_log_is_retained) { + char cache[INDEX_SUPERVISOR_TEST_PATH_CAP]; + (void)snprintf(cache, sizeof(cache), "%s/cbm-index-oversize-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + const char *old_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + (void)cbm_setenv("CBM_CACHE_DIR", cache, 1); + + cbm_index_worker_handle_t *handle = NULL; + int start_rc = cbm_index_worker_start("{\"__cbm_test_worker\":\"oversize\"}", 0, false, + NULL, NULL, &handle); + char log_path[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0}; + char response_path[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0}; + if (handle) { + (void)snprintf(log_path, sizeof(log_path), "%s", cbm_index_worker_log_path(handle)); + (void)snprintf(response_path, sizeof(response_path), "%s", + cbm_index_worker_response_path(handle)); + } + const cbm_index_worker_result_t *result = NULL; + bool terminal = handle && index_supervisor_test_poll_terminal( + handle, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &result); + bool rejected = terminal && result && result->response_rejected && !result->response && + result->outcome == CBM_PROC_EXIT_NONZERO && result->tree_quiesced && + !result->supervision_failed; + bool log_retained = log_path[0] && cbm_file_size(log_path) >= 0; + bool response_removed = response_path[0] && cbm_file_size(response_path) < 0; + if (terminal) { + cbm_index_worker_destroy(handle); + } else { + index_supervisor_test_cleanup_handle(handle); + } + (void)cbm_unlink(log_path); + index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache); + (void)th_rmtree(cache); + + ASSERT_EQ(start_rc, 0); + ASSERT_TRUE(terminal); + ASSERT_TRUE(rejected); + ASSERT_TRUE(log_retained); + ASSERT_TRUE(response_removed); + PASS(); +} + +SUITE(index_supervisor) { + RUN_TEST(index_supervisor_worker_argv_requires_exact_build_bound_grammar); + RUN_TEST(index_supervisor_async_jobs_are_isolated_cancellable_and_terminal_cached); + RUN_TEST(index_supervisor_sync_wrapper_forwards_cancel_and_drains_tree); + RUN_TEST(index_supervisor_terminal_log_lifecycle_matches_outcome_and_profiling); + RUN_TEST(index_supervisor_drains_terminal_backlog_into_request_progress_callback); + RUN_TEST(index_supervisor_oversized_response_is_contained_and_log_is_retained); +} diff --git a/tests/test_lock_registry.c b/tests/test_lock_registry.c new file mode 100644 index 000000000..298f12cfd --- /dev/null +++ b/tests/test_lock_registry.c @@ -0,0 +1,1670 @@ +/* RED contract for the generic writer-preference lock registry. */ +#include "test_framework.h" + +#include "foundation/compat.h" +#include "foundation/compat_thread.h" +#include "foundation/lock_registry.h" +#include "foundation/lock_registry_internal.h" +#include "foundation/platform.h" +#include "foundation/private_file_lock_internal.h" + +#include +#include +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +enum { + LOCK_REGISTRY_TEST_PATH_CAP = 1024, + LOCK_REGISTRY_TEST_TIMEOUT_MS = 5000, + LOCK_REGISTRY_STRESS_THREADS = 8, + LOCK_REGISTRY_STRESS_ITERATIONS = 160, + LOCK_REGISTRY_PARKING_WAITERS = 64, +}; + +typedef struct { + char parent[LOCK_REGISTRY_TEST_PATH_CAP]; + char root[LOCK_REGISTRY_TEST_PATH_CAP]; + cbm_private_lock_directory_t *directory; + cbm_lock_registry_t *registry; +} lock_registry_fixture_t; + +static void lock_registry_test_yield(void) { +#ifdef _WIN32 + (void)SwitchToThread(); +#else + (void)sched_yield(); +#endif +} + +#ifndef _WIN32 +static cbm_private_lock_directory_t *lock_registry_test_directory_open(const char *root) { + int fd = open(root, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + cbm_private_lock_directory_t *directory = NULL; + if (fd < 0 || + cbm_private_lock_directory_adopt_posix(fd, root, &directory) != CBM_PRIVATE_FILE_LOCK_OK) { + if (fd >= 0) { + (void)close(fd); + } + return NULL; + } + return directory; +} +#endif + +static bool lock_registry_fixture_start(lock_registry_fixture_t *fixture) { + memset(fixture, 0, sizeof(*fixture)); +#ifdef _WIN32 + return false; +#else + int written = snprintf(fixture->parent, sizeof(fixture->parent), "%s/cbm-lock-registry-XXXXXX", + cbm_tmpdir()); + if (written <= 0 || written >= (int)sizeof(fixture->parent) || !cbm_mkdtemp(fixture->parent)) { + return false; + } + written = snprintf(fixture->root, sizeof(fixture->root), "%s/root", fixture->parent); + if (written <= 0 || written >= (int)sizeof(fixture->root) || mkdir(fixture->root, 0700) != 0) { + return false; + } + fixture->directory = lock_registry_test_directory_open(fixture->root); + fixture->registry = cbm_lock_registry_new(fixture->directory); + return fixture->directory != NULL && fixture->registry != NULL; +#endif +} + +static void lock_registry_fixture_finish(lock_registry_fixture_t *fixture) { + (void)cbm_lock_registry_free(&fixture->registry); + cbm_private_lock_directory_close(fixture->directory); +#ifndef _WIN32 + DIR *directory = opendir(fixture->root); + if (directory) { + struct dirent *entry; + while ((entry = readdir(directory)) != NULL) { + if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) { + continue; + } + char path[LOCK_REGISTRY_TEST_PATH_CAP]; + int written = snprintf(path, sizeof(path), "%s/%s", fixture->root, entry->d_name); + if (written > 0 && written < (int)sizeof(path)) { + (void)unlink(path); + } + } + (void)closedir(directory); + } + (void)rmdir(fixture->root); + (void)rmdir(fixture->parent); +#endif + memset(fixture, 0, sizeof(*fixture)); +} + +typedef struct { + cbm_lock_registry_t *registry; + const char *resource_key; + cbm_private_file_lock_mode_t mode; + cbm_lock_cancel_token_t cancel_token; + atomic_bool finished; + cbm_private_file_lock_status_t status; + cbm_lock_lease_t *lease; +} lock_registry_waiter_t; + +static void *lock_registry_waiter_run(void *opaque) { + lock_registry_waiter_t *waiter = opaque; + waiter->status = + cbm_lock_registry_acquire(waiter->registry, waiter->resource_key, waiter->mode, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, + &waiter->cancel_token, &waiter->lease); + atomic_store_explicit(&waiter->finished, true, memory_order_release); + return NULL; +} + +typedef struct { + cbm_lock_cancel_token_t cancel_token; + atomic_bool native_ready; +} lock_registry_rollback_fault_t; + +static void lock_registry_cancel_at_native_ready(void *opaque, cbm_private_file_lock_mode_t mode, + cbm_lock_registry_stage_t stage) { + lock_registry_rollback_fault_t *fault = opaque; + if (mode == CBM_PRIVATE_FILE_LOCK_EX && stage == CBM_LOCK_REGISTRY_STAGE_NATIVE_READY) { + atomic_store_explicit(&fault->native_ready, true, memory_order_release); + atomic_store_explicit(&fault->cancel_token, true, memory_order_release); + } +} + +TEST(lock_registry_cancelled_wait_rolls_back_and_does_not_barge) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX native-directory registry RED runs on POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_lock_lease_t *holder = NULL; + cbm_private_file_lock_status_t holder_status = + started ? cbm_lock_registry_acquire( + fixture.registry, "cancelled-waiter", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &holder) + : CBM_PRIVATE_FILE_LOCK_IO; + + lock_registry_waiter_t waiter = {.registry = fixture.registry, + .resource_key = "cancelled-waiter", + .mode = CBM_PRIVATE_FILE_LOCK_EX, + .status = CBM_PRIVATE_FILE_LOCK_IO}; + atomic_init(&waiter.cancel_token, false); + atomic_init(&waiter.finished, false); + cbm_thread_t thread; + bool thread_started = holder_status == CBM_PRIVATE_FILE_LOCK_OK && + cbm_thread_create(&thread, 0, lock_registry_waiter_run, &waiter) == 0; + bool queued = false; + bool writer_has_turn = false; + char turn_name[CBM_LOCK_REGISTRY_NAME_CAP]; + char rw_name[CBM_LOCK_REGISTRY_NAME_CAP]; + bool names_ok = cbm_lock_registry_resource_names("cancelled-waiter", turn_name, rw_name); + uint64_t observe_deadline = cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS; + while (thread_started && cbm_now_ms() < observe_deadline) { + if (cbm_lock_registry_waiter_count(fixture.registry) == 1) { + queued = true; + cbm_private_file_lock_t *probe = NULL; + cbm_private_file_lock_status_t probe_status = + names_ok ? cbm_private_file_lock_try_acquire(fixture.directory, turn_name, + CBM_PRIVATE_FILE_LOCK_EX, &probe) + : CBM_PRIVATE_FILE_LOCK_IO; + if (probe_status == CBM_PRIVATE_FILE_LOCK_BUSY) { + writer_has_turn = true; + break; + } + if (probe) { + (void)cbm_private_file_lock_release(&probe); + } + } + if (atomic_load_explicit(&waiter.finished, memory_order_acquire)) { + break; + } + lock_registry_test_yield(); + } + if (thread_started) { + (void)cbm_lock_registry_request_cancel(fixture.registry, &waiter.cancel_token); + (void)cbm_thread_join(&thread); + } + cbm_private_file_lock_t *after_turn = NULL; + cbm_private_file_lock_status_t after_turn_status = + names_ok ? cbm_private_file_lock_try_acquire(fixture.directory, turn_name, + CBM_PRIVATE_FILE_LOCK_EX, &after_turn) + : CBM_PRIVATE_FILE_LOCK_IO; + if (after_turn) { + (void)cbm_private_file_lock_release(&after_turn); + } + cbm_private_file_lock_status_t holder_release = + holder ? cbm_lock_lease_release(&holder) : CBM_PRIVATE_FILE_LOCK_IO; + cbm_lock_lease_t *after = NULL; + cbm_private_file_lock_status_t after_status = + started ? cbm_lock_registry_acquire( + fixture.registry, "cancelled-waiter", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) + : CBM_PRIVATE_FILE_LOCK_IO; + if (after) { + (void)cbm_lock_lease_release(&after); + } + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(holder_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(queued); + ASSERT_TRUE(names_ok); + ASSERT_TRUE(writer_has_turn); + ASSERT_EQ(waiter.status, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_NULL(waiter.lease); + ASSERT_EQ(after_turn_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(holder_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(after_status, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} + +TEST(lock_registry_failed_rollback_returns_cleanup_only_lease) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX native-directory registry rollback runs on POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + lock_registry_rollback_fault_t fault; + atomic_init(&fault.cancel_token, false); + atomic_init(&fault.native_ready, false); + bool hook_set = started && cbm_lock_registry_set_stage_hook_for_test( + fixture.registry, lock_registry_cancel_at_native_ready, &fault); + bool fault_set = hook_set && cbm_lock_registry_fail_next_native_release_step_for_test( + fixture.registry, CBM_LOCK_REGISTRY_RELEASE_RW, + CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK); + cbm_lock_lease_t *cleanup = NULL; + cbm_private_file_lock_status_t status = + fault_set ? cbm_lock_registry_acquire(fixture.registry, "rollback-cleanup", + CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, + &fault.cancel_token, &cleanup) + : CBM_PRIVATE_FILE_LOCK_IO; + bool reached_native_ready = atomic_load_explicit(&fault.native_ready, memory_order_acquire); + bool cleanup_retained = cleanup != NULL; + + char turn_name[CBM_LOCK_REGISTRY_NAME_CAP]; + char rw_name[CBM_LOCK_REGISTRY_NAME_CAP]; + bool names_ok = cbm_lock_registry_resource_names("rollback-cleanup", turn_name, rw_name); + cbm_private_file_lock_t *probe = NULL; + cbm_private_file_lock_status_t while_cleanup_pending = + names_ok ? cbm_private_file_lock_try_acquire(fixture.directory, rw_name, + CBM_PRIVATE_FILE_LOCK_EX, &probe) + : CBM_PRIVATE_FILE_LOCK_IO; + if (probe) { + (void)cbm_private_file_lock_release(&probe); + } + cbm_private_file_lock_status_t free_while_pending = + started ? cbm_lock_registry_free(&fixture.registry) : CBM_PRIVATE_FILE_LOCK_IO; + bool registry_preserved = fixture.registry != NULL; + cbm_private_file_lock_status_t cleanup_release = + cleanup ? cbm_lock_lease_release(&cleanup) : CBM_PRIVATE_FILE_LOCK_IO; + if (cleanup) { + (void)cbm_lock_lease_release(&cleanup); + } + cbm_private_file_lock_status_t final_free = + registry_preserved ? cbm_lock_registry_free(&fixture.registry) : CBM_PRIVATE_FILE_LOCK_IO; + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(hook_set); + ASSERT_TRUE(fault_set); + ASSERT_TRUE(reached_native_ready); + ASSERT_EQ(status, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(cleanup_retained); + ASSERT_TRUE(names_ok); + ASSERT_EQ(while_cleanup_pending, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_EQ(free_while_pending, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_TRUE(registry_preserved); + ASSERT_EQ(cleanup_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(cleanup); + ASSERT_EQ(final_free, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} + +#ifndef _WIN32 +static int lock_registry_abort_bookkeeping_failure_retains_cleanup( + cbm_lock_registry_abort_failure_t failure, const char *resource_key) { + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + lock_registry_rollback_fault_t fault; + atomic_init(&fault.cancel_token, false); + atomic_init(&fault.native_ready, false); + bool hook_set = started && cbm_lock_registry_set_stage_hook_for_test( + fixture.registry, lock_registry_cancel_at_native_ready, &fault); + bool abort_fault_set = hook_set && cbm_lock_registry_fail_next_abort_bookkeeping_for_test( + fixture.registry, failure); + bool release_fault_set = + abort_fault_set && + cbm_lock_registry_fail_next_native_release_step_for_test( + fixture.registry, CBM_LOCK_REGISTRY_RELEASE_RW, CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK); + + cbm_lock_lease_t *cleanup = NULL; + cbm_private_file_lock_status_t status = + release_fault_set + ? cbm_lock_registry_acquire(fixture.registry, resource_key, CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, + &fault.cancel_token, &cleanup) + : CBM_PRIVATE_FILE_LOCK_IO; + bool reached_native_ready = atomic_load_explicit(&fault.native_ready, memory_order_acquire); + bool cleanup_retained = cleanup != NULL; + bool owns_rw = + cbm_lock_lease_has_release_handle_for_test(cleanup, CBM_LOCK_REGISTRY_RELEASE_RW); + bool owns_turn = + cbm_lock_lease_has_release_handle_for_test(cleanup, CBM_LOCK_REGISTRY_RELEASE_TURN); + bool used_exact_lock_failure_path = + failure != CBM_LOCK_REGISTRY_ABORT_FAIL_LOCK || + cbm_lock_lease_used_abort_lock_failure_path_for_test(cleanup); + size_t waiter_before_release = cbm_lock_registry_waiter_count(fixture.registry); + size_t pending_before_release = + cbm_lock_registry_pending_cleanup_count_for_test(fixture.registry); + + char turn_name[CBM_LOCK_REGISTRY_NAME_CAP]; + char rw_name[CBM_LOCK_REGISTRY_NAME_CAP]; + bool names_ok = cbm_lock_registry_resource_names(resource_key, turn_name, rw_name); + cbm_private_file_lock_t *probe = NULL; + cbm_private_file_lock_status_t native_before_release = + names_ok ? cbm_private_file_lock_try_acquire(fixture.directory, rw_name, + CBM_PRIVATE_FILE_LOCK_EX, &probe) + : CBM_PRIVATE_FILE_LOCK_IO; + if (probe) { + (void)cbm_private_file_lock_release(&probe); + } + cbm_private_file_lock_status_t free_while_waiter = + started ? cbm_lock_registry_free(&fixture.registry) : CBM_PRIVATE_FILE_LOCK_IO; + bool registry_preserved = fixture.registry != NULL; + + cbm_private_file_lock_status_t first_cleanup_release = + cleanup ? cbm_lock_lease_release(&cleanup) : CBM_PRIVATE_FILE_LOCK_IO; + bool retained_after_native_failure = cleanup != NULL; + size_t waiter_after_detach = cbm_lock_registry_waiter_count(fixture.registry); + size_t pending_after_detach = + cbm_lock_registry_pending_cleanup_count_for_test(fixture.registry); + probe = NULL; + cbm_private_file_lock_status_t native_while_pending = + names_ok ? cbm_private_file_lock_try_acquire(fixture.directory, rw_name, + CBM_PRIVATE_FILE_LOCK_EX, &probe) + : CBM_PRIVATE_FILE_LOCK_IO; + if (probe) { + (void)cbm_private_file_lock_release(&probe); + } + cbm_private_file_lock_status_t free_while_pending = + registry_preserved ? cbm_lock_registry_free(&fixture.registry) : CBM_PRIVATE_FILE_LOCK_IO; + + cbm_private_file_lock_status_t second_cleanup_release = + cleanup ? cbm_lock_lease_release(&cleanup) : CBM_PRIVATE_FILE_LOCK_IO; + size_t waiter_after_cleanup = cbm_lock_registry_waiter_count(fixture.registry); + size_t pending_after_cleanup = + cbm_lock_registry_pending_cleanup_count_for_test(fixture.registry); + cbm_lock_lease_t *after = NULL; + cbm_private_file_lock_status_t after_status = + cleanup_retained && fixture.registry + ? cbm_lock_registry_acquire(fixture.registry, resource_key, CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t after_release = + after ? cbm_lock_lease_release(&after) : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t final_free = cleanup_retained && fixture.registry + ? cbm_lock_registry_free(&fixture.registry) + : CBM_PRIVATE_FILE_LOCK_IO; + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(hook_set); + ASSERT_TRUE(abort_fault_set); + ASSERT_TRUE(release_fault_set); + ASSERT_TRUE(reached_native_ready); + ASSERT_EQ(status, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(cleanup_retained); + ASSERT_TRUE(owns_rw); + ASSERT_TRUE(owns_turn); + ASSERT_TRUE(used_exact_lock_failure_path); + ASSERT_EQ(waiter_before_release, 1); + ASSERT_EQ(pending_before_release, 0); + ASSERT_TRUE(names_ok); + ASSERT_EQ(native_before_release, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_EQ(free_while_waiter, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_TRUE(registry_preserved); + ASSERT_EQ(first_cleanup_release, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(retained_after_native_failure); + ASSERT_EQ(waiter_after_detach, 0); + ASSERT_EQ(pending_after_detach, 1); + ASSERT_EQ(native_while_pending, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_EQ(free_while_pending, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_EQ(second_cleanup_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(cleanup); + ASSERT_EQ(waiter_after_cleanup, 0); + ASSERT_EQ(pending_after_cleanup, 0); + ASSERT_EQ(after_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(after_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(final_free, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +} + +TEST(lock_registry_terminal_close_error_finishes_pending_accounting) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX consumed-close registry accounting runs on POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_lock_lease_t *reader = NULL; + cbm_private_file_lock_status_t acquired = + started ? cbm_lock_registry_acquire( + fixture.registry, "terminal-close-accounting", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &reader) + : CBM_PRIVATE_FILE_LOCK_IO; + bool retryable_close_set = cbm_lock_lease_fail_next_release_step_for_test( + reader, CBM_LOCK_REGISTRY_RELEASE_RW, CBM_PRIVATE_FILE_LOCK_RELEASE_CLOSE); + cbm_private_file_lock_status_t first_release = + retryable_close_set ? cbm_lock_lease_release(&reader) : CBM_PRIVATE_FILE_LOCK_IO; + bool retained_pending = reader != NULL; + size_t active_pending = cbm_lock_registry_active_lease_count_for_test(fixture.registry); + size_t cleanup_pending = cbm_lock_registry_pending_cleanup_count_for_test(fixture.registry); + + bool terminal_close_set = + cbm_lock_lease_fail_close_after_consuming_for_test(reader, CBM_LOCK_REGISTRY_RELEASE_RW); + cbm_private_file_lock_status_t terminal_release = + terminal_close_set ? cbm_lock_lease_release(&reader) : CBM_PRIVATE_FILE_LOCK_IO; + size_t cleanup_finished = cbm_lock_registry_pending_cleanup_count_for_test(fixture.registry); + cbm_lock_lease_t *after = NULL; + cbm_private_file_lock_status_t after_status = + started ? cbm_lock_registry_acquire( + fixture.registry, "terminal-close-accounting", CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t after_release = + after ? cbm_lock_lease_release(&after) : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t final_free = + started ? cbm_lock_registry_free(&fixture.registry) : CBM_PRIVATE_FILE_LOCK_IO; + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(acquired, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(retryable_close_set); + ASSERT_EQ(first_release, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(retained_pending); + ASSERT_EQ(active_pending, 0); + ASSERT_EQ(cleanup_pending, 1); + ASSERT_TRUE(terminal_close_set); + ASSERT_EQ(terminal_release, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_NULL(reader); + ASSERT_EQ(cleanup_finished, 0); + ASSERT_EQ(after_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(after_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(final_free, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} +#endif + +TEST(lock_registry_abort_lock_failure_returns_waiter_cleanup_lease) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX native-directory registry abort cleanup runs on POSIX"); +#else + return lock_registry_abort_bookkeeping_failure_retains_cleanup( + CBM_LOCK_REGISTRY_ABORT_FAIL_LOCK, "abort-lock-cleanup"); +#endif +} + +TEST(lock_registry_abort_remove_failure_returns_waiter_cleanup_lease) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX native-directory registry abort cleanup runs on POSIX"); +#else + return lock_registry_abort_bookkeeping_failure_retains_cleanup( + CBM_LOCK_REGISTRY_ABORT_FAIL_REMOVE, "abort-remove-cleanup"); +#endif +} + +TEST(lock_registry_never_upgrades_shared_lease_in_place) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX native-directory registry RED runs on POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_lock_lease_t *reader = NULL; + cbm_private_file_lock_status_t reader_status = + started + ? cbm_lock_registry_acquire(fixture.registry, "no-upgrade", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &reader) + : CBM_PRIVATE_FILE_LOCK_IO; + lock_registry_waiter_t writer = {.registry = fixture.registry, + .resource_key = "no-upgrade", + .mode = CBM_PRIVATE_FILE_LOCK_EX, + .status = CBM_PRIVATE_FILE_LOCK_IO}; + atomic_init(&writer.cancel_token, false); + atomic_init(&writer.finished, false); + cbm_thread_t writer_thread; + bool writer_started = + reader_status == CBM_PRIVATE_FILE_LOCK_OK && + cbm_thread_create(&writer_thread, 0, lock_registry_waiter_run, &writer) == 0; + char turn_name[CBM_LOCK_REGISTRY_NAME_CAP]; + char rw_name[CBM_LOCK_REGISTRY_NAME_CAP]; + bool names_ok = cbm_lock_registry_resource_names("no-upgrade", turn_name, rw_name); + bool writer_has_turn = false; + uint64_t deadline = cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS; + while (writer_started && names_ok && cbm_now_ms() < deadline) { + cbm_private_file_lock_t *probe = NULL; + cbm_private_file_lock_status_t probe_status = cbm_private_file_lock_try_acquire( + fixture.directory, turn_name, CBM_PRIVATE_FILE_LOCK_EX, &probe); + if (probe_status == CBM_PRIVATE_FILE_LOCK_BUSY) { + writer_has_turn = true; + break; + } + if (probe) { + (void)cbm_private_file_lock_release(&probe); + } + if (atomic_load_explicit(&writer.finished, memory_order_acquire)) { + break; + } + lock_registry_test_yield(); + } + bool finished_before_reader_release = + atomic_load_explicit(&writer.finished, memory_order_acquire); + cbm_private_file_lock_status_t reader_release = + reader ? cbm_lock_lease_release(&reader) : CBM_PRIVATE_FILE_LOCK_IO; + if (writer_started) { + (void)cbm_thread_join(&writer_thread); + } + cbm_private_file_lock_status_t writer_release = + writer.lease ? cbm_lock_lease_release(&writer.lease) : CBM_PRIVATE_FILE_LOCK_IO; + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(reader_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(writer_started); + ASSERT_TRUE(names_ok); + ASSERT_TRUE(writer_has_turn); + ASSERT_FALSE(finished_before_reader_release); + ASSERT_EQ(reader_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(writer.status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(writer_release, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} + +TEST(lock_registry_reader_close_failure_retains_lease_and_accounting) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX native-directory registry lifecycle runs on POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_lock_lease_t *reader = NULL; + cbm_private_file_lock_status_t acquired = + started ? cbm_lock_registry_acquire( + fixture.registry, "retry-reader-close", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &reader) + : CBM_PRIVATE_FILE_LOCK_IO; + bool fault_set = cbm_lock_lease_fail_next_release_step_for_test( + reader, CBM_LOCK_REGISTRY_RELEASE_RW, CBM_PRIVATE_FILE_LOCK_RELEASE_CLOSE); + cbm_private_file_lock_status_t first_release = + fault_set ? cbm_lock_lease_release(&reader) : CBM_PRIVATE_FILE_LOCK_IO; + bool retained = reader != NULL; + size_t active_after_failure = cbm_lock_registry_active_lease_count_for_test(fixture.registry); + size_t pending_after_failure = + cbm_lock_registry_pending_cleanup_count_for_test(fixture.registry); + + cbm_lock_lease_t *blocked_writer = NULL; + cbm_private_file_lock_status_t while_close_pending = + started ? cbm_lock_registry_acquire(fixture.registry, "retry-reader-close", + CBM_PRIVATE_FILE_LOCK_EX, cbm_now_ms() + 50, NULL, + &blocked_writer) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t blocked_writer_release = + blocked_writer ? cbm_lock_lease_release(&blocked_writer) : CBM_PRIVATE_FILE_LOCK_IO; + + bool duplicate_unlock_fault_set = cbm_lock_lease_fail_next_release_step_for_test( + reader, CBM_LOCK_REGISTRY_RELEASE_RW, CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK); + cbm_private_file_lock_status_t retry = + reader ? cbm_lock_lease_release(&reader) : CBM_PRIVATE_FILE_LOCK_IO; + if (reader) { + (void)cbm_lock_lease_release(&reader); + } + size_t active_after_retry = cbm_lock_registry_active_lease_count_for_test(fixture.registry); + cbm_lock_lease_t *after = NULL; + cbm_private_file_lock_status_t after_status = + started ? cbm_lock_registry_acquire( + fixture.registry, "retry-reader-close", CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t after_release = + after ? cbm_lock_lease_release(&after) : CBM_PRIVATE_FILE_LOCK_IO; + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(acquired, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(fault_set); + ASSERT_EQ(first_release, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(retained); + ASSERT_EQ(active_after_failure, 0); + ASSERT_EQ(pending_after_failure, 1); + ASSERT_EQ(while_close_pending, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(blocked_writer); + ASSERT_EQ(blocked_writer_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(duplicate_unlock_fault_set); + ASSERT_EQ(retry, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(reader); + ASSERT_EQ(active_after_retry, 0); + ASSERT_EQ(after_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(after_release, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} + +TEST(lock_registry_writer_partial_release_retries_rw_then_turn) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX native-directory registry lifecycle runs on POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_lock_lease_t *writer = NULL; + cbm_private_file_lock_status_t acquired = + started ? cbm_lock_registry_acquire( + fixture.registry, "retry-writer-turn", CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &writer) + : CBM_PRIVATE_FILE_LOCK_IO; + bool fault_set = cbm_lock_lease_fail_next_release_step_for_test( + writer, CBM_LOCK_REGISTRY_RELEASE_TURN, CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK); + cbm_private_file_lock_status_t first_release = + fault_set ? cbm_lock_lease_release(&writer) : CBM_PRIVATE_FILE_LOCK_IO; + bool retained = writer != NULL; + bool rw_closed = + !cbm_lock_lease_has_release_handle_for_test(writer, CBM_LOCK_REGISTRY_RELEASE_RW); + bool turn_retained = + cbm_lock_lease_has_release_handle_for_test(writer, CBM_LOCK_REGISTRY_RELEASE_TURN); + size_t active_after_failure = cbm_lock_registry_active_lease_count_for_test(fixture.registry); + size_t pending_after_failure = + cbm_lock_registry_pending_cleanup_count_for_test(fixture.registry); + + char turn_name[CBM_LOCK_REGISTRY_NAME_CAP]; + char rw_name[CBM_LOCK_REGISTRY_NAME_CAP]; + bool names_ok = cbm_lock_registry_resource_names("retry-writer-turn", turn_name, rw_name); + cbm_private_file_lock_t *rw_probe = NULL; + cbm_private_file_lock_status_t rw_status = + names_ok ? cbm_private_file_lock_try_acquire(fixture.directory, rw_name, + CBM_PRIVATE_FILE_LOCK_EX, &rw_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + if (rw_probe) { + (void)cbm_private_file_lock_release(&rw_probe); + } + cbm_private_file_lock_t *turn_probe = NULL; + cbm_private_file_lock_status_t turn_status = + names_ok ? cbm_private_file_lock_try_acquire(fixture.directory, turn_name, + CBM_PRIVATE_FILE_LOCK_EX, &turn_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + if (turn_probe) { + (void)cbm_private_file_lock_release(&turn_probe); + } + + cbm_lock_lease_t *next_writer = NULL; + cbm_private_file_lock_status_t next_writer_status = + started ? cbm_lock_registry_acquire(fixture.registry, "retry-writer-turn", + CBM_PRIVATE_FILE_LOCK_EX, cbm_now_ms() + 50, NULL, + &next_writer) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t next_writer_release = + next_writer ? cbm_lock_lease_release(&next_writer) : CBM_PRIVATE_FILE_LOCK_IO; + + cbm_private_file_lock_status_t retry = + writer ? cbm_lock_lease_release(&writer) : CBM_PRIVATE_FILE_LOCK_IO; + if (writer) { + (void)cbm_lock_lease_release(&writer); + } + size_t active_after_retry = cbm_lock_registry_active_lease_count_for_test(fixture.registry); + cbm_lock_lease_t *after = NULL; + cbm_private_file_lock_status_t after_status = + started ? cbm_lock_registry_acquire( + fixture.registry, "retry-writer-turn", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t after_release = + after ? cbm_lock_lease_release(&after) : CBM_PRIVATE_FILE_LOCK_IO; + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(acquired, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(fault_set); + ASSERT_EQ(first_release, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(retained); + ASSERT_TRUE(rw_closed); + ASSERT_TRUE(turn_retained); + ASSERT_EQ(active_after_failure, 0); + ASSERT_EQ(pending_after_failure, 1); + ASSERT_TRUE(names_ok); + ASSERT_EQ(rw_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(turn_status, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_EQ(next_writer_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(next_writer_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(retry, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(writer); + ASSERT_EQ(active_after_retry, 0); + ASSERT_EQ(after_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(after_release, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} + +TEST(lock_registry_free_refuses_active_lease) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX native-directory registry lifecycle runs on POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_lock_lease_t *lease = NULL; + cbm_private_file_lock_status_t acquired = + started + ? cbm_lock_registry_acquire(fixture.registry, "free-active", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &lease) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t busy = + started ? cbm_lock_registry_free(&fixture.registry) : CBM_PRIVATE_FILE_LOCK_IO; + bool registry_preserved = fixture.registry != NULL; + cbm_private_file_lock_status_t released = + lease ? cbm_lock_lease_release(&lease) : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t freed = + registry_preserved ? cbm_lock_registry_free(&fixture.registry) : CBM_PRIVATE_FILE_LOCK_IO; + bool registry_cleared = fixture.registry == NULL; + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(acquired, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(busy, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_TRUE(registry_preserved); + ASSERT_EQ(released, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(freed, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(registry_cleared); + PASS(); +#endif +} + +TEST(lock_registry_free_retires_identity_and_rejects_stale_pointer) { +#ifdef _WIN32 + SKIP_PLATFORM("native registry fixture retirement runs on POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_lock_registry_t *stale = fixture.registry; + cbm_private_file_lock_status_t first_free = + started ? cbm_lock_registry_free(&fixture.registry) : CBM_PRIVATE_FILE_LOCK_IO; + bool caller_cleared = fixture.registry == NULL; + bool retired = cbm_lock_registry_is_retired_for_test(stale); + + cbm_lock_registry_t *fresh = started ? cbm_lock_registry_new(fixture.directory) : NULL; + bool identity_not_reused = fresh && fresh != stale; + cbm_lock_lease_t *stale_lease = NULL; + cbm_private_file_lock_status_t stale_acquire = CBM_PRIVATE_FILE_LOCK_IO; + cbm_lock_cancel_token_t stale_cancel_token; + atomic_init(&stale_cancel_token, false); + cbm_private_file_lock_status_t stale_cancel = CBM_PRIVATE_FILE_LOCK_OK; + cbm_private_file_lock_status_t stale_free = CBM_PRIVATE_FILE_LOCK_IO; + bool stale_free_preserved = true; + if (retired && identity_not_reused) { + stale_acquire = cbm_lock_registry_acquire( + stale, "retired-registry", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &stale_lease); + stale_cancel = cbm_lock_registry_request_cancel(stale, &stale_cancel_token); + cbm_lock_registry_t *stale_copy = stale; + stale_free = cbm_lock_registry_free(&stale_copy); + stale_free_preserved = stale_copy == stale; + } + + cbm_lock_lease_t *fresh_lease = NULL; + cbm_private_file_lock_status_t fresh_acquire = + fresh ? cbm_lock_registry_acquire(fresh, "retired-registry", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, + &fresh_lease) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t fresh_release = + fresh_lease ? cbm_lock_lease_release(&fresh_lease) : CBM_PRIVATE_FILE_LOCK_IO; + fixture.registry = fresh; + cbm_private_file_lock_status_t fresh_free = + fresh ? cbm_lock_registry_free(&fixture.registry) : CBM_PRIVATE_FILE_LOCK_IO; + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(first_free, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(caller_cleared); + ASSERT_TRUE(retired); + ASSERT_TRUE(identity_not_reused); + ASSERT_EQ(stale_acquire, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_NULL(stale_lease); + ASSERT_EQ(stale_cancel, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(atomic_load_explicit(&stale_cancel_token, memory_order_acquire)); + ASSERT_EQ(stale_free, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(stale_free_preserved); + ASSERT_EQ(fresh_acquire, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(fresh_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(fresh_free, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} + +TEST(lock_registry_large_queue_parks_non_head_waiters) { +#ifdef _WIN32 + SKIP_PLATFORM("native registry parking fixture runs on POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_lock_lease_t *holder = NULL; + cbm_private_file_lock_status_t holder_status = + started ? cbm_lock_registry_acquire( + fixture.registry, "large-queue-parking", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &holder) + : CBM_PRIVATE_FILE_LOCK_IO; + + lock_registry_waiter_t waiters[LOCK_REGISTRY_PARKING_WAITERS]; + cbm_thread_t threads[LOCK_REGISTRY_PARKING_WAITERS]; + size_t thread_count = 0; + memset(waiters, 0, sizeof(waiters)); + for (; holder_status == CBM_PRIVATE_FILE_LOCK_OK && + thread_count < LOCK_REGISTRY_PARKING_WAITERS; + thread_count++) { + waiters[thread_count] = (lock_registry_waiter_t){ + .registry = fixture.registry, + .resource_key = "large-queue-parking", + .mode = CBM_PRIVATE_FILE_LOCK_EX, + .status = CBM_PRIVATE_FILE_LOCK_IO, + }; + atomic_init(&waiters[thread_count].cancel_token, false); + atomic_init(&waiters[thread_count].finished, false); + if (cbm_thread_create(&threads[thread_count], 0, lock_registry_waiter_run, + &waiters[thread_count]) != 0) { + break; + } + } + + bool all_queued = false; + bool tails_parked = false; + uint64_t observe_deadline = cbm_now_ms() + 500; + while (thread_count == LOCK_REGISTRY_PARKING_WAITERS && + cbm_now_ms() < observe_deadline) { + all_queued = cbm_lock_registry_waiter_count(fixture.registry) == + LOCK_REGISTRY_PARKING_WAITERS; + tails_parked = cbm_lock_registry_condition_waiter_count_for_test(fixture.registry) >= + LOCK_REGISTRY_PARKING_WAITERS - 1; + if (all_queued && tails_parked) { + break; + } + lock_registry_test_yield(); + } + size_t attempting = cbm_lock_registry_attempting_waiter_count_for_test(fixture.registry); + uint64_t waits_before = + cbm_lock_registry_condition_wait_call_count_for_test(fixture.registry); + cbm_usleep(25000); + uint64_t waits_after = + cbm_lock_registry_condition_wait_call_count_for_test(fixture.registry); + + for (size_t index = 0; index < thread_count; index++) { + (void)cbm_lock_registry_request_cancel(fixture.registry, &waiters[index].cancel_token); + } + bool all_cancelled = true; + for (size_t index = 0; index < thread_count; index++) { + all_cancelled = cbm_thread_join(&threads[index]) == 0 && + waiters[index].status == CBM_PRIVATE_FILE_LOCK_BUSY && all_cancelled; + if (waiters[index].lease) { + (void)cbm_lock_lease_release(&waiters[index].lease); + } + } + cbm_private_file_lock_status_t holder_release = + holder ? cbm_lock_lease_release(&holder) : CBM_PRIVATE_FILE_LOCK_IO; + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(holder_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(thread_count, LOCK_REGISTRY_PARKING_WAITERS); + ASSERT_TRUE(all_queued); + ASSERT_TRUE(tails_parked); + ASSERT_EQ(attempting, 1); + ASSERT_GTE(waits_before, LOCK_REGISTRY_PARKING_WAITERS - 1); + ASSERT_TRUE(waits_after - waits_before < 128); + ASSERT_TRUE(all_cancelled); + ASSERT_EQ(holder_release, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} + +TEST(lock_registry_cancel_request_wakes_parked_tail) { +#ifdef _WIN32 + SKIP_PLATFORM("native registry cancellation fixture runs on POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_lock_lease_t *holder = NULL; + cbm_private_file_lock_status_t holder_status = + started ? cbm_lock_registry_acquire( + fixture.registry, "cancel-parked-tail", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &holder) + : CBM_PRIVATE_FILE_LOCK_IO; + + lock_registry_waiter_t head = {.registry = fixture.registry, + .resource_key = "cancel-parked-tail", + .mode = CBM_PRIVATE_FILE_LOCK_EX, + .status = CBM_PRIVATE_FILE_LOCK_IO}; + atomic_init(&head.cancel_token, false); + atomic_init(&head.finished, false); + cbm_thread_t head_thread; + bool head_started = holder_status == CBM_PRIVATE_FILE_LOCK_OK && + cbm_thread_create(&head_thread, 0, lock_registry_waiter_run, &head) == 0; + bool head_attempting = false; + uint64_t head_deadline = cbm_now_ms() + 500; + while (head_started && cbm_now_ms() < head_deadline) { + head_attempting = cbm_lock_registry_waiter_count(fixture.registry) == 1 && + cbm_lock_registry_attempting_waiter_count_for_test(fixture.registry) == 1; + if (head_attempting) { + break; + } + lock_registry_test_yield(); + } + + lock_registry_waiter_t tail = {.registry = fixture.registry, + .resource_key = "cancel-parked-tail", + .mode = CBM_PRIVATE_FILE_LOCK_EX, + .status = CBM_PRIVATE_FILE_LOCK_IO}; + atomic_init(&tail.cancel_token, false); + atomic_init(&tail.finished, false); + cbm_thread_t tail_thread; + bool tail_started = head_attempting && + cbm_thread_create(&tail_thread, 0, lock_registry_waiter_run, &tail) == 0; + bool tail_parked = false; + uint64_t tail_deadline = cbm_now_ms() + 500; + while (tail_started && cbm_now_ms() < tail_deadline) { + tail_parked = cbm_lock_registry_waiter_count(fixture.registry) == 2 && + cbm_lock_registry_attempting_waiter_count_for_test(fixture.registry) == 1 && + cbm_lock_registry_condition_waiter_count_for_test(fixture.registry) >= 1; + if (tail_parked) { + break; + } + lock_registry_test_yield(); + } + + cbm_private_file_lock_status_t cancel_status = + tail_parked ? cbm_lock_registry_request_cancel(fixture.registry, &tail.cancel_token) + : CBM_PRIVATE_FILE_LOCK_IO; + bool tail_woke = false; + uint64_t wake_deadline = cbm_now_ms() + 500; + while (tail_started && cbm_now_ms() < wake_deadline) { + tail_woke = atomic_load_explicit(&tail.finished, memory_order_acquire); + if (tail_woke) { + break; + } + lock_registry_test_yield(); + } + bool head_still_waiting = head_started && + !atomic_load_explicit(&head.finished, memory_order_acquire); + + if (head_started) { + (void)cbm_lock_registry_request_cancel(fixture.registry, &head.cancel_token); + } + cbm_private_file_lock_status_t holder_release = + holder ? cbm_lock_lease_release(&holder) : CBM_PRIVATE_FILE_LOCK_IO; + if (tail_started) { + (void)cbm_thread_join(&tail_thread); + } + if (head_started) { + (void)cbm_thread_join(&head_thread); + } + size_t remaining_waiters = + started ? cbm_lock_registry_waiter_count(fixture.registry) : (size_t)-1; + if (tail.lease) { + (void)cbm_lock_lease_release(&tail.lease); + } + if (head.lease) { + (void)cbm_lock_lease_release(&head.lease); + } + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(holder_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(head_started); + ASSERT_TRUE(head_attempting); + ASSERT_TRUE(tail_started); + ASSERT_TRUE(tail_parked); + ASSERT_EQ(cancel_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(tail_woke); + ASSERT_TRUE(head_still_waiting); + ASSERT_EQ(tail.status, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_NULL(tail.lease); + ASSERT_EQ(head.status, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_NULL(head.lease); + ASSERT_EQ(holder_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(remaining_waiters, 0); + PASS(); +#endif +} + +typedef struct { + cbm_lock_registry_t *registry; + cbm_lock_cancel_token_t cancel_token; + atomic_bool ready; + atomic_bool go; + atomic_bool finished; + uint64_t deadline_ms; + uint64_t returned_ms; + cbm_private_file_lock_status_t status; + cbm_lock_lease_t *lease; +} lock_registry_deadline_waiter_t; + +static void *lock_registry_deadline_waiter_run(void *opaque) { + lock_registry_deadline_waiter_t *waiter = opaque; + atomic_store_explicit(&waiter->ready, true, memory_order_release); + while (!atomic_load_explicit(&waiter->go, memory_order_acquire)) { + lock_registry_test_yield(); + } + waiter->status = cbm_lock_registry_acquire( + waiter->registry, "absolute-deadline", CBM_PRIVATE_FILE_LOCK_EX, waiter->deadline_ms, + &waiter->cancel_token, &waiter->lease); + waiter->returned_ms = cbm_now_ms(); + atomic_store_explicit(&waiter->finished, true, memory_order_release); + return NULL; +} + +TEST(lock_registry_absolute_deadline_survives_repeated_wakes) { +#ifdef _WIN32 + SKIP_PLATFORM("native registry deadline fixture runs on POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_lock_lease_t *holder = NULL; + cbm_private_file_lock_status_t holder_status = + started ? cbm_lock_registry_acquire( + fixture.registry, "absolute-deadline", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &holder) + : CBM_PRIVATE_FILE_LOCK_IO; + + lock_registry_waiter_t head = {.registry = fixture.registry, + .resource_key = "absolute-deadline", + .mode = CBM_PRIVATE_FILE_LOCK_EX, + .status = CBM_PRIVATE_FILE_LOCK_IO}; + atomic_init(&head.cancel_token, false); + atomic_init(&head.finished, false); + cbm_thread_t head_thread; + bool head_started = holder_status == CBM_PRIVATE_FILE_LOCK_OK && + cbm_thread_create(&head_thread, 0, lock_registry_waiter_run, &head) == 0; + bool head_attempting = false; + uint64_t head_deadline = cbm_now_ms() + 500; + while (head_started && cbm_now_ms() < head_deadline) { + head_attempting = cbm_lock_registry_waiter_count(fixture.registry) == 1 && + cbm_lock_registry_attempting_waiter_count_for_test(fixture.registry) == 1; + if (head_attempting) { + break; + } + lock_registry_test_yield(); + } + + lock_registry_deadline_waiter_t tail = {.registry = fixture.registry, + .status = CBM_PRIVATE_FILE_LOCK_IO}; + atomic_init(&tail.cancel_token, false); + atomic_init(&tail.ready, false); + atomic_init(&tail.go, false); + atomic_init(&tail.finished, false); + cbm_thread_t tail_thread; + bool tail_started = head_attempting && + cbm_thread_create(&tail_thread, 0, lock_registry_deadline_waiter_run, + &tail) == 0; + uint64_t ready_deadline = cbm_now_ms() + 500; + while (tail_started && !atomic_load_explicit(&tail.ready, memory_order_acquire) && + cbm_now_ms() < ready_deadline) { + lock_registry_test_yield(); + } + bool tail_ready = tail_started && atomic_load_explicit(&tail.ready, memory_order_acquire); + uint64_t deadline_start = cbm_now_ms(); + tail.deadline_ms = deadline_start + 200; + atomic_store_explicit(&tail.go, true, memory_order_release); + + bool tail_queued = false; + uint64_t queue_deadline = deadline_start + 100; + while (tail_ready && cbm_now_ms() < queue_deadline) { + tail_queued = cbm_lock_registry_waiter_count(fixture.registry) == 2 && + cbm_lock_registry_attempting_waiter_count_for_test(fixture.registry) == 1; + if (tail_queued) { + break; + } + lock_registry_test_yield(); + } + + cbm_lock_cancel_token_t unrelated_token; + atomic_init(&unrelated_token, false); + bool broadcasts_ok = true; + uint64_t observe_deadline = deadline_start + 600; + while (tail_ready && !atomic_load_explicit(&tail.finished, memory_order_acquire) && + cbm_now_ms() < observe_deadline) { + broadcasts_ok = cbm_lock_registry_request_cancel(fixture.registry, &unrelated_token) == + CBM_PRIVATE_FILE_LOCK_OK && + broadcasts_ok; + cbm_usleep(5000); + } + bool returned_at_deadline = + tail_ready && atomic_load_explicit(&tail.finished, memory_order_acquire); + uint64_t elapsed_ms = returned_at_deadline ? tail.returned_ms - deadline_start : UINT64_MAX; + + if (!returned_at_deadline && tail_started) { + (void)cbm_lock_registry_request_cancel(fixture.registry, &tail.cancel_token); + } + if (head_started) { + (void)cbm_lock_registry_request_cancel(fixture.registry, &head.cancel_token); + } + cbm_private_file_lock_status_t holder_release = + holder ? cbm_lock_lease_release(&holder) : CBM_PRIVATE_FILE_LOCK_IO; + if (tail_started) { + (void)cbm_thread_join(&tail_thread); + } + if (head_started) { + (void)cbm_thread_join(&head_thread); + } + if (tail.lease) { + (void)cbm_lock_lease_release(&tail.lease); + } + if (head.lease) { + (void)cbm_lock_lease_release(&head.lease); + } + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(holder_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(head_started); + ASSERT_TRUE(head_attempting); + ASSERT_TRUE(tail_started); + ASSERT_TRUE(tail_ready); + ASSERT_TRUE(tail_queued); + ASSERT_TRUE(broadcasts_ok); + ASSERT_TRUE(returned_at_deadline); + ASSERT_GTE(elapsed_ms, 150); + ASSERT_TRUE(elapsed_ms < 350); + ASSERT_EQ(tail.status, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_NULL(tail.lease); + ASSERT_EQ(head.status, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_NULL(head.lease); + ASSERT_EQ(holder_release, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} + +typedef struct { + cbm_lock_registry_t *registry; + unsigned int id; + atomic_int *ready; + atomic_bool *go; + atomic_int *readers; + atomic_int *writers; + atomic_int *violations; +} lock_registry_stress_worker_t; + +static void *lock_registry_stress_run(void *opaque) { + lock_registry_stress_worker_t *worker = opaque; + (void)atomic_fetch_add_explicit(worker->ready, 1, memory_order_acq_rel); + while (!atomic_load_explicit(worker->go, memory_order_acquire)) { + lock_registry_test_yield(); + } + for (unsigned int iteration = 0; iteration < LOCK_REGISTRY_STRESS_ITERATIONS; iteration++) { + cbm_private_file_lock_mode_t mode = (iteration + worker->id) % 5U == 0U + ? CBM_PRIVATE_FILE_LOCK_EX + : CBM_PRIVATE_FILE_LOCK_SH; + cbm_lock_lease_t *lease = NULL; + cbm_private_file_lock_status_t status = cbm_lock_registry_acquire( + worker->registry, "stress", mode, cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, + &lease); + if (status != CBM_PRIVATE_FILE_LOCK_OK || !lease) { + (void)atomic_fetch_add_explicit(worker->violations, 1, memory_order_relaxed); + return NULL; + } + if (mode == CBM_PRIVATE_FILE_LOCK_EX) { + if (atomic_fetch_add_explicit(worker->writers, 1, memory_order_acq_rel) != 0 || + atomic_load_explicit(worker->readers, memory_order_acquire) != 0) { + (void)atomic_fetch_add_explicit(worker->violations, 1, memory_order_relaxed); + } + lock_registry_test_yield(); + if (atomic_load_explicit(worker->readers, memory_order_acquire) != 0) { + (void)atomic_fetch_add_explicit(worker->violations, 1, memory_order_relaxed); + } + (void)atomic_fetch_sub_explicit(worker->writers, 1, memory_order_acq_rel); + } else { + if (atomic_load_explicit(worker->writers, memory_order_acquire) != 0) { + (void)atomic_fetch_add_explicit(worker->violations, 1, memory_order_relaxed); + } + (void)atomic_fetch_add_explicit(worker->readers, 1, memory_order_acq_rel); + if (atomic_load_explicit(worker->writers, memory_order_acquire) != 0) { + (void)atomic_fetch_add_explicit(worker->violations, 1, memory_order_relaxed); + } + lock_registry_test_yield(); + (void)atomic_fetch_sub_explicit(worker->readers, 1, memory_order_acq_rel); + } + if (cbm_lock_lease_release(&lease) != CBM_PRIVATE_FILE_LOCK_OK) { + (void)atomic_fetch_add_explicit(worker->violations, 1, memory_order_relaxed); + return NULL; + } + } + return NULL; +} + +TEST(lock_registry_concurrent_shared_exclusive_stress) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX native-directory registry RED runs on POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_thread_t threads[LOCK_REGISTRY_STRESS_THREADS]; + lock_registry_stress_worker_t workers[LOCK_REGISTRY_STRESS_THREADS]; + atomic_int ready; + atomic_bool go; + atomic_int readers; + atomic_int writers; + atomic_int violations; + atomic_init(&ready, 0); + atomic_init(&go, false); + atomic_init(&readers, 0); + atomic_init(&writers, 0); + atomic_init(&violations, 0); + size_t created = 0; + for (; started && created < LOCK_REGISTRY_STRESS_THREADS; created++) { + workers[created] = (lock_registry_stress_worker_t){ + .registry = fixture.registry, + .id = (unsigned int)created, + .ready = &ready, + .go = &go, + .readers = &readers, + .writers = &writers, + .violations = &violations, + }; + if (cbm_thread_create(&threads[created], 0, lock_registry_stress_run, &workers[created]) != + 0) { + break; + } + } + uint64_t ready_deadline = cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS; + while (created == LOCK_REGISTRY_STRESS_THREADS && + atomic_load_explicit(&ready, memory_order_acquire) != LOCK_REGISTRY_STRESS_THREADS && + cbm_now_ms() < ready_deadline) { + lock_registry_test_yield(); + } + bool all_ready = + atomic_load_explicit(&ready, memory_order_acquire) == LOCK_REGISTRY_STRESS_THREADS; + atomic_store_explicit(&go, true, memory_order_release); + for (size_t index = 0; index < created; index++) { + (void)cbm_thread_join(&threads[index]); + } + int violation_count = atomic_load_explicit(&violations, memory_order_acquire); + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(created, LOCK_REGISTRY_STRESS_THREADS); + ASSERT_TRUE(all_ready); + ASSERT_EQ(violation_count, 0); + PASS(); +#endif +} + +#ifndef _WIN32 +static bool lock_registry_pipe_write(int fd, char value) { + ssize_t written; + do { + written = write(fd, &value, 1); + } while (written < 0 && errno == EINTR); + return written == 1; +} + +static bool lock_registry_pipe_read(int fd, uint32_t timeout_ms, char *value_out) { + struct pollfd descriptor = {.fd = fd, .events = POLLIN, .revents = 0}; + int ready; + do { + ready = poll(&descriptor, 1, (int)timeout_ms); + } while (ready < 0 && errno == EINTR); + if (ready != 1 || (descriptor.revents & POLLIN) == 0) { + return false; + } + ssize_t received; + do { + received = read(fd, value_out, 1); + } while (received < 0 && errno == EINTR); + return received == 1; +} + +static bool lock_registry_child_wait(pid_t child, int *status_out) { + uint64_t deadline = cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS; + while (cbm_now_ms() < deadline) { + pid_t result = waitpid(child, status_out, WNOHANG); + if (result == child) { + return true; + } + if (result < 0 && errno != EINTR) { + return false; + } + lock_registry_test_yield(); + } + (void)kill(child, SIGKILL); + return waitpid(child, status_out, 0) == child && false; +} + +static int lock_registry_writer_child(const char *root, int started_fd, int acquired_fd, + int release_fd) { + cbm_private_lock_directory_t *directory = lock_registry_test_directory_open(root); + cbm_lock_registry_t *registry = cbm_lock_registry_new(directory); + bool started = registry && lock_registry_pipe_write(started_fd, 'S'); + cbm_lock_lease_t *lease = NULL; + cbm_private_file_lock_status_t status = + started ? cbm_lock_registry_acquire(registry, "writer-preference", CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, + &lease) + : CBM_PRIVATE_FILE_LOCK_IO; + bool reported = + lock_registry_pipe_write(acquired_fd, status == CBM_PRIVATE_FILE_LOCK_OK ? 'W' : 'E'); + char command = 0; + bool released = status == CBM_PRIVATE_FILE_LOCK_OK && + lock_registry_pipe_read(release_fd, LOCK_REGISTRY_TEST_TIMEOUT_MS, &command) && + command == 'X' && cbm_lock_lease_release(&lease) == CBM_PRIVATE_FILE_LOCK_OK; + cbm_private_file_lock_status_t freed = cbm_lock_registry_free(®istry); + cbm_private_lock_directory_close(directory); + return started && reported && released && freed == CBM_PRIVATE_FILE_LOCK_OK ? 0 : 1; +} + +typedef struct { + int fd; + bool reported; +} lock_registry_stage_pipe_t; + +static void lock_registry_stage_pipe_report(void *opaque, cbm_private_file_lock_mode_t mode, + cbm_lock_registry_stage_t stage) { + lock_registry_stage_pipe_t *stage_pipe = opaque; + if (!stage_pipe->reported && mode == CBM_PRIVATE_FILE_LOCK_SH && + stage == CBM_LOCK_REGISTRY_STAGE_TURN_BUSY) { + stage_pipe->reported = lock_registry_pipe_write(stage_pipe->fd, 'T'); + } +} + +static int lock_registry_reader_child(const char *root, int acquired_fd, int attempted_fd) { + cbm_private_lock_directory_t *directory = lock_registry_test_directory_open(root); + cbm_lock_registry_t *registry = cbm_lock_registry_new(directory); + lock_registry_stage_pipe_t stage_pipe = {.fd = attempted_fd}; + bool hook_set = registry && cbm_lock_registry_set_stage_hook_for_test( + registry, lock_registry_stage_pipe_report, &stage_pipe); + cbm_lock_lease_t *lease = NULL; + cbm_private_file_lock_status_t status = + hook_set + ? cbm_lock_registry_acquire(registry, "writer-preference", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &lease) + : CBM_PRIVATE_FILE_LOCK_IO; + bool reported = + lock_registry_pipe_write(acquired_fd, status == CBM_PRIVATE_FILE_LOCK_OK ? 'R' : 'E'); + bool released = status == CBM_PRIVATE_FILE_LOCK_OK && + cbm_lock_lease_release(&lease) == CBM_PRIVATE_FILE_LOCK_OK; + cbm_private_file_lock_status_t freed = cbm_lock_registry_free(®istry); + cbm_private_lock_directory_close(directory); + return hook_set && stage_pipe.reported && reported && released && + freed == CBM_PRIVATE_FILE_LOCK_OK + ? 0 + : 1; +} + +static int lock_registry_inherited_child(cbm_lock_registry_t *registry, + cbm_lock_lease_t *inherited_lease, int report_fd) { + cbm_lock_lease_t *new_lease = NULL; + cbm_private_file_lock_status_t acquire_status = cbm_lock_registry_acquire( + registry, "fork-registry", CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &new_lease); + cbm_private_file_lock_status_t release_status = cbm_lock_lease_release(&inherited_lease); + cbm_private_file_lock_status_t free_status = cbm_lock_registry_free(®istry); + bool reported = lock_registry_pipe_write(report_fd, (char)acquire_status) && + lock_registry_pipe_write(report_fd, (char)release_status) && + lock_registry_pipe_write(report_fd, (char)free_status); + if (new_lease) { + (void)cbm_lock_lease_release(&new_lease); + } + return reported ? 0 : 1; +} +#endif + +TEST(lock_registry_cross_process_writer_beats_late_reader) { +#ifdef _WIN32 + SKIP_PLATFORM("fork/pipe writer-preference proof applies only to POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_lock_lease_t *initial_reader = NULL; + cbm_private_file_lock_status_t initial_status = + started ? cbm_lock_registry_acquire( + fixture.registry, "writer-preference", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &initial_reader) + : CBM_PRIVATE_FILE_LOCK_IO; + int writer_started[2] = {-1, -1}; + int writer_acquired[2] = {-1, -1}; + int writer_release[2] = {-1, -1}; + bool writer_pipes = initial_status == CBM_PRIVATE_FILE_LOCK_OK && pipe(writer_started) == 0 && + pipe(writer_acquired) == 0 && pipe(writer_release) == 0; + pid_t writer = writer_pipes ? fork() : -1; + if (writer == 0) { + (void)close(writer_started[0]); + (void)close(writer_acquired[0]); + (void)close(writer_release[1]); + int child_result = lock_registry_writer_child(fixture.root, writer_started[1], + writer_acquired[1], writer_release[0]); + _exit(child_result); + } + + char report = 0; + bool writer_announced = false; + bool writer_has_turn = false; + char turn_name[CBM_LOCK_REGISTRY_NAME_CAP]; + char rw_name[CBM_LOCK_REGISTRY_NAME_CAP]; + bool names_ok = cbm_lock_registry_resource_names("writer-preference", turn_name, rw_name); + if (writer > 0) { + (void)close(writer_started[1]); + (void)close(writer_acquired[1]); + (void)close(writer_release[0]); + writer_announced = + lock_registry_pipe_read(writer_started[0], LOCK_REGISTRY_TEST_TIMEOUT_MS, &report) && + report == 'S'; + uint64_t deadline = cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS; + while (writer_announced && names_ok && cbm_now_ms() < deadline) { + cbm_private_file_lock_t *probe = NULL; + cbm_private_file_lock_status_t probe_status = cbm_private_file_lock_try_acquire( + fixture.directory, turn_name, CBM_PRIVATE_FILE_LOCK_EX, &probe); + if (probe_status == CBM_PRIVATE_FILE_LOCK_BUSY) { + writer_has_turn = true; + break; + } + if (probe) { + (void)cbm_private_file_lock_release(&probe); + } + if (probe_status != CBM_PRIVATE_FILE_LOCK_OK) { + break; + } + lock_registry_test_yield(); + } + } + + int late_attempted[2] = {-1, -1}; + int late_acquired[2] = {-1, -1}; + bool late_pipe = writer_has_turn && pipe(late_attempted) == 0 && pipe(late_acquired) == 0; + pid_t late_reader = late_pipe ? fork() : -1; + if (late_reader == 0) { + (void)close(late_attempted[0]); + (void)close(late_acquired[0]); + (void)close(writer_started[0]); + (void)close(writer_acquired[0]); + (void)close(writer_release[1]); + _exit(lock_registry_reader_child(fixture.root, late_acquired[1], late_attempted[1])); + } + if (late_reader > 0) { + (void)close(late_attempted[1]); + (void)close(late_acquired[1]); + } + + char attempted_report = 0; + bool late_reached_turn = + late_reader > 0 && + lock_registry_pipe_read(late_attempted[0], LOCK_REGISTRY_TEST_TIMEOUT_MS, + &attempted_report) && + attempted_report == 'T'; + + /* All forks are complete before introducing a second parent thread. */ + lock_registry_waiter_t local_reader = { + .registry = fixture.registry, + .resource_key = "writer-preference", + .mode = CBM_PRIVATE_FILE_LOCK_SH, + .status = CBM_PRIVATE_FILE_LOCK_IO, + }; + atomic_init(&local_reader.cancel_token, false); + atomic_init(&local_reader.finished, false); + cbm_thread_t local_reader_thread; + bool local_reader_started = + late_reached_turn && + cbm_thread_create(&local_reader_thread, 0, lock_registry_waiter_run, &local_reader) == 0; + bool local_reader_queued = false; + uint64_t local_deadline = cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS; + while (local_reader_started && cbm_now_ms() < local_deadline) { + if (cbm_lock_registry_waiter_count(fixture.registry) == 1) { + local_reader_queued = true; + break; + } + if (atomic_load_explicit(&local_reader.finished, memory_order_acquire)) { + break; + } + lock_registry_test_yield(); + } + + cbm_private_file_lock_status_t initial_release = + initial_reader ? cbm_lock_lease_release(&initial_reader) : CBM_PRIVATE_FILE_LOCK_IO; + char writer_report = 0; + bool writer_won = writer > 0 && + lock_registry_pipe_read(writer_acquired[0], LOCK_REGISTRY_TEST_TIMEOUT_MS, + &writer_report) && + writer_report == 'W'; + struct pollfd late_probe = { + .fd = late_reader > 0 ? late_acquired[0] : -1, + .events = POLLIN, + .revents = 0, + }; + int late_ready_while_writer = late_reader > 0 ? poll(&late_probe, 1, 0) : -1; + bool local_ready_while_writer = + atomic_load_explicit(&local_reader.finished, memory_order_acquire); + bool writer_commanded = writer_won && lock_registry_pipe_write(writer_release[1], 'X'); + int writer_status = -1; + bool writer_exited = writer > 0 && lock_registry_child_wait(writer, &writer_status); + if (local_reader_started) { + (void)cbm_thread_join(&local_reader_thread); + } + cbm_private_file_lock_status_t local_reader_release = + local_reader.lease ? cbm_lock_lease_release(&local_reader.lease) : CBM_PRIVATE_FILE_LOCK_IO; + char late_report = 0; + bool late_followed = + late_reader > 0 && + lock_registry_pipe_read(late_acquired[0], LOCK_REGISTRY_TEST_TIMEOUT_MS, &late_report) && + late_report == 'R'; + int late_status = -1; + bool late_exited = late_reader > 0 && lock_registry_child_wait(late_reader, &late_status); + if (writer > 0) { + (void)close(writer_started[0]); + (void)close(writer_acquired[0]); + (void)close(writer_release[1]); + } + if (late_reader > 0) { + (void)close(late_attempted[0]); + (void)close(late_acquired[0]); + } + if (initial_reader) { + (void)cbm_lock_lease_release(&initial_reader); + } + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(initial_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(writer_pipes); + ASSERT_GT(writer, 0); + ASSERT_TRUE(writer_announced); + ASSERT_TRUE(names_ok); + ASSERT_TRUE(writer_has_turn); + ASSERT_TRUE(local_reader_started); + ASSERT_TRUE(local_reader_queued); + ASSERT_TRUE(late_pipe); + ASSERT_GT(late_reader, 0); + ASSERT_TRUE(late_reached_turn); + ASSERT_EQ(initial_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(writer_won); + ASSERT_EQ(late_ready_while_writer, 0); + ASSERT_FALSE(local_ready_while_writer); + ASSERT_TRUE(writer_commanded); + ASSERT_TRUE(writer_exited); + ASSERT_TRUE(WIFEXITED(writer_status)); + ASSERT_EQ(WEXITSTATUS(writer_status), 0); + ASSERT_EQ(local_reader.status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(local_reader_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(late_followed); + ASSERT_TRUE(late_exited); + ASSERT_TRUE(WIFEXITED(late_status)); + ASSERT_EQ(WEXITSTATUS(late_status), 0); + PASS(); +#endif +} + +TEST(lock_registry_fork_child_rejects_inherited_registry) { +#ifdef _WIN32 + SKIP_PLATFORM("fork inheritance applies only to POSIX"); +#else + lock_registry_fixture_t fixture; + bool started = lock_registry_fixture_start(&fixture); + cbm_lock_lease_t *reader = NULL; + cbm_private_file_lock_status_t reader_status = + started + ? cbm_lock_registry_acquire(fixture.registry, "fork-registry", CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &reader) + : CBM_PRIVATE_FILE_LOCK_IO; + int reports[2] = {-1, -1}; + bool pipe_ok = reader_status == CBM_PRIVATE_FILE_LOCK_OK && pipe(reports) == 0; + pid_t child = pipe_ok ? fork() : -1; + if (child == 0) { + (void)close(reports[0]); + int result = lock_registry_inherited_child(fixture.registry, reader, reports[1]); + _exit(result); + } + if (child > 0) { + (void)close(reports[1]); + } + + char acquire_report = 0; + char release_report = 0; + char free_report = 0; + bool reports_ok = + child > 0 && + lock_registry_pipe_read(reports[0], LOCK_REGISTRY_TEST_TIMEOUT_MS, &acquire_report) && + lock_registry_pipe_read(reports[0], LOCK_REGISTRY_TEST_TIMEOUT_MS, &release_report) && + lock_registry_pipe_read(reports[0], LOCK_REGISTRY_TEST_TIMEOUT_MS, &free_report); + int child_status = -1; + bool child_exited = child > 0 && lock_registry_child_wait(child, &child_status); + if (child > 0) { + (void)close(reports[0]); + } + + char turn_name[CBM_LOCK_REGISTRY_NAME_CAP]; + char rw_name[CBM_LOCK_REGISTRY_NAME_CAP]; + bool names_ok = cbm_lock_registry_resource_names("fork-registry", turn_name, rw_name); + cbm_private_file_lock_t *probe = NULL; + cbm_private_file_lock_status_t parent_still_locked = + names_ok ? cbm_private_file_lock_try_acquire(fixture.directory, rw_name, + CBM_PRIVATE_FILE_LOCK_EX, &probe) + : CBM_PRIVATE_FILE_LOCK_IO; + if (probe) { + (void)cbm_private_file_lock_release(&probe); + } + cbm_private_file_lock_status_t reader_release = + reader ? cbm_lock_lease_release(&reader) : CBM_PRIVATE_FILE_LOCK_IO; + cbm_lock_lease_t *writer = NULL; + cbm_private_file_lock_status_t writer_after = + started + ? cbm_lock_registry_acquire(fixture.registry, "fork-registry", CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &writer) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t writer_release = + writer ? cbm_lock_lease_release(&writer) : CBM_PRIVATE_FILE_LOCK_IO; + lock_registry_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(reader_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(pipe_ok); + ASSERT_GT(child, 0); + ASSERT_TRUE(reports_ok); + ASSERT_EQ((unsigned char)acquire_report, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_EQ((unsigned char)release_report, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ((unsigned char)free_report, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(child_exited); + ASSERT_TRUE(WIFEXITED(child_status)); + ASSERT_EQ(WEXITSTATUS(child_status), 0); + ASSERT_TRUE(names_ok); + ASSERT_EQ(parent_still_locked, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_EQ(reader_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(writer_after, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(writer_release, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} + +SUITE(lock_registry) { + RUN_TEST(lock_registry_cancelled_wait_rolls_back_and_does_not_barge); + RUN_TEST(lock_registry_failed_rollback_returns_cleanup_only_lease); + RUN_TEST(lock_registry_abort_lock_failure_returns_waiter_cleanup_lease); + RUN_TEST(lock_registry_abort_remove_failure_returns_waiter_cleanup_lease); + RUN_TEST(lock_registry_never_upgrades_shared_lease_in_place); + RUN_TEST(lock_registry_reader_close_failure_retains_lease_and_accounting); + RUN_TEST(lock_registry_writer_partial_release_retries_rw_then_turn); + RUN_TEST(lock_registry_terminal_close_error_finishes_pending_accounting); + RUN_TEST(lock_registry_free_refuses_active_lease); + RUN_TEST(lock_registry_free_retires_identity_and_rejects_stale_pointer); + RUN_TEST(lock_registry_large_queue_parks_non_head_waiters); + RUN_TEST(lock_registry_cancel_request_wakes_parked_tail); + RUN_TEST(lock_registry_absolute_deadline_survives_repeated_wakes); + RUN_TEST(lock_registry_concurrent_shared_exclusive_stress); + RUN_TEST(lock_registry_cross_process_writer_beats_late_reader); + RUN_TEST(lock_registry_fork_child_rejects_inherited_registry); +} diff --git a/tests/test_main.c b/tests/test_main.c index ffbb1fb5c..d410b02f0 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -13,6 +13,9 @@ int tf_skip_count = 0; #include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ #include "foundation/compat_fs.h" /* cbm_fopen — worker response file */ #include "foundation/mem.h" /* cbm_mem_init — worker budget */ +#include "daemon/runtime.h" /* bounded worker response probe */ +#include "daemon/ipc.h" /* Windows private-lock re-exec probe */ +#include "daemon/version_cohort.h" /* Windows crash-turnover re-exec probe */ #include "mcp/index_supervisor.h" /* cbm_index_set_worker_role */ #include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */ #include @@ -21,8 +24,15 @@ int tf_skip_count = 0; #include #include #include +#include #ifdef _WIN32 #include /* #798 follow-up: socket-isolation re-exec probe */ +#else +#include +#ifdef __APPLE__ +#include +#include +#endif #endif /* Test handlers that exercise the production index_repository flow must never @@ -51,48 +61,102 @@ static bool tf_setup_cache_sentinel(void) { return true; } -/* #832 guard support: when the index supervisor spawns THIS binary as - * ` cli --index-worker index_repository --response-out ` - * (exactly the argv cbm_index_spawn_worker builds), act as a faithful in-process - * index worker instead of re-running the test suites. This lets the deterministic - * gating guard (test_mcp.c) spawn a REAL worker child that indexes the fixture and - * writes its response back, using only public APIs — no production test seam. - * Returns an exit code (>=0) when it handled a worker invocation, else -1. */ -static int tf_maybe_run_index_worker(int argc, char **argv) { - if (argc < 2 || strcmp(argv[1], "cli") != 0) { - return -1; +/* Fast real-process probes for the async index-supervisor contract. They run + * only in a child admitted by the exact build-bound worker grammar. */ +static void tf_index_worker_probe(const char *args_json, const char *response_out) { + if (!args_json || !strstr(args_json, "\"__cbm_test_worker\"")) { + return; } - bool is_worker = false; - const char *tool = NULL; - const char *args_json = "{}"; - const char *response_out = NULL; - for (int i = 2; i < argc; i++) { - if (strcmp(argv[i], "--index-worker") == 0) { - is_worker = true; - } else if (strcmp(argv[i], "--response-out") == 0) { - if (i + 1 < argc) { - response_out = argv[++i]; + if (strstr(args_json, "\"clean\"")) { + FILE *response = response_out ? cbm_fopen(response_out, "wb") : NULL; + if (response) { + (void)fputs("{\"probe\":\"clean\"}", response); + (void)fclose(response); + } + (void)fprintf(stderr, "async worker clean probe\n"); + fflush(NULL); + _Exit(response ? 0 : 1); + } + if (strstr(args_json, "\"crash\"")) { + (void)fprintf(stderr, "async worker crash probe\n"); + fflush(NULL); + abort(); + } + if (strstr(args_json, "\"oversize\"")) { + FILE *response = response_out ? cbm_fopen(response_out, "wb") : NULL; + bool written = false; + if (response) { + written = fseek(response, (long)CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX, + SEEK_SET) == 0 && + fputc('x', response) != EOF; + written = fclose(response) == 0 && written; + } + (void)fprintf(stderr, "async worker oversized response probe\n"); + fflush(NULL); + _Exit(written ? 0 : 1); + } + if (strstr(args_json, "\"hang-tree\"")) { + (void)signal(SIGTERM, SIG_IGN); + long descendant = 0; +#ifndef _WIN32 + pid_t child = fork(); + if (child == 0) { + for (;;) { + cbm_usleep(100000); } - } else if (argv[i][0] == '{') { - args_json = argv[i]; - } else if (argv[i][0] != '-' && !tool) { - tool = argv[i]; + } + if (child > 0) { + descendant = (long)child; + } +#endif + const char *marker = getenv("CBM_INDEX_MARKER_FILE"); + FILE *ready = marker ? cbm_fopen(marker, "wb") : NULL; + if (ready) { + (void)fprintf( + ready, "single=%s\nmarker=%s\nquarantine=%s\nbudget=%zu\ndescendant=%ld\n", + getenv("CBM_INDEX_SINGLE_THREAD") ? getenv("CBM_INDEX_SINGLE_THREAD") : "", marker, + getenv("CBM_INDEX_QUARANTINE_FILE") ? getenv("CBM_INDEX_QUARANTINE_FILE") : "", + cbm_mem_budget(), descendant); + (void)fclose(ready); + } + (void)fprintf(stderr, "async worker hang-tree probe\n"); + fflush(NULL); + for (;;) { + cbm_usleep(100000); } } - if (!is_worker) { +} + +/* #832 guard support: when the index supervisor spawns THIS binary with the + * exact build-bound worker grammar produced by cbm_index_worker_start(), act + * as a faithful in-process index worker instead of re-running the test suites. + * This lets the deterministic + * gating guard (test_mcp.c) spawn a REAL worker child that indexes the fixture and + * writes its response back, using only public APIs — no production test seam. + * Returns an exit code (>=0) when it handled a worker invocation, else -1. */ +static int tf_maybe_run_index_worker(int argc, char **argv) { + cbm_index_worker_invocation_t invocation; + cbm_index_worker_argv_status_t status = + cbm_index_worker_parse_process_argv(argc, argv, &invocation); + if (status == CBM_INDEX_WORKER_ARGV_NOT_WORKER) { return -1; } - if (!tool) { - tool = "index_repository"; + if (status != CBM_INDEX_WORKER_ARGV_VALID) { + (void)fprintf(stderr, "CBM test index worker could not start: %s\n", + cbm_index_worker_argv_status_message(status)); + return 1; } - cbm_mem_init(0.5); - cbm_index_set_worker_role(true, response_out); /* worker role → index in-process */ + cbm_index_set_worker_role_options(true, invocation.response_out, invocation.single_thread, + invocation.marker_file, invocation.quarantine_file, + invocation.memory_budget_bytes); + cbm_mem_init_with_cap(0.5, invocation.memory_budget_bytes); + tf_index_worker_probe(invocation.args_json, invocation.response_out); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); if (!srv) { return 1; } - char *result = cbm_mcp_handle_tool(srv, tool, args_json); + char *result = cbm_mcp_handle_tool(srv, "index_repository", invocation.args_json); if (result) { const char *ro = cbm_index_worker_response_out(); if (ro) { @@ -148,6 +212,236 @@ static int tf_maybe_run_socket_probe(int argc, char **argv) { #endif } +/* Windows cannot use fork to prove process-to-process daemon lock ownership. + * The daemon IPC suite re-execs this runner in this narrow mode and observes + * the tri-state through a stable exit-code mapping: 0 acquired, 20 busy, + * 21 validation/OS error. */ +static int tf_maybe_run_daemon_ipc_lock_probe(int argc, char **argv) { +#ifdef _WIN32 + if (argc != 5 || strcmp(argv[1], "__cbm_daemon_ipc_lock_probe") != 0) { + return -1; + } + cbm_daemon_ipc_endpoint_t *endpoint = + cbm_daemon_ipc_endpoint_new(argv[3], argv[4]); + if (!endpoint) { + return 21; + } + int result = -1; + if (strcmp(argv[2], "startup") == 0) { + cbm_daemon_ipc_startup_lock_t *lock = NULL; + result = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &lock); + if (!cbm_daemon_ipc_startup_lock_release(&lock)) { + result = -1; + } + } else if (strcmp(argv[2], "lifetime") == 0) { + cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL; + result = cbm_daemon_ipc_lifetime_reservation_try_acquire( + endpoint, &reservation); + cbm_daemon_ipc_lifetime_reservation_release(reservation); + } + cbm_daemon_ipc_endpoint_free(endpoint); + return result == 1 ? 0 : (result == 0 ? 20 : 21); +#else + (void)argc; + (void)argv; + return -1; +#endif +} + +static int tf_maybe_run_version_cohort_crash_holder(int argc, char **argv) { +#ifdef _WIN32 + if (argc != 5 || + strcmp(argv[1], "__cbm_version_cohort_crash_holder") != 0) { + return -1; + } + cbm_daemon_ipc_endpoint_t *endpoint = + cbm_daemon_ipc_endpoint_new(argv[2], argv[3]); + cbm_version_cohort_manager_t *manager = + endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL; + cbm_daemon_build_identity_t identity = { + .semantic_version = "2.4.0", + .build_fingerprint = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + .protocol_abi = 3, + .store_abi = 11, + .feature_abi = 7, + }; + cbm_version_cohort_lease_t *lease = NULL; + cbm_daemon_conflict_t conflict; + cbm_version_cohort_status_t status = + manager ? cbm_version_cohort_acquire(manager, &identity, UINT64_MAX, + &lease, &conflict) + : CBM_VERSION_COHORT_IO; + FILE *ready = status == CBM_VERSION_COHORT_OK + ? cbm_fopen(argv[4], "wb") + : NULL; + bool announced = false; + if (ready) { + bool written = fputc('R', ready) != EOF; + announced = fclose(ready) == 0 && written; + } + if (announced) { + Sleep(INFINITE); + return 23; + } + while (lease && cbm_version_cohort_lease_release(&lease) != + CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + while (manager && cbm_version_cohort_manager_free(&manager) != + CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + cbm_daemon_ipc_endpoint_free(endpoint); + return 22; +#else + (void)argc; + (void)argv; + return -1; +#endif +} + +static int tf_maybe_run_runtime_image_holder(int argc, char **argv) { +#ifdef _WIN32 + if (argc != 3 || + strcmp(argv[1], "__cbm_runtime_image_holder") != 0) { + return -1; + } + HANDLE ready = OpenEventA(EVENT_MODIFY_STATE, FALSE, argv[2]); + bool announced = ready && SetEvent(ready) != 0; + if (ready) { + (void)CloseHandle(ready); + } + if (!announced) { + return 24; + } + Sleep(INFINITE); + return 25; +#else + (void)argc; + (void)argv; + return -1; +#endif +} + +/* Real copied-image HELLO probe used by daemon_runtime. Keeping this in the + * test runner avoids any production-only test hook: the daemon authenticates + * and fingerprints an ordinary, separately executed process image. */ +static int tf_maybe_run_runtime_hello_client(int argc, char **argv) { + if (argc != 6 || strcmp(argv[1], "__cbm_runtime_hello_client") != 0) { + return -1; + } + cbm_daemon_ipc_endpoint_t *endpoint = + cbm_daemon_ipc_endpoint_new(argv[3], argv[2]); + cbm_daemon_build_identity_t identity = { + .semantic_version = argv[4], + .build_fingerprint = argv[5], + }; + cbm_daemon_runtime_connect_result_t result; + memset(&result, 0, sizeof(result)); + cbm_daemon_runtime_client_t *client = + endpoint ? cbm_daemon_runtime_client_connect(endpoint, &identity, 5000, + &result) + : NULL; + bool accepted = client && + result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED && + result.hello_status == CBM_DAEMON_HELLO_COMPATIBLE; + bool closed = !client || cbm_daemon_runtime_client_close(client, 5000); + cbm_daemon_ipc_endpoint_free(endpoint); + if (!accepted) { + return 26; + } + return closed ? 0 : 27; +} + +/* Real copied-image activation probe. The request identity is supplied by the + * executing copy so the daemon must authenticate that peer image rather than + * require the active generation's build fingerprint. */ +static int tf_maybe_run_runtime_activation_client(int argc, char **argv) { + if (argc != 7 || + strcmp(argv[1], "__cbm_runtime_activation_client") != 0) { + return -1; + } + char *action_end = NULL; + unsigned long action_value = strtoul(argv[6], &action_end, 10); + bool action_valid = action_end != argv[6] && *action_end == '\0' && + action_value >= (unsigned long) + CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL && + action_value <= (unsigned long) + CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL; + cbm_daemon_ipc_endpoint_t *endpoint = + action_valid ? cbm_daemon_ipc_endpoint_new(argv[3], argv[2]) : NULL; + cbm_daemon_build_identity_t identity = { + .semantic_version = argv[4], + .build_fingerprint = argv[5], + }; + cbm_daemon_runtime_activation_result_t result; + memset(&result, 0, sizeof(result)); + bool exchanged = endpoint && + cbm_daemon_runtime_request_activation_shutdown( + endpoint, &identity, + (cbm_daemon_runtime_activation_action_t)action_value, + 5000, &result); + cbm_daemon_ipc_endpoint_free(endpoint); + return exchanged && result.accepted ? 0 : 29; +} + +/* macOS adversarial HELLO probe: execute this mode from a foreign process + * image while mapping the genuine runner RX. The daemon must authenticate the + * main image, not merely find its own vnode in an arbitrary executable map. */ +static int tf_maybe_run_runtime_mapped_hello_client(int argc, char **argv) { +#ifdef __APPLE__ + if (argc != 7 || + strcmp(argv[1], "__cbm_runtime_mapped_hello_client") != 0) { + return -1; + } + int image_fd = open(argv[2], O_RDONLY | O_CLOEXEC | O_NOFOLLOW); + void *mapping = image_fd >= 0 + ? mmap(NULL, 1, PROT_READ | PROT_EXEC, MAP_PRIVATE, + image_fd, 0) + : MAP_FAILED; + bool mapped = mapping != MAP_FAILED; + if (image_fd >= 0 && close(image_fd) != 0) { + mapped = false; + } + if (!mapped) { + if (mapping != MAP_FAILED) { + (void)munmap(mapping, 1); + } + return 28; + } + + char *hello_argv[] = {argv[0], "__cbm_runtime_hello_client", argv[3], + argv[4], argv[5], argv[6]}; + int result = tf_maybe_run_runtime_hello_client(6, hello_argv); + if (munmap(mapping, 1) != 0) { + return 28; + } + return result >= 0 ? result : 28; +#else + (void)argc; + (void)argv; + return -1; +#endif +} + +static int tf_maybe_run_mcp_idxfailclosed_probe(int argc, char **argv) { +#ifndef _WIN32 + if (argc != 4 || + strcmp(argv[1], "__cbm_mcp_idxfailclosed_probe") != 0) { + return -1; + } + extern int mcp_test_idxfailclosed_supervisor_start_check( + const char *repo_dir, const char *cache_dir); + alarm(20); + return mcp_test_idxfailclosed_supervisor_start_check(argv[2], argv[3]); +#else + (void)argc; + (void)argv; + return -1; +#endif +} + static int g_suite_argc = 0; static char **g_suite_argv = NULL; static bool *g_suite_arg_matched = NULL; @@ -182,6 +476,8 @@ extern void suite_log(void); extern void suite_str_util(void); extern void suite_platform(void); extern void suite_subprocess(void); +extern void suite_private_file_lock(void); +extern void suite_lock_registry(void); extern void suite_extraction(void); extern void suite_extraction_inheritance(void); extern void suite_extraction_imports(void); @@ -195,6 +491,17 @@ extern void suite_store_edges(void); extern void suite_store_search(void); extern void suite_cypher(void); extern void suite_mcp(void); +extern void suite_mcp_mutation_guard(void); +extern void suite_index_supervisor(void); +extern void suite_daemon(void); +extern void suite_project_lock(void); +extern void suite_version_cohort(void); +extern void suite_daemon_version(void); +extern void suite_daemon_runtime(void); +extern void suite_daemon_application(void); +extern void suite_daemon_frontend(void); +extern void suite_daemon_bootstrap(void); +extern void suite_daemon_ipc(void); extern void suite_language(void); extern void suite_userconfig(void); extern void suite_gitignore(void); @@ -203,6 +510,7 @@ extern void suite_discover(void); extern void suite_graph_buffer(void); extern void suite_registry(void); extern void suite_pipeline(void); +extern void suite_cross_repo(void); extern void suite_index_resilience(void); extern void suite_fqn(void); extern void suite_route_canon(void); @@ -243,6 +551,7 @@ extern void suite_config_json_like(void); extern void suite_config_toml_edit(void); extern void suite_config_yaml_edit(void); extern void suite_config_text_edit(void); +extern void suite_activation_transaction(void); extern void suite_system_info(void); extern void suite_worker_pool(void); extern void suite_parallel(void); @@ -283,6 +592,48 @@ extern void suite_dump_verify_io(void); extern void cbm_kind_in_set_free_cache(void); int main(int argc, char **argv) { + /* Installation tests use this executable as a structurally real candidate. + * Mirror the production binary's minimal verification contract. */ + if (argc == 2 && strcmp(argv[1], "--version") == 0) { + (void)puts("codebase-memory-mcp test-runner"); + return 0; + } + int mcp_idxfailclosed_rc = + tf_maybe_run_mcp_idxfailclosed_probe(argc, argv); + if (mcp_idxfailclosed_rc >= 0) { + return mcp_idxfailclosed_rc; + } + int runtime_image_rc = + tf_maybe_run_runtime_image_holder(argc, argv); + if (runtime_image_rc >= 0) { + return runtime_image_rc; + } + int runtime_hello_rc = + tf_maybe_run_runtime_hello_client(argc, argv); + if (runtime_hello_rc >= 0) { + return runtime_hello_rc; + } + int runtime_activation_rc = + tf_maybe_run_runtime_activation_client(argc, argv); + if (runtime_activation_rc >= 0) { + return runtime_activation_rc; + } + int runtime_mapped_hello_rc = + tf_maybe_run_runtime_mapped_hello_client(argc, argv); + if (runtime_mapped_hello_rc >= 0) { + return runtime_mapped_hello_rc; + } + int cohort_crash_rc = + tf_maybe_run_version_cohort_crash_holder(argc, argv); + if (cohort_crash_rc >= 0) { + return cohort_crash_rc; + } + int daemon_ipc_probe_rc = + tf_maybe_run_daemon_ipc_lock_probe(argc, argv); + if (daemon_ipc_probe_rc >= 0) { + return daemon_ipc_probe_rc; + } + /* #798 follow-up: if spawned as the socket-isolation probe, report whether an * inheritable socket handle crossed into this child and exit before any suite. */ int probe_rc = tf_maybe_run_socket_probe(argc, argv); @@ -290,6 +641,11 @@ int main(int argc, char **argv) { return probe_rc; } + /* Capture once so the parent tests and any re-exec'd worker bind to this + * executable image. A worker with an unavailable/mismatched identity is + * rejected by the exact parser below before any suite or MCP state exists. */ + (void)cbm_index_supervisor_capture_build_fingerprint(); + /* #832: if spawned as a supervised index worker, do the real work and exit * before any suite runs (see tf_maybe_run_index_worker). */ int worker_rc = tf_maybe_run_index_worker(argc, argv); @@ -328,6 +684,8 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(str_util); RUN_SELECTED_SUITE(platform); RUN_SELECTED_SUITE(subprocess); + RUN_SELECTED_SUITE(private_file_lock); + RUN_SELECTED_SUITE(lock_registry); RUN_SELECTED_SUITE(dump_verify); /* Existing C code regression tests */ @@ -354,6 +712,19 @@ int main(int argc, char **argv) { /* MCP Server (M9) */ RUN_SELECTED_SUITE(mcp); + RUN_SELECTED_SUITE(mcp_mutation_guard); + RUN_SELECTED_SUITE(index_supervisor); + + /* Shared MCP daemon coordination + private framing */ + RUN_SELECTED_SUITE(daemon); + RUN_SELECTED_SUITE(project_lock); + RUN_SELECTED_SUITE(version_cohort); + RUN_SELECTED_SUITE(daemon_version); + RUN_SELECTED_SUITE(daemon_runtime); + RUN_SELECTED_SUITE(daemon_application); + RUN_SELECTED_SUITE(daemon_frontend); + RUN_SELECTED_SUITE(daemon_bootstrap); + RUN_SELECTED_SUITE(daemon_ipc); /* Discover (M2) */ RUN_SELECTED_SUITE(language); @@ -368,6 +739,7 @@ int main(int argc, char **argv) { /* Pipeline (M8) */ RUN_SELECTED_SUITE(registry); RUN_SELECTED_SUITE(pipeline); + RUN_SELECTED_SUITE(cross_repo); RUN_SELECTED_SUITE(index_resilience); RUN_SELECTED_SUITE(fqn); RUN_SELECTED_SUITE(route_canon); @@ -425,6 +797,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(config_toml_edit); RUN_SELECTED_SUITE(config_yaml_edit); RUN_SELECTED_SUITE(config_text_edit); + RUN_SELECTED_SUITE(activation_transaction); /* System info + worker pool (parallelism) */ RUN_SELECTED_SUITE(system_info); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 4e1d5eeb3..906c38593 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -8,15 +8,18 @@ #include "../src/foundation/compat_fs.h" /* cbm_unlink / cbm_rmdir */ #include "../src/foundation/constants.h" #include "../src/foundation/log.h" +#include "../src/foundation/platform.h" /* cbm_file_size */ #include "test_framework.h" #include "test_helpers.h" #include #include /* spawn-count hook — #845 in-process guard */ #include +#include #include #include #include #include +#include #include #include #include @@ -27,10 +30,16 @@ #define cbm_chdir _chdir #define cbm_getcwd _getcwd #else -#include /* waitpid — #845 fork+alarm harness */ +#ifdef __APPLE__ +#include +#endif +#include +#include +#include #include #define cbm_chdir chdir #define cbm_getcwd getcwd +extern char **environ; #endif static bool mcp_response_has_exact_tool(const char *response, const char *expected_name) { @@ -116,6 +125,302 @@ static void cleanup_project_db(const char *cache, const char *project) { cbm_unlink(path); } +#define MCP_MUTATION_GUARD_MAX_EVENTS 16 + +typedef struct { + int deny_begin_call; /* one-based; zero allows every acquisition */ + int cancel_on_begin_call; /* one-based; zero never requests cancellation */ + int begin_count; + int end_count; + cbm_mcp_server_t *cancel_server; + bool cancel_attempted; + bool cancel_accepted; + const char *observed_db_path; + const char *observed_backup_path; + bool db_exists_at_begin; + bool backup_exists_at_begin; + bool db_exists_at_end; + bool backup_exists_at_end; + char begin_projects[MCP_MUTATION_GUARD_MAX_EVENTS][CBM_SZ_256]; + char end_projects[MCP_MUTATION_GUARD_MAX_EVENTS][CBM_SZ_256]; +} mcp_mutation_guard_probe_t; + +typedef struct { + const char *deny_step; + int call_count; + char steps[4][64]; +} mcp_quarantine_hook_probe_t; + +static bool mcp_quarantine_hook_probe(void *context, const char *step) { + mcp_quarantine_hook_probe_t *probe = context; + if (!probe || !step) { + return false; + } + int event = probe->call_count++; + if (event >= 0 && event < 4) { + snprintf(probe->steps[event], sizeof(probe->steps[event]), "%s", step); + } + return !probe->deny_step || strcmp(probe->deny_step, step) != 0; +} + +static bool mcp_mutation_guard_probe_begin(void *context, const char *project) { + mcp_mutation_guard_probe_t *probe = context; + if (!probe) { + return false; + } + + int event = probe->begin_count++; + if (event < MCP_MUTATION_GUARD_MAX_EVENTS) { + snprintf(probe->begin_projects[event], sizeof(probe->begin_projects[event]), "%s", + project ? project : ""); + } + if (probe->cancel_on_begin_call > 0 && + probe->begin_count == probe->cancel_on_begin_call) { + probe->cancel_attempted = true; + probe->cancel_accepted = cbm_mcp_server_cancel_active(probe->cancel_server); + } + if (probe->observed_db_path) { + probe->db_exists_at_begin = cbm_file_exists(probe->observed_db_path); + } + if (probe->observed_backup_path) { + probe->backup_exists_at_begin = cbm_file_exists(probe->observed_backup_path); + } + return probe->deny_begin_call == 0 || probe->begin_count != probe->deny_begin_call; +} + +static void mcp_mutation_guard_probe_end(void *context, const char *project) { + mcp_mutation_guard_probe_t *probe = context; + if (!probe) { + return; + } + + int event = probe->end_count++; + if (event < MCP_MUTATION_GUARD_MAX_EVENTS) { + snprintf(probe->end_projects[event], sizeof(probe->end_projects[event]), "%s", + project ? project : ""); + } + if (probe->observed_db_path) { + probe->db_exists_at_end = cbm_file_exists(probe->observed_db_path); + } + if (probe->observed_backup_path) { + probe->backup_exists_at_end = cbm_file_exists(probe->observed_backup_path); + } +} + +static bool mcp_make_corrupt_project_store(const char *cache, const char *project) { + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) { + return false; + } + + /* Numeric root paths are the deterministic corruption trigger used by + * cbm_store_check_integrity() and the issue #557 reproduction. */ + bool created = cbm_store_upsert_project(store, project, "826") == CBM_STORE_OK; + cbm_store_close(store); + return created; +} + +/* Keep a writer open so the fixture has a real, committed WAL generation. + * Query-only opens must not alter either file when quarantine is denied or + * cannot be published safely. The caller owns the returned store. */ +static cbm_store_t *mcp_open_corrupt_project_store_with_wal(const char *cache, + const char *project) { + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) { + return NULL; + } + + bool ready = cbm_store_exec(store, "PRAGMA wal_autocheckpoint=0;") == CBM_STORE_OK && + cbm_store_upsert_project(store, project, "826") == CBM_STORE_OK && + cbm_store_exec(store, + "CREATE TABLE IF NOT EXISTS guard_wal_sentinel(value TEXT);" + "INSERT INTO guard_wal_sentinel(value) VALUES('committed');") == + CBM_STORE_OK; + if (!ready) { + cbm_store_close(store); + return NULL; + } + return store; +} + +static bool mcp_make_valid_project_store_at(const char *path, const char *project, + const char *root_path) { + cbm_store_t *store = cbm_store_open_path(path); + if (!store) { + return false; + } + bool ready = cbm_store_upsert_project(store, project, root_path) == CBM_STORE_OK && + cbm_store_prepare_for_publish(store) == CBM_STORE_OK; + cbm_store_close(store); + return ready; +} + +static unsigned char *mcp_read_file_bytes(const char *path, long *out_len) { + if (!out_len) { + return NULL; + } + *out_len = 0; + FILE *fp = cbm_fopen(path, "rb"); + if (!fp) { + return NULL; + } + if (fseek(fp, 0, SEEK_END) != 0) { + fclose(fp); + return NULL; + } + long size = ftell(fp); + if (size < 0 || fseek(fp, 0, SEEK_SET) != 0) { + fclose(fp); + return NULL; + } + unsigned char *bytes = malloc(size > 0 ? (size_t)size : 1); + if (!bytes) { + fclose(fp); + return NULL; + } + size_t read_count = fread(bytes, 1, (size_t)size, fp); + fclose(fp); + if (read_count != (size_t)size) { + free(bytes); + return NULL; + } + *out_len = size; + return bytes; +} + +static bool mcp_file_matches_snapshot(const char *path, const unsigned char *expected, + long expected_len) { + long actual_len = 0; + unsigned char *actual = mcp_read_file_bytes(path, &actual_len); + bool matches = actual && expected && actual_len == expected_len && + memcmp(actual, expected, (size_t)actual_len) == 0; + free(actual); + return matches; +} + +/* Return the number of quarantine files for a project and, when present, the + * first path whose name is distinct from the legacy fixed `.corrupt` name. */ +static bool mcp_is_corrupt_backup_main_name(const char *name, const char *prefix) { + size_t prefix_len = strlen(prefix); + if (strcmp(name, prefix) == 0) { + return true; + } + const char *suffix = name + prefix_len; + if (strncmp(name, prefix, prefix_len) != 0 || suffix[0] != '.' || + strlen(suffix + 1) != 16) { + return false; + } + for (const char *cursor = suffix + 1; *cursor; cursor++) { + if (!isxdigit((unsigned char)*cursor)) { + return false; + } + } + return true; +} + +static int mcp_find_corrupt_backups(const char *cache, const char *project, char *unique_path, + size_t unique_path_size) { + if (unique_path && unique_path_size > 0) { + unique_path[0] = '\0'; + } + char prefix[CBM_DIRENT_NAME_MAX]; + snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project); + int count = 0; + cbm_dir_t *dir = cbm_opendir(cache); + if (!dir) { + return 0; + } + cbm_dirent_t *entry; + while ((entry = cbm_readdir(dir)) != NULL) { + if (!mcp_is_corrupt_backup_main_name(entry->name, prefix)) { + continue; + } + char path[CBM_SZ_1K]; + snprintf(path, sizeof(path), "%s/%s", cache, entry->name); + if (!cbm_file_exists(path)) { + continue; + } + count++; + if (unique_path && unique_path_size > 0 && unique_path[0] == '\0' && + strcmp(entry->name, prefix) != 0) { + snprintf(unique_path, unique_path_size, "%s", path); + } + } + cbm_closedir(dir); + return count; +} + +static int mcp_count_corrupt_artifacts(const char *cache, const char *project) { + char prefix[CBM_DIRENT_NAME_MAX]; + snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project); + size_t prefix_len = strlen(prefix); + int count = 0; + cbm_dir_t *dir = cbm_opendir(cache); + if (!dir) { + return 0; + } + cbm_dirent_t *entry; + while ((entry = cbm_readdir(dir)) != NULL) { + if (strncmp(entry->name, prefix, prefix_len) == 0) { + count++; + } + } + cbm_closedir(dir); + return count; +} + +static void mcp_cleanup_corrupt_backups(const char *cache, const char *project) { + char prefix[CBM_DIRENT_NAME_MAX]; + snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project); + size_t prefix_len = strlen(prefix); + cbm_dir_t *dir = cbm_opendir(cache); + if (!dir) { + return; + } + cbm_dirent_t *entry; + while ((entry = cbm_readdir(dir)) != NULL) { + if (strncmp(entry->name, prefix, prefix_len) == 0) { + char path[CBM_SZ_1K]; + snprintf(path, sizeof(path), "%s/%s", cache, entry->name); + cbm_unlink(path); + } + } + cbm_closedir(dir); +} + +typedef struct { + mcp_mutation_guard_probe_t guard; + const char *replacement_path; + const char *live_path; + bool replacement_attempted; + bool replacement_succeeded; +} mcp_replacing_mutation_guard_t; + +static bool mcp_replacing_mutation_guard_begin(void *context, const char *project) { + mcp_replacing_mutation_guard_t *replacement = context; + if (!replacement || + !mcp_mutation_guard_probe_begin(&replacement->guard, project)) { + return false; + } + replacement->replacement_attempted = true; + bool sidecars_removed = cbm_remove_db_sidecars(replacement->live_path) == 0; + replacement->replacement_succeeded = + sidecars_removed && + cbm_rename_replace(replacement->replacement_path, replacement->live_path) == 0; + return true; +} + +static void mcp_replacing_mutation_guard_end(void *context, const char *project) { + mcp_replacing_mutation_guard_t *replacement = context; + if (replacement) { + mcp_mutation_guard_probe_end(&replacement->guard, project); + } +} + /* ══════════════════════════════════════════════════════════════════ * JSON-RPC PARSING * ══════════════════════════════════════════════════════════════════ */ @@ -1928,6 +2233,95 @@ TEST(tool_delete_project_not_found) { PASS(); } +TEST(tool_delete_project_mutation_guard_blocks_then_releases) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-delete-guard-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "guard-delete-project"; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *setup = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(setup); + ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/guard-delete-project"), + CBM_STORE_OK); + cbm_store_close(setup); + ASSERT_TRUE(cbm_file_exists(db_path)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + + char *resp = cbm_mcp_handle_tool( + srv, "delete_project", "{\"project\":\"guard-delete-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "blocked")); + ASSERT_EQ(probe.begin_count, 1); + ASSERT_EQ(probe.end_count, 0); + ASSERT_STR_EQ(probe.begin_projects[0], project); + ASSERT_TRUE(cbm_file_exists(db_path)); + free(resp); + + probe.deny_begin_call = 0; + resp = cbm_mcp_handle_tool( + srv, "delete_project", "{\"project\":\"guard-delete-project\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "deleted")); + ASSERT_EQ(probe.begin_count, 2); + ASSERT_EQ(probe.end_count, 1); + ASSERT_STR_EQ(probe.begin_projects[1], project); + ASSERT_STR_EQ(probe.end_projects[0], project); + ASSERT_FALSE(cbm_file_exists(db_path)); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + cbm_rmdir(cache); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + PASS(); +} + +TEST(tool_index_repository_mutation_guard_blocks_before_local_worker) { + char root[CBM_SZ_1K]; + (void)snprintf(root, sizeof(root), "%s/cbm-index-guard-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(root)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + + char args[CBM_SZ_2K]; + (void)snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"name\":\"GuardedIndex\"," + "\"mode\":\"fast\"}", root); + int spawn_before = cbm_index_supervisor_spawn_count(); + char *response = cbm_mcp_handle_tool(srv, "index_repository", args); + int spawn_after = cbm_index_supervisor_spawn_count(); + + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "blocked")); + ASSERT_EQ(probe.begin_count, 1); + ASSERT_EQ(probe.end_count, 0); + ASSERT_STR_EQ(probe.begin_projects[0], "GuardedIndex"); + ASSERT_EQ(spawn_after, spawn_before); + + free(response); + cbm_mcp_server_free(srv); + (void)th_rmtree(root); + PASS(); +} + TEST(tool_get_architecture_empty) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); @@ -2986,45 +3380,1094 @@ TEST(tool_manage_adr_get_with_existing_adr) { PASS(); } -/* issue #256: manage_adr (MCP) and the UI /api/adr endpoints must share ONE - * backend. A manage_adr(update) write must be readable via cbm_store_adr_get - * (the exact API the UI's /api/adr GET uses). */ -TEST(tool_manage_adr_unified_backend_issue256) { +/* issue #256: manage_adr (MCP) and the UI /api/adr endpoints must share ONE + * backend. A manage_adr(update) write must be readable via cbm_store_adr_get + * (the exact API the UI's /api/adr GET uses). */ +TEST(tool_manage_adr_unified_backend_issue256) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(st); + cbm_store_upsert_project(st, "adr-unify", "/tmp/adr-unify"); + cbm_mcp_server_set_project(srv, "adr-unify"); + + /* Write via the MCP tool. */ + char *resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":120,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_adr\",\"arguments\":{\"project\":\"adr-unify\"," + "\"mode\":\"update\",\"content\":\"## PURPOSE\\nUnified ADR backend.\\n\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + free(resp); + + /* Read DIRECTLY via the store API the UI /api/adr uses — must see it. */ + cbm_adr_t adr; + memset(&adr, 0, sizeof(adr)); + ASSERT_EQ(cbm_store_adr_get(st, "adr-unify", &adr), CBM_STORE_OK); + ASSERT_NOT_NULL(adr.content); + ASSERT_NOT_NULL(strstr(adr.content, "Unified ADR backend.")); + cbm_store_adr_free(&adr); + + /* And manage_adr(get) round-trips the same content. */ + resp = cbm_mcp_server_handle( + srv, "{\"jsonrpc\":\"2.0\",\"id\":121,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"manage_adr\",\"arguments\":{\"project\":\"adr-unify\"," + "\"mode\":\"get\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Unified ADR backend.")); + ASSERT_NULL(strstr(resp, "\"isError\":true")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_manage_adr_mutation_guard_balances_success) { + const char *project = "guard-adr-success"; + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/guard-adr-success"), + CBM_STORE_OK); + cbm_mcp_server_set_project(srv, project); + + mcp_mutation_guard_probe_t probe = {0}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + + char *resp = cbm_mcp_handle_tool( + srv, "manage_adr", + "{\"project\":\"guard-adr-success\",\"mode\":\"update\"," + "\"content\":\"## PURPOSE\\nGuarded ADR.\\n\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + ASSERT_EQ(probe.begin_count, 1); + ASSERT_EQ(probe.end_count, 1); + ASSERT_STR_EQ(probe.begin_projects[0], project); + ASSERT_STR_EQ(probe.end_projects[0], project); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(tool_manage_adr_mutation_guard_releases_on_missing_store) { + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-adr-guard-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + PASS(); + } + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "guard-adr-missing"; + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {0}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + + char *resp = cbm_mcp_handle_tool( + srv, "manage_adr", "{\"project\":\"guard-adr-missing\",\"mode\":\"get\"}"); + ASSERT_NOT_NULL(resp); + ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed")); + ASSERT_EQ(probe.begin_count, 1); + ASSERT_EQ(probe.end_count, 1); + ASSERT_STR_EQ(probe.begin_projects[0], project); + ASSERT_STR_EQ(probe.end_projects[0], project); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + cbm_rmdir(cache); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + PASS(); +} + +static bool mcp_cross_repo_create_project_store(const char *cache, const char *project, + const char *root_path) { + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *store = cbm_store_open_path(db_path); + if (!store) { + return false; + } + bool created = cbm_store_upsert_project(store, project, root_path) == CBM_STORE_OK; + cbm_store_close(store); + return created; +} + +/* Seed exactly one HTTP route match without invoking the indexing pipeline. + * This keeps the duplicate-target regression fast and makes a doubled result + * count observable instead of relying on an empty (zero-edge) scan. */ +static bool mcp_cross_repo_seed_http_match(const char *cache, const char *source_project, + const char *target_project, const char *root_path) { + char source_path[CBM_SZ_1K]; + char target_path[CBM_SZ_1K]; + snprintf(source_path, sizeof(source_path), "%s/%s.db", cache, source_project); + snprintf(target_path, sizeof(target_path), "%s/%s.db", cache, target_project); + + cbm_store_t *source = cbm_store_open_path(source_path); + cbm_store_t *target = cbm_store_open_path(target_path); + if (!source || !target) { + cbm_store_close(source); + cbm_store_close(target); + return false; + } + + bool ok = cbm_store_upsert_project(source, source_project, root_path) == CBM_STORE_OK && + cbm_store_upsert_project(target, target_project, root_path) == CBM_STORE_OK; + + cbm_node_t caller = {.project = source_project, + .label = "Function", + .name = "call_once", + .qualified_name = "cross.source.call_once", + .file_path = "client.c", + .start_line = 1, + .end_line = 2}; + cbm_node_t local_route = {.project = source_project, + .label = "Route", + .name = "GET /dedupe", + .qualified_name = "__route__GET__/dedupe", + .file_path = "client.c", + .start_line = 3, + .end_line = 3}; + int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0; + int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0; + cbm_edge_t http_call = {.project = source_project, + .source_id = caller_id, + .target_id = local_route_id, + .type = "HTTP_CALLS", + .properties_json = + "{\"url_path\":\"/dedupe\",\"method\":\"GET\"}"}; + ok = ok && caller_id > 0 && local_route_id > 0 && + cbm_store_insert_edge(source, &http_call) > 0; + + cbm_node_t target_route = {.project = target_project, + .label = "Route", + .name = "GET /dedupe", + .qualified_name = "__route__GET__/dedupe", + .file_path = "server.c", + .start_line = 3, + .end_line = 3}; + cbm_node_t handler = {.project = target_project, + .label = "Function", + .name = "handle_once", + .qualified_name = "cross.target.handle_once", + .file_path = "server.c", + .start_line = 1, + .end_line = 2}; + int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0; + int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0; + cbm_edge_t handles = {.project = target_project, + .source_id = handler_id, + .target_id = target_route_id, + .type = "HANDLES"}; + ok = ok && target_route_id > 0 && handler_id > 0 && + cbm_store_insert_edge(target, &handles) > 0; + + cbm_store_close(source); + cbm_store_close(target); + return ok; +} + +TEST(tool_cross_repo_mutation_guard_sorts_dedupes_and_unwinds) { + char repo[256]; + snprintf(repo, sizeof(repo), "/tmp/cbm-mcp-cross-guard-XXXXXX"); + if (!cbm_mkdtemp(repo)) { + PASS(); + } + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, repo, NULL)); + + mcp_mutation_guard_probe_t probe = {.deny_begin_call = 3}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"zzz-target\",\"000-target\",\"zzz-target\"]}", + repo); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "blocked")); + + /* The source plus two unique targets are acquired in lexical order. The + * third acquisition is denied, so only the first two are unwound. */ + ASSERT_EQ(probe.begin_count, 3); + ASSERT_TRUE(strcmp(probe.begin_projects[0], probe.begin_projects[1]) < 0); + ASSERT_TRUE(strcmp(probe.begin_projects[1], probe.begin_projects[2]) < 0); + int low_target_count = 0; + int high_target_count = 0; + for (int i = 0; i < probe.begin_count; i++) { + low_target_count += strcmp(probe.begin_projects[i], "000-target") == 0; + high_target_count += strcmp(probe.begin_projects[i], "zzz-target") == 0; + } + ASSERT_EQ(low_target_count, 1); + ASSERT_EQ(high_target_count, 1); + ASSERT_EQ(probe.end_count, 2); + ASSERT_STR_EQ(probe.end_projects[0], probe.begin_projects[1]); + ASSERT_STR_EQ(probe.end_projects[1], probe.begin_projects[0]); + free(resp); + + cbm_mcp_server_free(srv); + cbm_rmdir(repo); + PASS(); +} + +static unsigned char mcp_test_ascii_casefold(unsigned char ch) { + return ch >= 'A' && ch <= 'Z' ? (unsigned char)(ch + ('a' - 'A')) : ch; +} + +static bool mcp_test_project_keys_equivalent(const char *left, const char *right) { + if (!left || !right) { + return left == right; + } + while (*left && *right) { + if (mcp_test_ascii_casefold((unsigned char)*left) != + mcp_test_ascii_casefold((unsigned char)*right)) { + return false; + } + left++; + right++; + } + return *left == *right; +} + +/* Project-lock keys ASCII-fold A-Z, so case aliases must be one lease here too. + * Otherwise Foo + foo self-deadlocks, and two requests whose raw strcmp order + * differs can acquire the same OS locks in opposite (ABBA) order. Keep the + * original spellings: folding is only the comparison key, not a lookup value. */ +TEST(tool_cross_repo_mutation_guard_casefolds_aliases_and_order) { + char repo[256]; + snprintf(repo, sizeof(repo), "/tmp/cbm-mcp-cross-case-guard-XXXXXX"); + if (!cbm_mkdtemp(repo)) { + PASS(); + } + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, repo, NULL)); + + mcp_mutation_guard_probe_t first = {0}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &first); + char first_args[CBM_SZ_2K]; + snprintf(first_args, sizeof(first_args), + "{\"repo_path\":\"%s\",\"name\":\"Zulu\"," + "\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"Foo\",\"foo\",\"Alpha\"]}", + repo); + char *first_resp = cbm_mcp_handle_tool(srv, "index_repository", first_args); + ASSERT_NOT_NULL(first_resp); + free(first_resp); + + mcp_mutation_guard_probe_t second = {0}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &second); + char second_args[CBM_SZ_2K]; + snprintf(second_args, sizeof(second_args), + "{\"repo_path\":\"%s\",\"name\":\"zULU\"," + "\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"foo\",\"ALPHA\",\"FOO\"]}", + repo); + char *second_resp = cbm_mcp_handle_tool(srv, "index_repository", second_args); + ASSERT_NOT_NULL(second_resp); + free(second_resp); + + ASSERT_EQ(first.begin_count, 3); + ASSERT_EQ(first.end_count, 3); + ASSERT_EQ(second.begin_count, 3); + ASSERT_EQ(second.end_count, 3); + for (int i = 0; i < 3; i++) { + ASSERT_TRUE(mcp_test_project_keys_equivalent(first.begin_projects[i], + second.begin_projects[i])); + ASSERT_TRUE(mcp_test_project_keys_equivalent( + first.end_projects[i], first.begin_projects[2 - i])); + ASSERT_TRUE(mcp_test_project_keys_equivalent( + second.end_projects[i], second.begin_projects[2 - i])); + } + ASSERT_STR_EQ(first.begin_projects[0], "Alpha"); + ASSERT_STR_EQ(first.begin_projects[1], "Foo"); + ASSERT_STR_EQ(first.begin_projects[2], "Zulu"); + ASSERT_STR_EQ(second.begin_projects[0], "ALPHA"); + ASSERT_STR_EQ(second.begin_projects[1], "FOO"); + ASSERT_STR_EQ(second.begin_projects[2], "zULU"); + + cbm_mcp_server_free(srv); + cbm_rmdir(repo); + PASS(); +} + +/* A wildcard means "all projects" and therefore cannot be combined with a + * named target. Accepting the mixed form both obscures caller intent and lets + * the cross-repo pass create/use a literal "*.db" target on POSIX. Validation + * must happen before any project mutation lease is acquired. */ +TEST(tool_cross_repo_rejects_wildcard_mixed_with_named_targets) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-wildcard-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char *project = cbm_project_name_from_path(cache); + ASSERT_NOT_NULL(project); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + + mcp_mutation_guard_probe_t probe = {0}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"*\",\"named-target\"]}", + cache); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + bool rejected = resp && strstr(resp, "\"isError\":true") != NULL; + bool explained = resp && strstr(resp, "target_projects") && strstr(resp, "*") && + (strstr(resp, "only") || strstr(resp, "combin")); + int begin_count = probe.begin_count; + int end_count = probe.end_count; + + free(resp); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + cleanup_project_db(cache, "*"); + cleanup_project_db(cache, "named-target"); + free(project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(rejected); + ASSERT_TRUE(explained); + ASSERT_EQ(begin_count, 0); + ASSERT_EQ(end_count, 0); + PASS(); +} + +/* Cancellation can arrive while the final mutation lease is being acquired. + * The cross-repo operation must advertise itself through cancel_active(), + * observe the pending cancellation before doing cross-project writes, and + * unwind every lease it acquired. */ +TEST(tool_cross_repo_checks_cancellation_after_acquiring_leases) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-cancel-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char *project = cbm_project_name_from_path(cache); + ASSERT_NOT_NULL(project); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + + mcp_mutation_guard_probe_t probe = { + .cancel_on_begin_call = 3, + .cancel_server = srv, + }; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"000-cancel-target\",\"zzz-cancel-target\"]}", + cache); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + bool response_cancelled = resp && strstr(resp, "cancelled") != NULL; + bool cancel_attempted = probe.cancel_attempted; + bool cancel_accepted = probe.cancel_accepted; + int begin_count = probe.begin_count; + int end_count = probe.end_count; + bool reverse_unwind = + begin_count == 3 && end_count == 3 && + strcmp(probe.end_projects[0], probe.begin_projects[2]) == 0 && + strcmp(probe.end_projects[1], probe.begin_projects[1]) == 0 && + strcmp(probe.end_projects[2], probe.begin_projects[0]) == 0; + + free(resp); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + cleanup_project_db(cache, "000-cancel-target"); + cleanup_project_db(cache, "zzz-cancel-target"); + free(project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(cancel_attempted); + ASSERT_TRUE(cancel_accepted); + ASSERT_TRUE(response_cancelled); + ASSERT_EQ(begin_count, 3); + ASSERT_EQ(end_count, 3); + ASSERT_TRUE(reverse_unwind); + PASS(); +} + +/* cbm_store_open_path() creates its path. Cross-repo validation must therefore + * reject an absent source or named target before the matcher opens either one; + * otherwise a typo silently becomes a valid-looking empty project database. */ +TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-missing-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char *source_project = cbm_project_name_from_path(cache); + ASSERT_NOT_NULL(source_project); + const char *existing_target = "existing-cross-target"; + const char *missing_target = "missing-cross-target"; + ASSERT_TRUE(mcp_cross_repo_create_project_store(cache, existing_target, cache)); + + char source_db_path[CBM_SZ_1K]; + char missing_target_db_path[CBM_SZ_1K]; + snprintf(source_db_path, sizeof(source_db_path), "%s/%s.db", cache, source_project); + snprintf(missing_target_db_path, sizeof(missing_target_db_path), "%s/%s.db", cache, + missing_target); + ASSERT_FALSE(cbm_file_exists(source_db_path)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"%s\"]}", + cache, existing_target); + char *source_resp = cbm_mcp_handle_tool(srv, "index_repository", args); + bool source_failed = source_resp && strstr(source_resp, "\"isError\":true"); + bool source_reported = source_resp && + (strstr(source_resp, "not indexed") || + strstr(source_resp, "not found") || strstr(source_resp, "missing")); + bool source_ghost_created = cbm_file_exists(source_db_path); + free(source_resp); + + cleanup_project_db(cache, source_project); + ASSERT_TRUE(mcp_cross_repo_create_project_store(cache, source_project, cache)); + ASSERT_FALSE(cbm_file_exists(missing_target_db_path)); + + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"%s\"]}", + cache, missing_target); + char *target_resp = cbm_mcp_handle_tool(srv, "index_repository", args); + bool target_failed = target_resp && strstr(target_resp, "\"isError\":true"); + bool target_reported = target_resp && + (strstr(target_resp, "not indexed") || + strstr(target_resp, "not found") || strstr(target_resp, "missing")); + bool target_ghost_created = cbm_file_exists(missing_target_db_path); + free(target_resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, source_project); + cleanup_project_db(cache, existing_target); + cleanup_project_db(cache, missing_target); + free(source_project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(source_failed); + ASSERT_TRUE(source_reported); + ASSERT_FALSE(source_ghost_created); + ASSERT_TRUE(target_failed); + ASSERT_TRUE(target_reported); + ASSERT_FALSE(target_ghost_created); + PASS(); +} + +/* Named targets are a set, not a work list. A duplicate must be leased, + * scanned, and counted once; the fixture provides one real edge so the result + * counters cannot pass vacuously at zero. */ +TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-dedupe-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char *source_project = cbm_project_name_from_path(cache); + ASSERT_NOT_NULL(source_project); + const char *target_project = "cross-dedupe-target"; + ASSERT_TRUE(mcp_cross_repo_seed_http_match(cache, source_project, target_project, cache)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"%s\",\"%s\"]}", + cache, target_project, target_project); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + bool succeeded = resp && strstr(resp, "\"isError\":true") == NULL; + bool scanned_once = response_contains_json_fragment(resp, "\"projects_scanned\":1"); + bool counted_once = response_contains_json_fragment(resp, "\"cross_http_calls\":1") && + response_contains_json_fragment(resp, "\"total_cross_edges\":1"); + + char source_db_path[CBM_SZ_1K]; + char target_db_path[CBM_SZ_1K]; + snprintf(source_db_path, sizeof(source_db_path), "%s/%s.db", cache, source_project); + snprintf(target_db_path, sizeof(target_db_path), "%s/%s.db", cache, target_project); + cbm_store_t *source = cbm_store_open_path_query(source_db_path); + cbm_store_t *target = cbm_store_open_path_query(target_db_path); + int source_cross_edges = + source ? cbm_store_count_edges_by_type(source, source_project, "CROSS_HTTP_CALLS") : -1; + int target_cross_edges = + target ? cbm_store_count_edges_by_type(target, target_project, "CROSS_HTTP_CALLS") : -1; + cbm_store_close(source); + cbm_store_close(target); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, source_project); + cleanup_project_db(cache, target_project); + free(source_project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(succeeded); + ASSERT_TRUE(scanned_once); + ASSERT_TRUE(counted_once); + ASSERT_EQ(source_cross_edges, 1); + ASSERT_EQ(target_cross_edges, 1); + PASS(); +} + +/* `name` is the documented index project-name override and must identify the + * cross-repo source too. Deriving from repo_path here makes custom-named + * projects impossible to rescan even though ordinary indexing created them. */ +TEST(tool_cross_repo_honors_source_name_override) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-name-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *source_project = "cross-custom-source"; + const char *target_project = "cross-custom-target"; + ASSERT_TRUE(mcp_cross_repo_seed_http_match(cache, source_project, target_project, cache)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); + char args[CBM_SZ_2K]; + snprintf(args, sizeof(args), + "{\"repo_path\":\"%s\",\"name\":\"%s\"," + "\"mode\":\"cross-repo-intelligence\"," + "\"target_projects\":[\"%s\"]}", + cache, source_project, target_project); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + bool succeeded = resp && !response_contains_json_fragment(resp, "\"isError\":true") && + response_contains_json_fragment(resp, "\"cross_http_calls\":1"); + + free(resp); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, source_project); + cleanup_project_db(cache, target_project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(succeeded); + PASS(); +} + +/* Corrupt-store quarantine renames/unlinks the project DB and sidecars, so it + * is a mutation even when resolve_store() was reached by a query tool. The + * query path needs one balanced lease; manage_adr already owns that project + * lease and must not acquire a nested second lease during the same cleanup. */ +TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-guard-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "guard-corrupt-project"; + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + + ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); + cbm_mcp_server_t *query_srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(query_srv); + mcp_mutation_guard_probe_t query_probe = { + .observed_db_path = db_path, + }; + cbm_mcp_server_set_project_mutation_guard( + query_srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, + &query_probe); + + char *resp = cbm_mcp_handle_tool( + query_srv, "search_graph", + "{\"project\":\"guard-corrupt-project\",\"name_pattern\":\".*\"}"); + free(resp); + cbm_mcp_server_free(query_srv); + char query_backup_path[CBM_SZ_1K]; + int query_backup_count = mcp_find_corrupt_backups( + cache, project, query_backup_path, sizeof(query_backup_path)); + bool query_quarantined = !cbm_file_exists(db_path) && query_backup_count == 1 && + query_backup_path[0] != '\0'; + + /* Replant the same deterministic corruption to exercise manage_adr's + * already-held lease independently from the query server above. */ + mcp_cleanup_corrupt_backups(cache, project); + ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); + cbm_mcp_server_t *adr_srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(adr_srv); + mcp_mutation_guard_probe_t adr_probe = { + .observed_db_path = db_path, + }; + cbm_mcp_server_set_project_mutation_guard( + adr_srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &adr_probe); + resp = cbm_mcp_handle_tool( + adr_srv, "manage_adr", + "{\"project\":\"guard-corrupt-project\",\"mode\":\"get\"}"); + free(resp); + cbm_mcp_server_free(adr_srv); + char adr_backup_path[CBM_SZ_1K]; + int adr_backup_count = mcp_find_corrupt_backups( + cache, project, adr_backup_path, sizeof(adr_backup_path)); + bool adr_quarantined = !cbm_file_exists(db_path) && adr_backup_count == 1 && + adr_backup_path[0] != '\0'; + + mcp_cleanup_corrupt_backups(cache, project); + cleanup_project_db(cache, project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(query_quarantined); + ASSERT_EQ(query_probe.begin_count, 1); + ASSERT_EQ(query_probe.end_count, 1); + ASSERT_STR_EQ(query_probe.begin_projects[0], project); + ASSERT_STR_EQ(query_probe.end_projects[0], project); + ASSERT_TRUE(query_probe.db_exists_at_begin); + ASSERT_FALSE(query_probe.db_exists_at_end); + ASSERT_TRUE(adr_quarantined); + ASSERT_EQ(adr_probe.begin_count, 1); + ASSERT_EQ(adr_probe.end_count, 1); + ASSERT_STR_EQ(adr_probe.begin_projects[0], project); + ASSERT_STR_EQ(adr_probe.end_projects[0], project); + ASSERT_TRUE(adr_probe.db_exists_at_begin); + ASSERT_FALSE(adr_probe.db_exists_at_end); + PASS(); +} + +/* Integrity is checked before the lease is requested, but quarantine itself + * must fail closed when that lease is denied. In particular, a rejected query + * may not remove either a recoverable DB generation or its committed WAL. */ +TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-denied-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "guard-corrupt-denied"; + char db_path[CBM_SZ_1K]; + char wal_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); + cbm_store_t *writer = mcp_open_corrupt_project_store_with_wal(cache, project); + ASSERT_NOT_NULL(writer); + ASSERT_TRUE(cbm_file_exists(db_path)); + ASSERT_TRUE(cbm_file_exists(wal_path)); + + long db_len = 0; + long wal_len = 0; + unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len); + unsigned char *wal_before = mcp_read_file_bytes(wal_path, &wal_len); + ASSERT_NOT_NULL(db_before); + ASSERT_NOT_NULL(wal_before); + ASSERT_TRUE(db_len > 0); + ASSERT_TRUE(wal_len > 0); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + char *resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"guard-corrupt-denied\",\"name_pattern\":\".*\"}"); + + bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); + bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); + char unexpected_backup[CBM_SZ_1K]; + int backup_count = + mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); + int artifact_count = mcp_count_corrupt_artifacts(cache, project); + int begin_count = probe.begin_count; + int end_count = probe.end_count; + bool guarded_project = begin_count == 1 && + strcmp(probe.begin_projects[0], project) == 0; + + free(resp); + cbm_mcp_server_free(srv); + free(db_before); + free(wal_before); + cbm_store_close(writer); + mcp_cleanup_corrupt_backups(cache, project); + cleanup_project_db(cache, project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_EQ(begin_count, 1); + ASSERT_EQ(end_count, 0); + ASSERT_TRUE(guarded_project); + ASSERT_TRUE(db_unchanged); + ASSERT_TRUE(wal_unchanged); + ASSERT_EQ(backup_count, 0); + ASSERT_EQ(artifact_count, 0); + PASS(); +} + +/* Another session may publish a good generation while this query waits for + * the mutation lease. Cleanup must re-open and re-check the path after lease + * acquisition; quarantining based on the stale pre-wait handle loses the new + * generation and returns a false "not indexed" result. */ +TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-recheck-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "guard-corrupt-recheck"; + const char *replacement_root = "/tmp/guard-corrupt-replacement"; + char db_path[CBM_SZ_1K]; + char replacement_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(replacement_path, sizeof(replacement_path), "%s/%s.replacement.db", cache, + project); + ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); + ASSERT_TRUE( + mcp_make_valid_project_store_at(replacement_path, project, replacement_root)); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_replacing_mutation_guard_t replacement = { + .replacement_path = replacement_path, + .live_path = db_path, + }; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_replacing_mutation_guard_begin, mcp_replacing_mutation_guard_end, + &replacement); + char *resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"guard-corrupt-recheck\",\"name_pattern\":\".*\"}"); + bool response_used_replacement = + resp && !response_contains_json_fragment(resp, "\"isError\":true"); + free(resp); + cbm_mcp_server_free(srv); + + cbm_store_t *check = cbm_store_open_path_query(db_path); + bool valid_generation = check && cbm_store_check_integrity(check); + cbm_project_t stored_project = {0}; + bool replacement_root_visible = + check && cbm_store_get_project(check, project, &stored_project) == CBM_STORE_OK && + stored_project.root_path && strcmp(stored_project.root_path, replacement_root) == 0; + cbm_project_free_fields(&stored_project); + cbm_store_close(check); + char unexpected_backup[CBM_SZ_1K]; + int backup_count = + mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); + bool live_exists = cbm_file_exists(db_path); + bool replacement_consumed = !cbm_file_exists(replacement_path); + int begin_count = replacement.guard.begin_count; + int end_count = replacement.guard.end_count; + bool guarded_project = + begin_count == 1 && end_count == 1 && + strcmp(replacement.guard.begin_projects[0], project) == 0 && + strcmp(replacement.guard.end_projects[0], project) == 0; + bool replacement_attempted = replacement.replacement_attempted; + bool replacement_succeeded = replacement.replacement_succeeded; + + mcp_cleanup_corrupt_backups(cache, project); + cleanup_project_db(cache, project); + cbm_unlink(replacement_path); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(replacement_attempted); + ASSERT_TRUE(replacement_succeeded); + ASSERT_TRUE(guarded_project); + ASSERT_TRUE(response_used_replacement); + ASSERT_TRUE(live_exists); + ASSERT_TRUE(replacement_consumed); + ASSERT_TRUE(valid_generation); + ASSERT_TRUE(replacement_root_visible); + ASSERT_EQ(backup_count, 0); + PASS(); +} + +/* A fixed `.corrupt` destination is itself user recovery data. A later + * quarantine must retain it byte-for-byte and choose a distinct backup name + * rather than unlinking the previous incident before rename. */ +TEST(tool_corrupt_store_cleanup_preserves_existing_backup_and_uses_unique_name) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-unique-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "guard-corrupt-unique"; + char db_path[CBM_SZ_1K]; + char existing_backup_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(existing_backup_path, sizeof(existing_backup_path), "%s.corrupt", db_path); + ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); + ASSERT_EQ(th_write_file(existing_backup_path, "previous-backup-must-survive\n"), 0); + + long existing_len = 0; + unsigned char *existing_before = + mcp_read_file_bytes(existing_backup_path, &existing_len); + ASSERT_NOT_NULL(existing_before); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t probe = {0}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + char *resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"guard-corrupt-unique\",\"name_pattern\":\".*\"}"); + free(resp); + cbm_mcp_server_free(srv); + + bool existing_unchanged = + mcp_file_matches_snapshot(existing_backup_path, existing_before, existing_len); + free(existing_before); + char unique_backup_path[CBM_SZ_1K]; + int backup_count = mcp_find_corrupt_backups(cache, project, unique_backup_path, + sizeof(unique_backup_path)); + cbm_store_t *quarantined = unique_backup_path[0] + ? cbm_store_open_path_query(unique_backup_path) + : NULL; + bool unique_backup_is_corrupt = + quarantined && !cbm_store_check_integrity(quarantined); + cbm_store_close(quarantined); + bool live_removed = !cbm_file_exists(db_path); + int begin_count = probe.begin_count; + int end_count = probe.end_count; + bool guarded_project = + begin_count == 1 && end_count == 1 && + strcmp(probe.begin_projects[0], project) == 0 && + strcmp(probe.end_projects[0], project) == 0; + + mcp_cleanup_corrupt_backups(cache, project); + cleanup_project_db(cache, project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(guarded_project); + ASSERT_TRUE(existing_unchanged); + ASSERT_EQ(backup_count, 2); + ASSERT_TRUE(unique_backup_path[0] != '\0'); + ASSERT_TRUE(unique_backup_is_corrupt); + ASSERT_TRUE(live_removed); + PASS(); +} + +/* Deterministically fail immediately before atomic snapshot publication on + * every platform. The incomplete pending copy must be removed while the live + * DB and its committed WAL remain byte-for-byte untouched. */ +TEST(tool_corrupt_store_cleanup_publish_failure_preserves_db_and_wal) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-publish-fail-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "guard-corrupt-publish-fail"; + char db_path[CBM_SZ_1K]; + char wal_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); + cbm_store_t *writer = mcp_open_corrupt_project_store_with_wal(cache, project); + ASSERT_NOT_NULL(writer); + ASSERT_TRUE(cbm_file_exists(wal_path)); + + long db_len = 0; + long wal_len = 0; + unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len); + unsigned char *wal_before = mcp_read_file_bytes(wal_path, &wal_len); + ASSERT_NOT_NULL(db_before); + ASSERT_NOT_NULL(wal_before); + ASSERT_TRUE(db_len > 0); + ASSERT_TRUE(wal_len > 0); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); - cbm_store_t *st = cbm_mcp_server_store(srv); - ASSERT_NOT_NULL(st); - cbm_store_upsert_project(st, "adr-unify", "/tmp/adr-unify"); - cbm_mcp_server_set_project(srv, "adr-unify"); + mcp_mutation_guard_probe_t guard = {0}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &guard); + mcp_quarantine_hook_probe_t hook = {.deny_step = "before_snapshot_publish"}; + cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook); + char *resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"guard-corrupt-publish-fail\",\"name_pattern\":\".*\"}"); + + bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); + bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); + char unexpected_backup[CBM_SZ_1K]; + int backup_count = + mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup)); + int artifact_count = mcp_count_corrupt_artifacts(cache, project); + int begin_count = guard.begin_count; + int end_count = guard.end_count; + bool guarded_project = + begin_count == 1 && end_count == 1 && + strcmp(guard.begin_projects[0], project) == 0 && + strcmp(guard.end_projects[0], project) == 0; + bool failed_at_publish = hook.call_count == 1 && + strcmp(hook.steps[0], "before_snapshot_publish") == 0; - /* Write via the MCP tool. */ - char *resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":120,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"manage_adr\",\"arguments\":{\"project\":\"adr-unify\"," - "\"mode\":\"update\",\"content\":\"## PURPOSE\\nUnified ADR backend.\\n\"}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "updated")); free(resp); + cbm_mcp_server_free(srv); + free(db_before); + free(wal_before); + cbm_store_close(writer); + mcp_cleanup_corrupt_backups(cache, project); + cleanup_project_db(cache, project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); - /* Read DIRECTLY via the store API the UI /api/adr uses — must see it. */ - cbm_adr_t adr; - memset(&adr, 0, sizeof(adr)); - ASSERT_EQ(cbm_store_adr_get(st, "adr-unify", &adr), CBM_STORE_OK); - ASSERT_NOT_NULL(adr.content); - ASSERT_NOT_NULL(strstr(adr.content, "Unified ADR backend.")); - cbm_store_adr_free(&adr); + ASSERT_TRUE(failed_at_publish); + ASSERT_TRUE(guarded_project); + ASSERT_TRUE(db_unchanged); + ASSERT_TRUE(wal_unchanged); + ASSERT_EQ(backup_count, 0); + ASSERT_EQ(artifact_count, 0); + PASS(); +} - /* And manage_adr(get) round-trips the same content. */ - resp = cbm_mcp_server_handle( - srv, "{\"jsonrpc\":\"2.0\",\"id\":121,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"manage_adr\",\"arguments\":{\"project\":\"adr-unify\"," - "\"mode\":\"get\"}}}"); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "Unified ADR backend.")); - ASSERT_NULL(strstr(resp, "\"isError\":true")); - free(resp); +/* Once the recovery snapshot is atomically visible, a crash/failure before + * deleting the live generation may leave both copies. The live DB/WAL must be + * unchanged, and the published backup must already contain committed WAL data + * as one self-contained SQLite database. */ +TEST(tool_corrupt_store_cleanup_publishes_complete_wal_snapshot_before_delete) { + char cache[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-after-publish-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(cache)); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + const char *project = "guard-corrupt-after-publish"; + char db_path[CBM_SZ_1K]; + char wal_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path); + cbm_store_t *writer = mcp_open_corrupt_project_store_with_wal(cache, project); + ASSERT_NOT_NULL(writer); + ASSERT_TRUE(cbm_file_exists(wal_path)); + + long db_len = 0; + long wal_len = 0; + unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len); + unsigned char *wal_before = mcp_read_file_bytes(wal_path, &wal_len); + ASSERT_NOT_NULL(db_before); + ASSERT_NOT_NULL(wal_before); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + mcp_mutation_guard_probe_t guard = {0}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &guard); + mcp_quarantine_hook_probe_t hook = {.deny_step = "after_snapshot_publish"}; + cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook); + char *resp = cbm_mcp_handle_tool( + srv, "search_graph", + "{\"project\":\"guard-corrupt-after-publish\",\"name_pattern\":\".*\"}"); + + bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); + bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); + char backup_path[CBM_SZ_1K]; + int backup_count = + mcp_find_corrupt_backups(cache, project, backup_path, sizeof(backup_path)); + int artifact_count = mcp_count_corrupt_artifacts(cache, project); + cbm_store_t *snapshot = backup_path[0] ? cbm_store_open_path_query(backup_path) : NULL; + cbm_project_t recovered = {0}; + bool recovered_wal_project = + snapshot && cbm_store_get_project(snapshot, project, &recovered) == CBM_STORE_OK && + recovered.root_path && strcmp(recovered.root_path, "826") == 0; + cbm_project_free_fields(&recovered); + cbm_store_close(snapshot); + char backup_wal[CBM_SZ_2K]; + char backup_shm[CBM_SZ_2K]; + snprintf(backup_wal, sizeof(backup_wal), "%s-wal", backup_path); + snprintf(backup_shm, sizeof(backup_shm), "%s-shm", backup_path); + bool snapshot_self_contained = !cbm_file_exists(backup_wal) && + !cbm_file_exists(backup_shm); + bool hook_order = hook.call_count == 2 && + strcmp(hook.steps[0], "before_snapshot_publish") == 0 && + strcmp(hook.steps[1], "after_snapshot_publish") == 0; + bool guard_balanced = guard.begin_count == 1 && guard.end_count == 1 && + strcmp(guard.begin_projects[0], project) == 0 && + strcmp(guard.end_projects[0], project) == 0; + free(resp); cbm_mcp_server_free(srv); + free(db_before); + free(wal_before); + cbm_store_close(writer); + mcp_cleanup_corrupt_backups(cache, project); + cleanup_project_db(cache, project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + cbm_rmdir(cache); + + ASSERT_TRUE(hook_order); + ASSERT_TRUE(guard_balanced); + ASSERT_TRUE(db_unchanged); + ASSERT_TRUE(wal_unchanged); + ASSERT_EQ(backup_count, 1); + ASSERT_EQ(artifact_count, 1); + ASSERT_TRUE(recovered_wal_project); + ASSERT_TRUE(snapshot_self_contained); PASS(); } @@ -4763,12 +6206,10 @@ TEST(tool_resolve_store_by_internal_name_issue704) { } char a_path[700]; snprintf(a_path, sizeof(a_path), "%s/alpha704.db", cache); - char corrupt_path[720]; - snprintf(corrupt_path, sizeof(corrupt_path), "%s.corrupt", ghost_path); cbm_unlink(a_path); cbm_unlink(gamma_path); cbm_unlink(ghost_path); - cbm_unlink(corrupt_path); /* ghost may be quarantined by resolve_store */ + mcp_cleanup_corrupt_backups(cache, "ghost704"); char side[740]; snprintf(side, sizeof(side), "%s-wal", a_path); cbm_unlink(side); @@ -4858,7 +6299,7 @@ TEST(tool_list_projects_ignores_missed_shadow_issue1044) { } /* ══════════════════════════════════════════════════════════════════ - * QUERY STORE READ-ONLY (data-integrity reproductions) + * QUERY STORE COHERENCE + READ-ONLY (data-integrity reproductions) * * Bug: query tools resolve the project store via resolve_store() -> * cbm_store_open_path_query(), which opens the DB SQLITE_OPEN_READWRITE @@ -4868,11 +6309,77 @@ TEST(tool_list_projects_ignores_missed_shadow_issue1044) { * (b) query tools FAIL outright on a read-only DB file / filesystem * (the READWRITE open returns CANTOPEN -> resolve_store NULL -> * "project not found"). - * Both tests below are written reproduce-first and are RED on the + * Both read-only tests below are written reproduce-first and are RED on the * unfixed code, GREEN once query opens are READONLY with read-only * pragmas. * ══════════════════════════════════════════════════════════════════ */ +/* Reproduce-first: one MCP session caches a query connection to generation A, + * then the fixture models an independent writer publishing generation B by + * atomically replacing the project DB at the same cache path. Because + * resolve_store() keys its cache only by project name, the next query can reuse + * stale generation A. It must instead return generation B. */ +TEST(query_store_reopens_after_database_replacement) { + static const char project[] = "cbm-store-generation-refresh"; + static const char active_filename[] = "cbm-store-generation-refresh.db"; + static const char staged_filename[] = "cbm-store-generation-next.db"; + + char cache[512]; + snprintf(cache, sizeof(cache), "%s/cbm-store-generation-XXXXXX", cbm_tmpdir()); + bool cache_ready = cbm_mkdtemp(cache) != NULL; + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + if (cache_ready) { + cbm_setenv("CBM_CACHE_DIR", cache, 1); + } + + bool generation_a_ready = + cache_ready && issue704_make_db(cache, active_filename, project, "GenerationA"); + cbm_mcp_server_t *srv = generation_a_ready ? cbm_mcp_server_new(NULL) : NULL; + bool server_ready = srv != NULL; + + char args[512]; + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"name_pattern\":\".*Generation.*\",\"limit\":10}", project); + char *before = srv ? cbm_mcp_handle_tool(srv, "search_graph", args) : NULL; + bool saw_generation_a = before && strstr(before, "GenerationA") != NULL; + + bool generation_b_ready = + cache_ready && issue704_make_db(cache, staged_filename, project, "GenerationB"); + char active_path[700]; + char staged_path[700]; + snprintf(active_path, sizeof(active_path), "%s/%s", cache, active_filename); + snprintf(staged_path, sizeof(staged_path), "%s/%s", cache, staged_filename); + bool replaced = generation_b_ready && cbm_rename_replace(staged_path, active_path) == 0; + + char *after = (srv && replaced) ? cbm_mcp_handle_tool(srv, "search_graph", args) : NULL; + bool saw_generation_b = after && strstr(after, "GenerationB") != NULL; + bool retained_generation_a = after && strstr(after, "GenerationA") != NULL; + + free(before); + free(after); + if (srv) { + cbm_mcp_server_free(srv); + } + if (cache_ready) { + cleanup_project_db(cache, project); + cleanup_project_db(cache, "cbm-store-generation-next"); + cbm_rmdir(cache); + } + restore_cache_dir(saved_copy); + free(saved_copy); + + ASSERT_TRUE(cache_ready); + ASSERT_TRUE(generation_a_ready); + ASSERT_TRUE(server_ready); + ASSERT_TRUE(saw_generation_a); + ASSERT_TRUE(generation_b_ready); + ASSERT_TRUE(replaced); + ASSERT_TRUE(saw_generation_b); + ASSERT_FALSE(retained_generation_a); + PASS(); +} + #define ROQ_PROJECT "cbm-roq-test" /* Whole-file byte snapshot. Returns malloc'd buffer (caller frees) and @@ -5128,6 +6635,7 @@ enum { IDX823_RESPONSE_NAME_MISSING = 64, IDX823_LIST_NAME_MISSING = 65, IDX823_SEARCH_FAILED = 66, + IDX823_PARENT_GUARD_USED = 67, }; #ifndef _WIN32 /* helper used only by the POSIX fork harness below */ @@ -5143,13 +6651,23 @@ static int idx823_supervised_name_override_check(const char *repo_dir, const cha if (!srv) { return IDX823_NO_SERVER; } + /* A supervised local index transfers project-lock ownership to the worker. + * Denying the parent guard is therefore harmless and proves the parent did + * not acquire a lease before spawning. RED on the former ordering, which + * returned "blocked" without ever starting the worker. */ + mcp_mutation_guard_probe_t parent_guard = {.deny_begin_call = 1}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, + &parent_guard); char args[1024]; snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\",\"name\":\"%s\"}", repo_dir, custom_name); char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); int code = IDX823_OK; - if (!resp) { + if (parent_guard.begin_count != 0 || parent_guard.end_count != 0) { + code = IDX823_PARENT_GUARD_USED; + } else if (!resp) { code = IDX823_NO_RESULT; } else if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) { code = IDX823_NOT_INDEXED; @@ -5246,7 +6764,9 @@ TEST(index_repository_cli_name_override_issue823) { if (signalled) { printf(" child killed by signal %d (alarm => worker hang)\n", sig); } else if (code != IDX823_OK) { - printf(" child exit code %d (64=response name, 65=list name, 66=search)\n", code); + printf(" child exit code %d (64=response name, 65=list name, " + "66=search, 67=parent guard used)\n", + code); } ASSERT_FALSE(signalled); ASSERT_EQ(code, IDX823_OK); @@ -5258,6 +6778,38 @@ TEST(index_repository_cli_name_override_issue823) { * #845 — supervisor gate must not wrap embedders of cbm_mcp_handle_tool * ══════════════════════════════════════════════════════════════════ */ +TEST(index_supervisor_unsafe_clean_is_never_fallback_or_recovery) { + char response[] = "{\"status\":\"indexed\"}"; + cbm_index_worker_result_t result = { + .outcome = CBM_PROC_CLEAN, + .exit_code = 0, + .tree_quiesced = true, + .response = response, + }; + ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), CBM_MCP_SUPERVISED_RESULT_SUCCESS); + + result.cancellation_requested = true; + ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), + CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL); + result.cancellation_requested = false; + result.tree_quiesced = false; + ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), + CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL); + result.tree_quiesced = true; + result.supervision_failed = true; + ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), + CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL); + + result.supervision_failed = false; + result.outcome = CBM_PROC_CRASH; + result.response = NULL; + ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), + CBM_MCP_SUPERVISED_RESULT_CONTAINED_FAILURE); + ASSERT_EQ(cbm_mcp_supervised_result_disposition(-1, &result), + CBM_MCP_SUPERVISED_RESULT_FALLBACK); + PASS(); +} + /* Child-side check: index a tiny fixture and verify it ran IN-PROCESS. * Distinct exit codes so the parent can report the exact failure mode. */ enum { @@ -5298,7 +6850,7 @@ TEST(index_supervisor_gate_requires_marked_host_issue845) { * cbm_index_supervisor_mark_host() — i.e. the real binary's main(). Before * the fix, should_wrap() was true for ANY embedder: the gate resolved the * CURRENT binary (this test runner!) and spawned - * ' cli --index-worker index_repository …', which a test binary + * ' cli --index-worker --index-worker-build …', which a test binary * interprets as suite-filter args → it re-runs test suites in the child → * recursive spawn chains (observed 11-min hangs; kernel VM-map load during * the 2026-07-04 host panics). @@ -5386,9 +6938,148 @@ TEST(index_supervisor_gate_requires_marked_host_issue845) { } /* ══════════════════════════════════════════════════════════════════ + * Mandatory supervision must fail closed in real CBM hosts + * ══════════════════════════════════════════════════════════════════ */ + +/* A real CBM host must never turn a supervisor refusal into permission to run + * the native index pipeline in its own long-lived process. The legacy + * CBM_INDEX_SUPERVISOR=0 switch is a deterministic start-failure seam here: on + * the buggy path should_wrap() returned false, the parent mutation guard ran, + * and the project DB was written in-process. The fixed path keeps supervision + * mandatory, returns an error, and leaves both the guard and filesystem + * untouched. Host marking is process-lifetime state, so isolate it in a clean + * re-exec. posix_spawn stays reliable after earlier tests created threads, + * whereas a late raw fork can fail transiently under sanitizers on macOS. */ +enum { + IDXFAILCLOSED_OK = 0, + IDXFAILCLOSED_NO_SERVER = 81, + IDXFAILCLOSED_PARENT_MUTATED = 82, + IDXFAILCLOSED_NO_RESPONSE = 83, + IDXFAILCLOSED_INDEXED = 84, + IDXFAILCLOSED_NOT_ERROR = 85, +}; + +#ifndef _WIN32 +int mcp_test_idxfailclosed_supervisor_start_check(const char *repo_dir, + const char *cache_dir) { + (void)cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); + cbm_index_supervisor_mark_host(); + (void)cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + if (!srv) { + return IDXFAILCLOSED_NO_SERVER; + } + mcp_mutation_guard_probe_t parent_guard = {0}; + cbm_mcp_server_set_project_mutation_guard( + srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &parent_guard); + + char args[CBM_SZ_4K]; + (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", + repo_dir); + char *response = cbm_mcp_handle_tool(srv, "index_repository", args); + + int result = IDXFAILCLOSED_OK; + if (parent_guard.begin_count != 0 || parent_guard.end_count != 0) { + result = IDXFAILCLOSED_PARENT_MUTATED; + } else if (!response) { + result = IDXFAILCLOSED_NO_RESPONSE; + } else if (response_contains_json_fragment(response, "\"status\":\"indexed\"")) { + result = IDXFAILCLOSED_INDEXED; + } else if (!response_contains_json_fragment(response, "\"status\":\"error\"") || + !response_contains_json_fragment(response, "\"outcome\":\"spawn_failed\"")) { + result = IDXFAILCLOSED_NOT_ERROR; + } + + free(response); + cbm_mcp_server_free(srv); + return result; +} + +static bool idxfailclosed_self_path(char out[CBM_SZ_4K]) { +#ifdef __APPLE__ + int length = proc_pidpath(getpid(), out, CBM_SZ_4K); + bool resolved = length > 0 && length < CBM_SZ_4K; + if (resolved) { + out[length] = '\0'; + } + return resolved; +#elif defined(__linux__) + ssize_t length = readlink("/proc/self/exe", out, CBM_SZ_4K - 1); + bool resolved = length > 0 && length < (ssize_t)CBM_SZ_4K - 1; + if (resolved) { + out[length] = '\0'; + } + return resolved; +#else + (void)out; + return false; +#endif +} +#endif + +TEST(index_supervisor_start_failure_is_fail_closed_in_real_host) { +#ifdef _WIN32 + SKIP_PLATFORM("immutable host mark needs fork isolation (POSIX-only)"); +#else + char repo_dir[CBM_SZ_1K]; + char cache_dir[CBM_SZ_1K]; + (void)snprintf(repo_dir, sizeof(repo_dir), "%s/cbm-idx-failclosed-repo-XXXXXX", + cbm_tmpdir()); + (void)snprintf(cache_dir, sizeof(cache_dir), "%s/cbm-idx-failclosed-cache-XXXXXX", + cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(repo_dir)); + ASSERT_NOT_NULL(cbm_mkdtemp(cache_dir)); + + char source_path[CBM_SZ_4K]; + (void)snprintf(source_path, sizeof(source_path), "%s/should_not_index.py", repo_dir); + FILE *source = cbm_fopen(source_path, "wb"); + ASSERT_NOT_NULL(source); + ASSERT_TRUE(fputs("def should_not_index():\n return True\n", source) >= 0); + ASSERT_EQ(fclose(source), 0); + + char *project = cbm_project_name_from_path(repo_dir); + ASSERT_NOT_NULL(project); + char db_path[CBM_SZ_4K]; + (void)snprintf(db_path, sizeof(db_path), "%s/%s.db", cache_dir, project); + + char self_path[CBM_SZ_4K] = {0}; + ASSERT_TRUE(idxfailclosed_self_path(self_path)); + char *const child_argv[] = { + self_path, + "__cbm_mcp_idxfailclosed_probe", + repo_dir, + cache_dir, + NULL, + }; + (void)fflush(NULL); + pid_t child = -1; + ASSERT_EQ(posix_spawn(&child, self_path, NULL, NULL, child_argv, environ), + 0); + ASSERT_TRUE(child > 0); + int status = 0; + ASSERT_EQ(waitpid(child, &status, 0), child); + bool exited = WIFEXITED(status); + int child_result = exited ? WEXITSTATUS(status) : -1; + bool database_absent = !cbm_file_exists(db_path); + + cleanup_project_db(cache_dir, project); + free(project); + (void)cbm_unlink(source_path); + (void)th_rmtree(repo_dir); + (void)th_rmtree(cache_dir); + + ASSERT_TRUE(exited); + ASSERT_EQ(child_result, IDXFAILCLOSED_OK); + ASSERT_TRUE(database_absent); + PASS(); +#endif +} + +/* ═══════════════════════════════════════════════════════════ * #832 — background auto-index + watcher re-index must run in the * supervised worker SUBPROCESS (RSS isolation) - * ══════════════════════════════════════════════════════════════════ */ + * ══════════════════════════════════════════════════════════ */ /* The long-lived server ran the full index pipeline in-process on two background * paths (session auto-index in mcp.c, watcher re-index in main.c). Worker-thread @@ -5734,7 +7425,15 @@ TEST(sequential_service_edge_props_are_valid_json_issue898) { ASSERT_NOT_NULL(strstr(resp, "indexed")); free(resp); - cbm_store_t *store = cbm_mcp_server_store(srv); + /* File-backed MCP stores are deliberately request-scoped so a sibling + * process can atomically replace the DB generation (and so Windows does + * not retain a replacement-blocking handle). Inspect the published DB + * through an independent query handle instead of relying on srv->store. */ + char *project = cbm_project_name_from_path(tmp); + ASSERT_NOT_NULL(project); + char db_path[CBM_SZ_512]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); + cbm_store_t *store = cbm_store_open_path_query(db_path); ASSERT_NOT_NULL(store); struct sqlite3 *db = cbm_store_get_db(store); ASSERT_NOT_NULL(db); @@ -5783,14 +7482,14 @@ TEST(sequential_service_edge_props_are_valid_json_issue898) { sqlite3_finalize(stmt); ASSERT_TRUE(brokered >= 1); + cbm_store_close(store); cbm_mcp_server_free(srv); - char *project = cbm_project_name_from_path(tmp); cleanup_project_db(cache, project); - free(project); restore_cache_dir(saved_cache_copy); free(saved_cache_copy); + free(project); th_rmtree(cache); - unlink(src_path); + cbm_unlink(src_path); cbm_rmdir(tmp); PASS(); } @@ -6237,7 +7936,64 @@ extern bool cbm_path_within_root(const char *root_path, const char *abs_path); TEST(mcp_path_within_root_rejects_escape) { #ifdef _WIN32 - SKIP_PLATFORM("POSIX realpath repro; the Windows _fullpath branch is the same guard"); + char root[512]; + char outside[512]; + snprintf(root, sizeof(root), "%s/cbm_pwr_root_XXXXXX", cbm_tmpdir()); + snprintf(outside, sizeof(outside), "%s/cbm_pwr_outside_XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(root)); + ASSERT_NOT_NULL(cbm_mkdtemp(outside)); + + char inside[700]; + char target[700]; + char junction[700]; + char linked_target[900]; + snprintf(inside, sizeof(inside), "%s/inside.c", root); + snprintf(target, sizeof(target), "%s/outside.c", outside); + snprintf(junction, sizeof(junction), "%s/escape", root); + snprintf(linked_target, sizeof(linked_target), "%s/outside.c", junction); + FILE *fp = cbm_fopen(inside, "w"); + ASSERT_NOT_NULL(fp); + fputs("int inside;\n", fp); + fclose(fp); + fp = cbm_fopen(target, "w"); + ASSERT_NOT_NULL(fp); + fputs("int outside;\n", fp); + fclose(fp); + + const char *junction_argv[] = {"cmd.exe", "/d", "/c", "mklink", "/J", junction, outside, NULL}; + bool linked = cbm_exec_no_shell(junction_argv) == 0; + + ASSERT_TRUE(linked); + ASSERT_TRUE(cbm_path_within_root(root, inside)); + ASSERT_FALSE(cbm_path_within_root(root, target)); + ASSERT_FALSE(cbm_path_within_root(root, linked_target)); + + char case_alias[sizeof(root)]; + snprintf(case_alias, sizeof(case_alias), "%s", root); + char *leaf = strrchr(case_alias, '/'); + char *backslash_leaf = strrchr(case_alias, '\\'); + if (!leaf || (backslash_leaf && backslash_leaf > leaf)) { + leaf = backslash_leaf; + } + leaf = leaf ? leaf + 1 : case_alias; + if (*leaf >= 'a' && *leaf <= 'z') { + *leaf = (char)(*leaf - 'a' + 'A'); + } else if (*leaf >= 'A' && *leaf <= 'Z') { + *leaf = (char)(*leaf - 'A' + 'a'); + } + ASSERT_TRUE(cbm_path_within_root(case_alias, inside)); + + char drive_root[] = {root[0], ':', '\\', '\0'}; + ASSERT_TRUE(((root[0] >= 'A' && root[0] <= 'Z') || (root[0] >= 'a' && root[0] <= 'z')) && + root[1] == ':'); + ASSERT_TRUE(cbm_path_within_root(drive_root, inside)); + + cbm_rmdir(junction); + cbm_unlink(inside); + cbm_unlink(target); + cbm_rmdir(root); + cbm_rmdir(outside); + PASS(); #else char root[512]; snprintf(root, sizeof(root), "%s/cbm_pwr_XXXXXX", cbm_tmpdir()); @@ -6259,6 +8015,7 @@ TEST(mcp_path_within_root_rejects_escape) { ASSERT_TRUE(cbm_path_within_root(root, inside)); ASSERT_FALSE(cbm_path_within_root(root, escape)); ASSERT_FALSE(cbm_path_within_root(root, "/etc/hosts")); + ASSERT_TRUE(cbm_path_within_root("/", "/etc/hosts")); remove(inside); cbm_rmdir(root); @@ -6312,6 +8069,257 @@ TEST(index_repository_honors_allowed_root) { PASS(); } +TEST(index_repository_relative_path_uses_explicit_session_root) { + char session_root[512]; + char cache[512]; + snprintf(session_root, sizeof(session_root), "%s/cbm_daemon_session_XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm_daemon_cache_XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(session_root) || !cbm_mkdtemp(cache)) { + th_rmtree(session_root); + th_rmtree(cache); + FAIL("cbm_mkdtemp failed"); + } + + char repo[1024]; + char source[1200]; + snprintf(repo, sizeof(repo), "%s/repo", session_root); + snprintf(source, sizeof(source), "%s/main.py", repo); + ASSERT_EQ(th_write_file(source, "def main():\n return 1\n"), 0); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + const char *saved_supervisor = getenv("CBM_INDEX_SUPERVISOR"); + char *saved_supervisor_copy = saved_supervisor ? strdup(saved_supervisor) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + bool context_set = srv && cbm_mcp_server_set_session_context(srv, session_root, session_root); + const char request[] = "{\"jsonrpc\":\"2.0\",\"id\":89,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\"," + "\"arguments\":{\"repo_path\":\"repo\",\"mode\":\"fast\"}}}"; + char *response = context_set ? cbm_mcp_server_handle(srv, request) : NULL; + bool accepted = response && strstr(response, "outside the allowed root") == NULL && + strstr(response, "\"isError\":true") == NULL; + + char *project = cbm_project_name_from_path(repo); + char db_path[CBM_SZ_4K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project ? project : "missing"); + bool indexed_session_repo = project && cbm_file_size(db_path) >= 0; + + free(response); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + free(project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + if (saved_supervisor_copy) { + cbm_setenv("CBM_INDEX_SUPERVISOR", saved_supervisor_copy, 1); + } else { + cbm_unsetenv("CBM_INDEX_SUPERVISOR"); + } + free(saved_supervisor_copy); + th_rmtree(session_root); + th_rmtree(cache); + + ASSERT_TRUE(context_set); + ASSERT_TRUE(accepted); + ASSERT_TRUE(indexed_session_repo); + PASS(); +} + +/* A daemon-backed session validates repo_path against its own session root, but + * the supervised worker is a fresh process that inherits the daemon's cwd. A + * relative path must therefore be resolved once by the session and forwarded to + * the worker as that same canonical absolute path. The decoy repo makes an + * unsanitized handoff observable: forwarding the original "repo" indexes the + * cwd-relative decoy instead of the validated session repo. */ +enum { + IDXCANON_OK = 0, + IDXCANON_GETCWD_FAILED = 71, + IDXCANON_CHDIR_FAILED = 72, + IDXCANON_NO_SERVER = 73, + IDXCANON_CONTEXT_FAILED = 74, + IDXCANON_NO_SPAWN = 75, + IDXCANON_NO_RESULT = 76, + IDXCANON_NOT_INDEXED = 77, + IDXCANON_WRONG_PROJECT = 78, + IDXCANON_DECOY_INDEXED = 79, + IDXCANON_TARGET_MISSING = 80, + IDXCANON_CWD_RESTORE_FAILED = 81, +}; + +#ifndef _WIN32 +static int idxcanon_supervised_session_path_check(const char *session_root, const char *decoy_cwd) { + char saved_cwd[CBM_SZ_4K]; + if (!cbm_getcwd(saved_cwd, sizeof(saved_cwd))) { + return IDXCANON_GETCWD_FAILED; + } + if (cbm_chdir(decoy_cwd) != 0) { + return IDXCANON_CHDIR_FAILED; + } + + /* Match a real supervisor host. Environment changes are isolated to this + * forked child and inherited by its worker; the parent test process keeps + * its supervisor kill switch and allowed-root environment untouched. */ + cbm_index_supervisor_mark_host(); + cbm_unsetenv("CBM_INDEX_SUPERVISOR"); + cbm_unsetenv("CBM_ALLOWED_ROOT"); + cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1); + cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1); + + char session_repo[CBM_SZ_4K]; + char decoy_repo[CBM_SZ_4K]; + snprintf(session_repo, sizeof(session_repo), "%s/repo", session_root); + snprintf(decoy_repo, sizeof(decoy_repo), "%s/repo", decoy_cwd); + char *session_project = cbm_project_name_from_path(session_repo); + char *decoy_project = cbm_project_name_from_path(decoy_repo); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + int code = IDXCANON_OK; + if (!srv) { + code = IDXCANON_NO_SERVER; + } else if (!cbm_mcp_server_set_session_context(srv, session_root, session_root)) { + code = IDXCANON_CONTEXT_FAILED; + } + + int spawns_before = cbm_index_supervisor_spawn_count(); + char *resp = code == IDXCANON_OK + ? cbm_mcp_handle_tool(srv, "index_repository", + "{\"repo_path\":\"repo\",\"mode\":\"fast\"}") + : NULL; + int spawns_after = cbm_index_supervisor_spawn_count(); + if (code == IDXCANON_OK && spawns_after == spawns_before) { + code = IDXCANON_NO_SPAWN; + } else if (code == IDXCANON_OK && !resp) { + code = IDXCANON_NO_RESULT; + } else if (code == IDXCANON_OK && + !response_contains_json_fragment(resp, "\"status\":\"indexed\"")) { + code = IDXCANON_NOT_INDEXED; + } + + if (code == IDXCANON_OK) { + char expected[CBM_SZ_4K]; + snprintf(expected, sizeof(expected), "\"project\":\"%s\"", + session_project ? session_project : ""); + if (!session_project || !response_contains_json_fragment(resp, expected)) { + code = IDXCANON_WRONG_PROJECT; + } + } + free(resp); + + /* A raw "repo" handoff is interpreted relative to decoy_cwd by the worker + * and creates this project DB. Its absence proves the original JSON did not + * substitute a different path after the parent validated session_repo. */ + if (code == IDXCANON_OK) { + const char *cache = getenv("CBM_CACHE_DIR"); + char decoy_db[CBM_SZ_4K]; + snprintf(decoy_db, sizeof(decoy_db), "%s/%s.db", cache ? cache : "", + decoy_project ? decoy_project : ""); + if (!cache || !decoy_project || cbm_file_size(decoy_db) >= 0) { + code = IDXCANON_DECOY_INDEXED; + } + } + + if (code == IDXCANON_OK) { + char query[CBM_SZ_4K]; + snprintf(query, sizeof(query), + "{\"project\":\"%s\",\"name_pattern\":\"canonical_target_fn\"," + "\"label\":\"Function\"}", + session_project ? session_project : ""); + char *search = cbm_mcp_handle_tool(srv, "search_graph", query); + if (!session_project || !search || !strstr(search, "canonical_target_fn")) { + code = IDXCANON_TARGET_MISSING; + } + free(search); + } + + cbm_mcp_server_free(srv); + free(session_project); + free(decoy_project); + if (cbm_chdir(saved_cwd) != 0 && code == IDXCANON_OK) { + code = IDXCANON_CWD_RESTORE_FAILED; + } + return code; +} +#endif + +TEST(index_repository_supervisor_uses_canonical_session_path) { +#ifdef _WIN32 + SKIP_PLATFORM("supervisor-host guard needs fork isolation (POSIX-only)"); +#else + char session_root[512]; + char decoy_cwd[512]; + char cache[512]; + snprintf(session_root, sizeof(session_root), "%s/cbm_canonical_session_XXXXXX", cbm_tmpdir()); + snprintf(decoy_cwd, sizeof(decoy_cwd), "%s/cbm_canonical_decoy_XXXXXX", cbm_tmpdir()); + snprintf(cache, sizeof(cache), "%s/cbm_canonical_cache_XXXXXX", cbm_tmpdir()); + if (!cbm_mkdtemp(session_root) || !cbm_mkdtemp(decoy_cwd) || !cbm_mkdtemp(cache)) { + th_rmtree(session_root); + th_rmtree(decoy_cwd); + th_rmtree(cache); + FAIL("cbm_mkdtemp failed"); + } + + char session_source[CBM_SZ_4K]; + char decoy_source[CBM_SZ_4K]; + snprintf(session_source, sizeof(session_source), "%s/repo/main.py", session_root); + snprintf(decoy_source, sizeof(decoy_source), "%s/repo/main.py", decoy_cwd); + ASSERT_EQ(th_write_file(session_source, "def canonical_target_fn():\n return 1\n"), 0); + ASSERT_EQ(th_write_file(decoy_source, "def decoy_fn():\n return 2\n"), 0); + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + int code = -1; + bool signalled = false; + int sig = 0; + fflush(NULL); + pid_t pid = fork(); + if (pid == 0) { + alarm(60); + _exit(idxcanon_supervised_session_path_check(session_root, decoy_cwd)); + } + ASSERT_TRUE(pid > 0); + int status = 0; + (void)waitpid(pid, &status, 0); + if (WIFEXITED(status)) { + code = WEXITSTATUS(status); + } else if (WIFSIGNALED(status)) { + signalled = true; + sig = WTERMSIG(status); + } + + char session_repo[CBM_SZ_4K]; + char decoy_repo[CBM_SZ_4K]; + snprintf(session_repo, sizeof(session_repo), "%s/repo", session_root); + snprintf(decoy_repo, sizeof(decoy_repo), "%s/repo", decoy_cwd); + char *session_project = cbm_project_name_from_path(session_repo); + char *decoy_project = cbm_project_name_from_path(decoy_repo); + cleanup_project_db(cache, session_project); + cleanup_project_db(cache, decoy_project); + free(session_project); + free(decoy_project); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + th_rmtree(session_root); + th_rmtree(decoy_cwd); + th_rmtree(cache); + + if (signalled) { + printf(" child killed by signal %d (alarm => worker hang)\n", sig); + } else if (code != IDXCANON_OK) { + printf(" child exit code %d (75=no spawn, 77=not indexed, 78=wrong project, " + "79=decoy indexed, 80=target missing)\n", + code); + } + ASSERT_FALSE(signalled); + ASSERT_EQ(code, IDXCANON_OK); + PASS(); +#endif +} + /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ @@ -6457,8 +8465,12 @@ SUITE(mcp) { RUN_TEST(tool_manage_adr_unified_backend_issue256); RUN_TEST(tool_index_repository_reports_store_backed_adr); RUN_TEST(tool_index_repository_dot_uses_absolute_project_key_and_preserves_adr); + RUN_TEST(index_repository_relative_path_uses_explicit_session_root); + RUN_TEST(index_repository_supervisor_uses_canonical_session_path); RUN_TEST(index_repository_cli_name_override_issue823); + RUN_TEST(index_supervisor_unsafe_clean_is_never_fallback_or_recovery); RUN_TEST(index_supervisor_gate_requires_marked_host_issue845); + RUN_TEST(index_supervisor_start_failure_is_fail_closed_in_real_host); RUN_TEST(index_bg_paths_route_through_supervisor_issue832); RUN_TEST(sequential_service_edge_props_are_valid_json_issue898); RUN_TEST(index_second_inprocess_run_survives_issue773); @@ -6470,6 +8482,8 @@ SUITE(mcp) { RUN_TEST(tool_ingest_traces_basic); RUN_TEST(tool_ingest_traces_empty); + /* Query store generation freshness */ + RUN_TEST(query_store_reopens_after_database_replacement); /* Query store read-only (data integrity) */ RUN_TEST(readonly_query_does_not_mutate_db); RUN_TEST(readonly_query_succeeds_on_readonly_fs); @@ -6523,3 +8537,25 @@ SUITE(mcp) { RUN_TEST(mcp_auto_watch_false_skips_watcher_on_connect); RUN_TEST(mcp_auto_watch_false_skips_supervised_autoindex_issue853); } + +/* Kept separate so daemon-coordination regressions can be iterated without + * running the much larger MCP behavior suite. */ +SUITE(mcp_mutation_guard) { + RUN_TEST(tool_delete_project_mutation_guard_blocks_then_releases); + RUN_TEST(tool_index_repository_mutation_guard_blocks_before_local_worker); + RUN_TEST(tool_manage_adr_mutation_guard_balances_success); + RUN_TEST(tool_manage_adr_mutation_guard_releases_on_missing_store); + RUN_TEST(tool_cross_repo_mutation_guard_sorts_dedupes_and_unwinds); + RUN_TEST(tool_cross_repo_mutation_guard_casefolds_aliases_and_order); + RUN_TEST(tool_cross_repo_rejects_wildcard_mixed_with_named_targets); + RUN_TEST(tool_cross_repo_checks_cancellation_after_acquiring_leases); + RUN_TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases); + RUN_TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting); + RUN_TEST(tool_cross_repo_honors_source_name_override); + RUN_TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested); + RUN_TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal); + RUN_TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait); + RUN_TEST(tool_corrupt_store_cleanup_preserves_existing_backup_and_uses_unique_name); + RUN_TEST(tool_corrupt_store_cleanup_publish_failure_preserves_db_and_wal); + RUN_TEST(tool_corrupt_store_cleanup_publishes_complete_wal_snapshot_before_delete); +} diff --git a/tests/test_mem.c b/tests/test_mem.c index 1acb4c1e4..7e67452f9 100644 --- a/tests/test_mem.c +++ b/tests/test_mem.c @@ -464,6 +464,23 @@ TEST(resolve_budget_override_when_total_unknown) { PASS(); } +TEST(resolve_budget_worker_cap_preserves_lower_user_override) { + size_t total = 8192 * CBM_TEST_MB; + size_t worker_cap = 16 * CBM_TEST_MB; + cbm_mem_budget_t lower = + cbm_mem_resolve_budget_capped(total, 0.5, "8", worker_cap); + ASSERT_EQ(lower.budget, 8 * CBM_TEST_MB); + ASSERT_STR_EQ(lower.source, "CBM_MEM_BUDGET_MB"); + ASSERT_FALSE(lower.hard_capped); + + cbm_mem_budget_t capped = + cbm_mem_resolve_budget_capped(total, 0.5, "64", worker_cap); + ASSERT_EQ(capped.budget, worker_cap); + ASSERT_STR_EQ(capped.source, "daemon_worker_cap"); + ASSERT_TRUE(capped.hard_capped); + PASS(); +} + TEST(resolve_budget_invalid_override_falls_back) { /* Non-numeric, zero, negative, trailing-garbage, and ERANGE-overflow * overrides are all rejected (invalid=true) → fraction budget, source @@ -1162,6 +1179,7 @@ SUITE(mem) { RUN_TEST(resolve_budget_override_wins); RUN_TEST(resolve_budget_override_clamped_to_total); RUN_TEST(resolve_budget_override_when_total_unknown); + RUN_TEST(resolve_budget_worker_cap_preserves_lower_user_override); RUN_TEST(resolve_budget_invalid_override_falls_back); RUN_TEST(resolve_budget_override_overflow_clamps_to_total); RUN_TEST(resolve_budget_override_overflow_total_unknown_caps); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 4f095f385..285a3cb4d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5549,6 +5549,122 @@ static void cleanup_incremental_repo(void) { th_rmtree(g_incr_tmpdir); } +/* Atomic-publish cancellation seam. Production invokes this hook after the + * staging database is complete, closed, and integrity-valid, but immediately + * before it can replace the last committed database. Keeping the hook on the + * pipeline instance avoids process-global failpoints and makes cancellation + * tests deterministic even when test runners gain concurrency. */ +extern void cbm_pipeline_set_before_publish_hook_for_tests( + cbm_pipeline_t *p, void (*hook)(cbm_pipeline_t *, const char *, void *), void *ctx); + +typedef struct { + const char *project; + const char *candidate; + char staging_path[768]; + int calls; + bool staging_existed; + bool staging_was_valid; + int staged_candidates; +} publish_cancel_ctx_t; + +typedef struct { + char staging_path[CBM_SZ_4K]; + int calls; + bool staging_was_valid; +} publish_observe_ctx_t; + +static void observe_publish_boundary(cbm_pipeline_t *p, const char *staging_path, void *arg) { + (void)p; + publish_observe_ctx_t *ctx = (publish_observe_ctx_t *)arg; + ctx->calls++; + if (!staging_path) { + return; + } + int n = snprintf(ctx->staging_path, sizeof(ctx->staging_path), "%s", staging_path); + if (n < 0 || (size_t)n >= sizeof(ctx->staging_path)) { + ctx->staging_path[0] = '\0'; + return; + } + cbm_store_t *staging = cbm_store_open_path_existing(staging_path); + if (staging) { + ctx->staging_was_valid = cbm_store_check_integrity(staging); + cbm_store_close(staging); + } +} + +typedef struct { + int calls; +} publish_rename_fail_ctx_t; + +static int fail_publish_rename(const char *staging_path, const char *final_path, void *arg) { + publish_rename_fail_ctx_t *ctx = (publish_rename_fail_ctx_t *)arg; + ctx->calls++; + return staging_path && final_path ? CBM_NOT_FOUND : 0; +} + +static bool pipeline_fixture_file_equals(const char *path, const char *expected) { + FILE *f = cbm_fopen(path, "rb"); + if (!f) { + return false; + } + size_t expected_len = strlen(expected); + char actual[64]; + size_t n = fread(actual, 1, sizeof(actual), f); + bool ok = n == expected_len && memcmp(actual, expected, expected_len) == 0 && fgetc(f) == EOF; + (void)fclose(f); + return ok; +} + +static void cancel_at_publish_boundary(cbm_pipeline_t *p, const char *staging_path, void *arg) { + publish_cancel_ctx_t *ctx = (publish_cancel_ctx_t *)arg; + ctx->calls++; + if (staging_path && staging_path[0]) { + snprintf(ctx->staging_path, sizeof(ctx->staging_path), "%s", staging_path); + struct stat st; + ctx->staging_existed = stat(staging_path, &st) == 0; + if (ctx->staging_existed) { + cbm_store_t *staging = cbm_store_open_path(staging_path); + if (staging) { + ctx->staging_was_valid = cbm_store_check_integrity(staging); + ctx->staged_candidates = + count_nodes_named(staging, ctx->project, ctx->candidate); + cbm_store_close(staging); + } + } + } + cbm_pipeline_cancel(p); +} + +static bool sqlite_artifacts_absent(const char *db_path) { + struct stat st; + if (!db_path || !db_path[0] || stat(db_path, &st) == 0) { + return false; + } + char sidecar[832]; + snprintf(sidecar, sizeof(sidecar), "%s-wal", db_path); + if (stat(sidecar, &st) == 0) { + return false; + } + snprintf(sidecar, sizeof(sidecar), "%s-shm", db_path); + if (stat(sidecar, &st) == 0) { + return false; + } + snprintf(sidecar, sizeof(sidecar), "%s-journal", db_path); + return stat(sidecar, &st) != 0; +} + +static bool write_go_file(const char *dir, const char *name, const char *source) { + char path[512]; + snprintf(path, sizeof(path), "%s/%s", dir, name); + FILE *f = fopen(path, "w"); + if (!f) { + return false; + } + bool ok = fputs(source, f) >= 0; + fclose(f); + return ok; +} + /* ═══════════════════════════════════════════════════════════════════ * FastAPI Depends() edge tracking (PR #66, fix #27) * ═══════════════════════════════════════════════════════════════════ */ @@ -5807,6 +5923,307 @@ TEST(incremental_new_file_added) { PASS(); } +/* Cancellation at the final publish boundary must never destroy the last good + * full index. Adding two files to the two-file baseline deliberately exceeds + * the incremental router's 1.5x file-count bound (4 > 2 + 2/2), forcing the + * full-reindex path while an existing committed DB is present. */ +TEST(cancelled_full_reindex_preserves_committed_db) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + cbm_store_t *live = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(live); + ASSERT_TRUE(cbm_store_check_integrity(live)); + int baseline_nodes = cbm_store_count_nodes(live, project); + int baseline_helper = count_nodes_named(live, project, "Helper"); + ASSERT_GT(baseline_nodes, 0); + ASSERT_GT(baseline_helper, 0); + ASSERT_EQ(count_nodes_named(live, project, "CandidateFull"), 0); + cbm_store_close(live); + + ASSERT_TRUE(write_go_file(g_incr_tmpdir, "candidate_full.go", + "package main\n\nfunc CandidateFull() int { return 41 }\n")); + ASSERT_TRUE(write_go_file(g_incr_tmpdir, "force_full.go", + "package main\n\nfunc ForceFullRoute() int { return 42 }\n")); + + publish_cancel_ctx_t hook = { + .project = project, + .candidate = "CandidateFull", + }; + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_set_before_publish_hook_for_tests(p, cancel_at_publish_boundary, &hook); + int rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + + live = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(live); + bool live_valid = cbm_store_check_integrity(live); + int nodes_after = cbm_store_count_nodes(live, project); + int helper_after = count_nodes_named(live, project, "Helper"); + int published_candidates = count_nodes_named(live, project, "CandidateFull"); + cbm_store_close(live); + bool staging_cleaned = sqlite_artifacts_absent(hook.staging_path); + + free(project); + cleanup_incremental_repo(); + + ASSERT_EQ(hook.calls, 1); + ASSERT_TRUE(hook.staging_existed); + ASSERT_TRUE(hook.staging_was_valid); + ASSERT_GT(hook.staged_candidates, 0); /* anti-vacuous: new full output was staged */ + ASSERT_EQ(rc, -1); + ASSERT_TRUE(live_valid); + ASSERT_EQ(nodes_after, baseline_nodes); + ASSERT_EQ(helper_after, baseline_helper); + ASSERT_EQ(published_candidates, 0); + ASSERT_TRUE(staging_cleaned); + PASS(); +} + +/* The same contract applies to the incremental path: the modified file is + * present in a complete staging DB at the hook, but cancellation leaves the + * prior committed snapshot queryable and removes every staging artifact. */ +TEST(cancelled_incremental_reindex_preserves_committed_db) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + char *project = strdup(cbm_pipeline_project_name(p)); + cbm_pipeline_free(p); + ASSERT_NOT_NULL(project); + + cbm_store_t *live = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(live); + ASSERT_TRUE(cbm_store_check_integrity(live)); + int baseline_nodes = cbm_store_count_nodes(live, project); + int baseline_helper = count_nodes_named(live, project, "Helper"); + ASSERT_GT(baseline_nodes, 0); + ASSERT_GT(baseline_helper, 0); + ASSERT_EQ(count_nodes_named(live, project, "CandidateIncremental"), 0); + cbm_store_close(live); + + ASSERT_TRUE(write_go_file(g_incr_tmpdir, "helper.go", + "package main\n\n" + "func Helper() string { return \"hello\" }\n\n" + "func CandidateIncremental() int { return 43 }\n")); + + publish_cancel_ctx_t hook = { + .project = project, + .candidate = "CandidateIncremental", + }; + p = cbm_pipeline_new(g_incr_tmpdir, g_incr_dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_set_before_publish_hook_for_tests(p, cancel_at_publish_boundary, &hook); + int rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + + live = cbm_store_open_path(g_incr_dbpath); + ASSERT_NOT_NULL(live); + bool live_valid = cbm_store_check_integrity(live); + int nodes_after = cbm_store_count_nodes(live, project); + int helper_after = count_nodes_named(live, project, "Helper"); + int published_candidates = count_nodes_named(live, project, "CandidateIncremental"); + cbm_store_close(live); + bool staging_cleaned = sqlite_artifacts_absent(hook.staging_path); + + free(project); + cleanup_incremental_repo(); + + ASSERT_EQ(hook.calls, 1); + ASSERT_TRUE(hook.staging_existed); + ASSERT_TRUE(hook.staging_was_valid); + ASSERT_GT(hook.staged_candidates, 0); /* anti-vacuous: changed output was staged */ + ASSERT_EQ(rc, -1); + ASSERT_TRUE(live_valid); + ASSERT_EQ(nodes_after, baseline_nodes); + ASSERT_EQ(helper_after, baseline_helper); + ASSERT_EQ(published_candidates, 0); + ASSERT_TRUE(staging_cleaned); + PASS(); +} + +TEST(backup_failed_publish_failure_preserves_final_sidecars) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + char final_path[512]; + char wal_path[544]; + char shm_path[544]; + char journal_path[544]; + snprintf(final_path, sizeof(final_path), "%s/blocked.db", g_incr_tmpdir); + snprintf(wal_path, sizeof(wal_path), "%s-wal", final_path); + snprintf(shm_path, sizeof(shm_path), "%s-shm", final_path); + snprintf(journal_path, sizeof(journal_path), "%s-journal", final_path); + + /* Invalid SQLite makes backup fail. The live sidecars may contain the only + * recoverable state, so the rebuilt generation must be refused without + * changing any member of the old database family. */ + ASSERT_EQ(th_write_file(final_path, "corrupt-main"), 0); + ASSERT_EQ(th_write_file(wal_path, "live-wal"), 0); + ASSERT_EQ(th_write_file(shm_path, "live-shm"), 0); + ASSERT_EQ(th_write_file(journal_path, "live-journal"), 0); + + publish_observe_ctx_t hook = {0}; + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, final_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_set_before_publish_hook_for_tests(p, observe_publish_boundary, &hook); + int rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + + bool final_preserved = pipeline_fixture_file_equals(final_path, "corrupt-main"); + bool wal_preserved = pipeline_fixture_file_equals(wal_path, "live-wal"); + bool shm_preserved = pipeline_fixture_file_equals(shm_path, "live-shm"); + bool journal_preserved = pipeline_fixture_file_equals(journal_path, "live-journal"); + + (void)cbm_unlink(final_path); + (void)cbm_unlink(wal_path); + (void)cbm_unlink(shm_path); + (void)cbm_unlink(journal_path); + cleanup_incremental_repo(); + + ASSERT_EQ(hook.calls, 1); + ASSERT_TRUE(hook.staging_was_valid); + ASSERT_TRUE(rc != 0); + ASSERT_TRUE(final_preserved); + ASSERT_TRUE(wal_preserved); + ASSERT_TRUE(shm_preserved); + ASSERT_TRUE(journal_preserved); + PASS(); +} + +TEST(backup_failed_rename_failure_preserves_corrupt_main) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + + char final_path[512]; + snprintf(final_path, sizeof(final_path), "%s/corrupt.db", g_incr_tmpdir); + ASSERT_EQ(th_write_file(final_path, "corrupt-main-before-rename"), 0); + + publish_observe_ctx_t observe = {0}; + publish_rename_fail_ctx_t rename_fail = {0}; + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, final_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + cbm_pipeline_set_before_publish_hook_for_tests(p, observe_publish_boundary, &observe); + cbm_pipeline_set_rename_hook_for_tests(p, fail_publish_rename, &rename_fail); + int rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + + bool final_preserved = + pipeline_fixture_file_equals(final_path, "corrupt-main-before-rename"); + (void)cbm_unlink(final_path); + cleanup_incremental_repo(); + + ASSERT_EQ(observe.calls, 1); + ASSERT_TRUE(observe.staging_was_valid); + ASSERT_EQ(rename_fail.calls, 1); + ASSERT_TRUE(rc != 0); + ASSERT_TRUE(final_preserved); + PASS(); +} + +#ifdef __linux__ +static void cleanup_long_db_fixture(char *deep_dir, const char *root, const char *db_path, + const char *staging_path) { + (void)cbm_unlink(db_path); + (void)cbm_remove_db_sidecars(db_path); + + /* The unfixed full-dump path writes to the first 1023 bytes of the + * staging name. Remove that sibling so the RED test cleans up after + * itself as well as the fixed implementation. */ + if (staging_path && strlen(staging_path) >= CBM_SZ_1K) { + char truncated[CBM_SZ_1K]; + memcpy(truncated, staging_path, sizeof(truncated) - 1); + truncated[sizeof(truncated) - 1] = '\0'; + (void)cbm_unlink(truncated); + (void)cbm_remove_db_sidecars(truncated); + } + + size_t root_len = strlen(root); + while (strlen(deep_dir) > root_len) { + (void)cbm_rmdir(deep_dir); + char *slash = strrchr(deep_dir, '/'); + if (!slash) { + break; + } + *slash = '\0'; + } + (void)cbm_rmdir(root); +} + +TEST(full_reindex_preserves_exact_long_db_path) { + if (setup_incremental_repo() != 0) { + FAIL("setup failed"); + } + char *created_root = th_mktempdir("cbm_long_db"); + ASSERT_NOT_NULL(created_root); + char root[256]; + snprintf(root, sizeof(root), "%s", created_root); + + char deep_dir[1600]; + snprintf(deep_dir, sizeof(deep_dir), "%s", root); + static const char component[] = + "/segment_abcdefghijklmnopqrstuvwxyz_ABCDEFGHIJKLMNOPQRSTUVWXYZ_0123456789"; + while (strlen(deep_dir) < 1100) { + size_t used = strlen(deep_dir); + ASSERT_TRUE(used + sizeof(component) < sizeof(deep_dir)); + memcpy(deep_dir + used, component, sizeof(component)); + } + ASSERT_TRUE(cbm_mkdir_p(deep_dir, 0755)); + + char db_path[1600]; + int db_n = snprintf(db_path, sizeof(db_path), "%s/graph.db", deep_dir); + ASSERT_TRUE(db_n > CBM_SZ_1K && (size_t)db_n < sizeof(db_path)); + + publish_observe_ctx_t hook = {0}; + cbm_pipeline_t *p = cbm_pipeline_new(g_incr_tmpdir, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + char *project = strdup(cbm_pipeline_project_name(p)); + ASSERT_NOT_NULL(project); + cbm_pipeline_set_before_publish_hook_for_tests(p, observe_publish_boundary, &hook); + int rc = cbm_pipeline_run(p); + cbm_pipeline_free(p); + + int node_count = -1; + cbm_store_t *published = rc == 0 ? cbm_store_open_path_existing(db_path) : NULL; + if (published) { + node_count = cbm_store_count_nodes(published, project); + cbm_store_close(published); + } + bool stray_truncated_db = false; + if (strlen(hook.staging_path) >= CBM_SZ_1K) { + char truncated[CBM_SZ_1K]; + memcpy(truncated, hook.staging_path, sizeof(truncated) - 1); + truncated[sizeof(truncated) - 1] = '\0'; + stray_truncated_db = access(truncated, F_OK) == 0; + } + + free(project); + cleanup_long_db_fixture(deep_dir, root, db_path, hook.staging_path); + cleanup_incremental_repo(); + + ASSERT_EQ(hook.calls, 1); + ASSERT_TRUE(strlen(hook.staging_path) >= CBM_SZ_1K); + ASSERT_EQ(rc, 0); + ASSERT_GT(node_count, 0); + ASSERT_FALSE(stray_truncated_db); + PASS(); +} +#endif + TEST(incremental_fast_preserves_mode_skipped_tools_dir) { /* Regression: 2026-04-13. A fast-mode reindex after a full-mode index * was silently destroying every file under FAST_SKIP_DIRS directories @@ -7095,6 +7512,13 @@ SUITE(pipeline) { RUN_TEST(incremental_aborts_when_previous_coverage_is_unreadable); RUN_TEST(incremental_detects_deleted_file); RUN_TEST(incremental_new_file_added); + RUN_TEST(cancelled_full_reindex_preserves_committed_db); + RUN_TEST(cancelled_incremental_reindex_preserves_committed_db); + RUN_TEST(backup_failed_publish_failure_preserves_final_sidecars); + RUN_TEST(backup_failed_rename_failure_preserves_corrupt_main); +#ifdef __linux__ + RUN_TEST(full_reindex_preserves_exact_long_db_path); +#endif RUN_TEST(incremental_fast_preserves_mode_skipped_tools_dir); RUN_TEST(incremental_k8s_manifest_indexed); RUN_TEST(incremental_kustomize_module_indexed); diff --git a/tests/test_platform.c b/tests/test_platform.c index 127edd8ab..3add7e33a 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -3,8 +3,11 @@ */ #include "test_framework.h" #include "../src/foundation/compat.h" /* cbm_setenv / cbm_unsetenv (Windows-portable) */ +#include "../src/foundation/compat_thread.h" #include "../src/foundation/platform.h" +#include "../src/foundation/platform_internal.h" #include "../src/foundation/system_info_internal.h" +#include #include #include @@ -18,6 +21,81 @@ #include #endif +enum { PLATFORM_TIME_THREADS = 8 }; + +TEST(platform_counter_scaling_avoids_intermediate_overflow) { + const uint64_t frequency = UINT64_C(10000000); + const uint64_t counter = UINT64_C(20000000000); + + ASSERT(cbm_platform_scale_counter_ns(counter, frequency) == UINT64_C(2000000000000)); + ASSERT(cbm_platform_scale_counter_ns(UINT64_MAX, UINT64_MAX) == UINT64_C(1000000000)); + ASSERT(cbm_platform_scale_counter_ns(UINT64_MAX, 1) == UINT64_MAX); + PASS(); +} + +TEST(platform_counter_scaling_preserves_monotonic_deadlines) { + const uint64_t frequency = UINT64_C(10000000); + const uint64_t counter = UINT64_MAX / UINT64_C(1000000000); + const uint64_t now_ns = cbm_platform_scale_counter_ns(counter, frequency); + const uint64_t next_ns = cbm_platform_scale_counter_ns(counter + 1, frequency); + const uint64_t deadline_ns = cbm_platform_scale_counter_ns(counter + 5 * frequency, frequency); + + ASSERT(next_ns >= now_ns); + ASSERT(deadline_ns > now_ns); + ASSERT(deadline_ns - now_ns == UINT64_C(5000000000)); + PASS(); +} + +typedef struct { + atomic_int *ready; + atomic_bool *go; + uint64_t value; +} platform_time_worker_t; + +static void *platform_time_first_call(void *opaque) { + platform_time_worker_t *worker = opaque; + (void)atomic_fetch_add_explicit(worker->ready, 1, memory_order_acq_rel); + while (!atomic_load_explicit(worker->go, memory_order_acquire)) { + atomic_signal_fence(memory_order_seq_cst); + } + worker->value = cbm_now_ns(); + return NULL; +} + +TEST(platform_now_ns_concurrent_first_call) { + cbm_thread_t threads[PLATFORM_TIME_THREADS]; + platform_time_worker_t workers[PLATFORM_TIME_THREADS]; + atomic_int ready; + atomic_bool go; + atomic_init(&ready, 0); + atomic_init(&go, false); + size_t created = 0; + for (; created < PLATFORM_TIME_THREADS; created++) { + workers[created] = (platform_time_worker_t){ + .ready = &ready, + .go = &go, + }; + if (cbm_thread_create(&threads[created], 0, platform_time_first_call, &workers[created]) != + 0) { + break; + } + } + while (created == PLATFORM_TIME_THREADS && + atomic_load_explicit(&ready, memory_order_acquire) != PLATFORM_TIME_THREADS) { + atomic_signal_fence(memory_order_seq_cst); + } + atomic_store_explicit(&go, true, memory_order_release); + for (size_t index = 0; index < created; index++) { + (void)cbm_thread_join(&threads[index]); + } + + ASSERT_EQ(created, PLATFORM_TIME_THREADS); + for (size_t index = 0; index < created; index++) { + ASSERT_GT(workers[index].value, 0); + } + PASS(); +} + TEST(platform_now_ns) { uint64_t t1 = cbm_now_ns(); ASSERT_GT(t1, 0); @@ -81,6 +159,87 @@ TEST(platform_mmap_nonexistent) { PASS(); } +typedef struct { + const char *home; + const char *cache; + atomic_int *ready; + atomic_int *release; +} platform_path_thread_result_t; + +static void *platform_capture_path_buffers(void *opaque) { + platform_path_thread_result_t *result = opaque; + result->home = cbm_get_home_dir(); + result->cache = cbm_resolve_cache_dir(); + atomic_fetch_add_explicit(result->ready, 1, memory_order_release); + while (atomic_load_explicit(result->release, memory_order_acquire) == 0) { + cbm_usleep(1000); + } + return NULL; +} + +/* The daemon dispatches different sessions concurrently. Path helpers may + * retain their historical return-pointer API, but each live thread needs + * separate storage; process-global mutable buffers are a C data race. */ +TEST(platform_path_helpers_use_per_thread_storage) { + const char *saved_home = getenv("HOME"); + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_home_copy = saved_home ? strdup(saved_home) : NULL; + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + ASSERT_EQ(cbm_setenv("HOME", "/tmp/cbm-platform-thread-home", 1), 0); + ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", "/tmp/cbm-platform-thread-cache", 1), 0); + + atomic_int ready; + atomic_int release; + atomic_init(&ready, 0); + atomic_init(&release, 0); + platform_path_thread_result_t results[2] = { + {.ready = &ready, .release = &release}, + {.ready = &ready, .release = &release}, + }; + cbm_thread_t threads[2]; + bool started0 = cbm_thread_create(&threads[0], 0, platform_capture_path_buffers, + &results[0]) == 0; + bool started1 = cbm_thread_create(&threads[1], 0, platform_capture_path_buffers, + &results[1]) == 0; + for (int spins = 0; started0 && started1 && spins < 5000 && + atomic_load_explicit(&ready, memory_order_acquire) < 2; + spins++) { + cbm_usleep(1000); + } + bool both_ready = atomic_load_explicit(&ready, memory_order_acquire) == 2; + bool separate_home = both_ready && results[0].home && results[1].home && + results[0].home != results[1].home; + bool separate_cache = both_ready && results[0].cache && results[1].cache && + results[0].cache != results[1].cache; + atomic_store_explicit(&release, 1, memory_order_release); + if (started0) { + (void)cbm_thread_join(&threads[0]); + } + if (started1) { + (void)cbm_thread_join(&threads[1]); + } + + if (saved_home_copy) { + (void)cbm_setenv("HOME", saved_home_copy, 1); + } else { + (void)cbm_unsetenv("HOME"); + } + if (saved_cache_copy) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_cache_copy, 1); + } else { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(saved_home_copy); + free(saved_cache_copy); + + ASSERT_TRUE(started0); + ASSERT_TRUE(started1); + ASSERT_TRUE(both_ready); + ASSERT_TRUE(separate_home); + ASSERT_TRUE(separate_cache); + PASS(); +} + /* * CBM_WORKERS env override for cbm_default_worker_count. * @@ -312,6 +471,9 @@ TEST(cgroup_no_mem_files) { #endif /* __linux__ */ SUITE(platform) { + RUN_TEST(platform_counter_scaling_avoids_intermediate_overflow); + RUN_TEST(platform_counter_scaling_preserves_monotonic_deadlines); + RUN_TEST(platform_now_ns_concurrent_first_call); RUN_TEST(platform_now_ns); RUN_TEST(platform_now_ms); RUN_TEST(platform_nprocs); @@ -320,6 +482,7 @@ SUITE(platform) { RUN_TEST(platform_file_size); RUN_TEST(platform_mmap); RUN_TEST(platform_mmap_nonexistent); + RUN_TEST(platform_path_helpers_use_per_thread_storage); RUN_TEST(platform_default_workers_env_override); RUN_TEST(platform_default_workers_env_invalid); RUN_TEST(platform_default_workers_env_unset); diff --git a/tests/test_private_file_lock.c b/tests/test_private_file_lock.c new file mode 100644 index 000000000..a1e569d3a --- /dev/null +++ b/tests/test_private_file_lock.c @@ -0,0 +1,888 @@ +/* RED contract for handle-anchored owner-only SH/EX lock files. */ +#include "test_framework.h" + +#include "foundation/compat.h" +#include "foundation/private_file_lock.h" +#include "foundation/private_file_lock_internal.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#include "daemon/ipc.h" +#include "foundation/win_utf8.h" + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#include +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#include +#endif +#endif + +enum { PRIVATE_LOCK_TEST_PATH_CAP = 1024 }; + +#ifdef __APPLE__ +/* Return 1 when installed, 0 only when the filesystem has no extended ACL + * support, and -1 for every other fixture failure. */ +static int private_lock_macos_set_mutating_acl(const char *path) { + errno = 0; + acl_t acl = acl_init(1); + acl_entry_t entry = NULL; + acl_permset_t permissions = NULL; + gid_t foreign_group = getegid() == (gid_t)0 ? (gid_t)1 : (gid_t)0; + uuid_t foreign_group_uuid = {0}; + bool valid = acl && acl_create_entry(&acl, &entry) == 0 && entry && + acl_set_tag_type(entry, ACL_EXTENDED_ALLOW) == 0 && + mbr_gid_to_uuid(foreign_group, foreign_group_uuid) == 0 && + acl_set_qualifier(entry, foreign_group_uuid) == 0 && + acl_get_permset(entry, &permissions) == 0 && permissions && + acl_clear_perms(permissions) == 0 && + acl_add_perm(permissions, ACL_WRITE_DATA) == 0 && + acl_add_perm(permissions, ACL_WRITE_ATTRIBUTES) == 0 && + acl_add_perm(permissions, ACL_DELETE) == 0 && + acl_set_permset(entry, permissions) == 0 && + acl_valid(acl) == 0; + errno = valid ? 0 : errno; + int result = valid ? acl_set_file(path, ACL_TYPE_EXTENDED, acl) : -1; + int saved_error = errno; + if (acl && acl_free(acl) != 0 && result == 0) { + result = -1; + saved_error = errno; + } + if (result == 0) { + return 1; + } + return saved_error == ENOTSUP || saved_error == EOPNOTSUPP ? 0 : -1; +} +#endif + +typedef struct { + char parent[PRIVATE_LOCK_TEST_PATH_CAP]; + char root[PRIVATE_LOCK_TEST_PATH_CAP]; + cbm_private_lock_directory_t *directory; +} private_lock_fixture_t; + +static bool private_lock_fixture_start(private_lock_fixture_t *fixture) { + memset(fixture, 0, sizeof(*fixture)); +#ifdef _WIN32 + int written = snprintf(fixture->parent, sizeof(fixture->parent), "%s/cbm-private-lock-XXXXXX", + cbm_tmpdir()); + if (written <= 0 || written >= (int)sizeof(fixture->parent) || !cbm_mkdtemp(fixture->parent)) { + return false; + } + written = snprintf(fixture->root, sizeof(fixture->root), "%s/root", fixture->parent); + if (written <= 0 || written >= (int)sizeof(fixture->root) || cbm_mkdir(fixture->root) != 0) { + return false; + } + FILE *seed = cbm_daemon_ipc_private_log_open(fixture->root, "seed", 64); + bool seeded = seed && fclose(seed) == 0; + char seed_path[PRIVATE_LOCK_TEST_PATH_CAP]; + written = snprintf(seed_path, sizeof(seed_path), "%s/seed", fixture->root); + wchar_t *wide_seed = + written > 0 && written < (int)sizeof(seed_path) ? cbm_utf8_to_wide(seed_path) : NULL; + if (wide_seed) { + (void)DeleteFileW(wide_seed); + } + free(wide_seed); + wchar_t *wide_root = seeded ? cbm_utf8_to_wide(fixture->root) : NULL; + HANDLE handle = + wide_root ? CreateFileW(wide_root, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL) + : INVALID_HANDLE_VALUE; + free(wide_root); + cbm_private_file_lock_status_t status = + handle != INVALID_HANDLE_VALUE + ? cbm_private_lock_directory_adopt_windows(handle, fixture->root, &fixture->directory) + : CBM_PRIVATE_FILE_LOCK_IO; + if (status != CBM_PRIVATE_FILE_LOCK_OK && handle != INVALID_HANDLE_VALUE) { + (void)CloseHandle(handle); + } + return status == CBM_PRIVATE_FILE_LOCK_OK; +#else + int written = snprintf(fixture->parent, sizeof(fixture->parent), "%s/cbm-private-lock-XXXXXX", + cbm_tmpdir()); + if (written <= 0 || written >= (int)sizeof(fixture->parent) || !cbm_mkdtemp(fixture->parent)) { + return false; + } + written = snprintf(fixture->root, sizeof(fixture->root), "%s/root", fixture->parent); + if (written <= 0 || written >= (int)sizeof(fixture->root) || mkdir(fixture->root, 0700) != 0) { + return false; + } + int fd = open(fixture->root, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + cbm_private_file_lock_status_t status = + cbm_private_lock_directory_adopt_posix(fd, fixture->root, &fixture->directory); + if (status != CBM_PRIVATE_FILE_LOCK_OK) { + if (fd >= 0) { + (void)close(fd); + } + return false; + } + return true; +#endif +} + +static bool private_lock_path(char out[PRIVATE_LOCK_TEST_PATH_CAP], + const private_lock_fixture_t *fixture, const char *base_name) { + int written = snprintf(out, PRIVATE_LOCK_TEST_PATH_CAP, "%s/%s", fixture->root, base_name); + return written > 0 && written < PRIVATE_LOCK_TEST_PATH_CAP; +} + +#ifdef _WIN32 +static void private_lock_windows_remove(const char *path, bool directory) { + wchar_t *wide = cbm_utf8_to_wide(path); + if (wide) { + if (directory) { + (void)RemoveDirectoryW(wide); + } else { + (void)DeleteFileW(wide); + } + } + free(wide); +} +#endif + +static void private_lock_fixture_finish(private_lock_fixture_t *fixture) { + cbm_private_lock_directory_close(fixture->directory); + char path[PRIVATE_LOCK_TEST_PATH_CAP]; + static const char *const files[] = {"matrix.lock", + "payload.lock", + "release-unlock.lock", + "release-close.lock", + "consumed-close.lock", + "post-acquire-cleanup.lock", + "lock-attempt-cleanup.lock", + "acl-directory.lock", + "acl-file.lock", + "fork.lock"}; + if (fixture->root[0]) { + for (size_t index = 0; index < sizeof(files) / sizeof(files[0]); index++) { + if (!private_lock_path(path, fixture, files[index])) { + continue; + } +#ifdef _WIN32 + private_lock_windows_remove(path, false); +#else + (void)unlink(path); +#endif + } + } +#ifdef _WIN32 + if (fixture->root[0]) { + private_lock_windows_remove(fixture->root, true); + } + if (fixture->parent[0]) { + private_lock_windows_remove(fixture->parent, true); + } +#else + if (fixture->root[0]) { + (void)rmdir(fixture->root); + } + if (fixture->parent[0]) { + (void)rmdir(fixture->parent); + } +#endif + memset(fixture, 0, sizeof(*fixture)); +} + +TEST(private_file_lock_payload_requires_exclusive_write_and_survives_reopen) { + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + static const unsigned char payload[] = { + 'C', 'B', 'M', 0, 1, 2, 3, 0xff, + }; + unsigned char readback[sizeof(payload)] = {0}; + size_t readback_length = 0; + cbm_private_file_lock_t *writer = NULL; + cbm_private_file_lock_t *reader = NULL; + cbm_private_file_lock_status_t writer_status = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t write_status = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t reader_status = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t read_status = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t shared_write_status = CBM_PRIVATE_FILE_LOCK_IO; + if (started) { + writer_status = cbm_private_file_lock_try_acquire( + fixture.directory, "payload.lock", CBM_PRIVATE_FILE_LOCK_EX, &writer); + } + if (writer_status == CBM_PRIVATE_FILE_LOCK_OK) { + write_status = cbm_private_file_lock_payload_write( + writer, payload, sizeof(payload)); + (void)cbm_private_file_lock_release(&writer); + reader_status = cbm_private_file_lock_try_acquire( + fixture.directory, "payload.lock", CBM_PRIVATE_FILE_LOCK_SH, &reader); + } + if (reader_status == CBM_PRIVATE_FILE_LOCK_OK) { + read_status = cbm_private_file_lock_payload_read( + reader, readback, sizeof(readback), &readback_length); + shared_write_status = cbm_private_file_lock_payload_write( + reader, payload, sizeof(payload)); + } + if (reader) { + (void)cbm_private_file_lock_release(&reader); + } + if (writer) { + (void)cbm_private_file_lock_release(&writer); + } + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(writer_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(write_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(reader_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(read_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(readback_length, sizeof(payload)); + ASSERT_TRUE(memcmp(readback, payload, sizeof(payload)) == 0); + ASSERT_EQ(shared_write_status, CBM_PRIVATE_FILE_LOCK_UNSAFE); + PASS(); +} + +#ifndef _WIN32 +static bool private_lock_fd_write_byte(int fd, char value) { + ssize_t written; + do { + written = write(fd, &value, 1); + } while (written < 0 && errno == EINTR); + return written == 1; +} + +static bool private_lock_fd_read_byte(int fd, char *value_out) { + ssize_t received; + do { + received = read(fd, value_out, 1); + } while (received < 0 && errno == EINTR); + return received == 1; +} +#endif + +TEST(private_file_lock_shared_and_exclusive_matrix) { + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + cbm_private_file_lock_t *first_reader = NULL; + cbm_private_file_lock_t *second_reader = NULL; + cbm_private_file_lock_t *writer = NULL; + cbm_private_file_lock_status_t first_status = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t second_status = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t blocked_status = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t one_reader_blocked_status = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t writer_status = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t reader_blocked_status = CBM_PRIVATE_FILE_LOCK_IO; + bool stable_file = false; +#ifdef _WIN32 + bool noninheritable = false; + cbm_private_file_lock_status_t device_name_status = CBM_PRIVATE_FILE_LOCK_IO; +#endif + if (started) { + first_status = cbm_private_file_lock_try_acquire(fixture.directory, "matrix.lock", + CBM_PRIVATE_FILE_LOCK_SH, &first_reader); +#ifdef _WIN32 + noninheritable = cbm_private_file_lock_is_cloexec_for_test(first_reader); + cbm_private_file_lock_t *device_lock = NULL; + device_name_status = cbm_private_file_lock_try_acquire( + fixture.directory, "nul.lock", CBM_PRIVATE_FILE_LOCK_EX, &device_lock); + if (device_lock) { + (void)cbm_private_file_lock_release(&device_lock); + } +#endif + second_status = cbm_private_file_lock_try_acquire(fixture.directory, "matrix.lock", + CBM_PRIVATE_FILE_LOCK_SH, &second_reader); + blocked_status = cbm_private_file_lock_try_acquire(fixture.directory, "matrix.lock", + CBM_PRIVATE_FILE_LOCK_EX, &writer); + } + if (second_reader) { + (void)cbm_private_file_lock_release(&second_reader); + } + if (started) { + one_reader_blocked_status = cbm_private_file_lock_try_acquire( + fixture.directory, "matrix.lock", CBM_PRIVATE_FILE_LOCK_EX, &writer); + } + if (first_reader) { + (void)cbm_private_file_lock_release(&first_reader); + } + if (started) { + writer_status = cbm_private_file_lock_try_acquire(fixture.directory, "matrix.lock", + CBM_PRIVATE_FILE_LOCK_EX, &writer); + reader_blocked_status = cbm_private_file_lock_try_acquire( + fixture.directory, "matrix.lock", CBM_PRIVATE_FILE_LOCK_SH, &first_reader); + } + if (first_reader) { + (void)cbm_private_file_lock_release(&first_reader); + } + if (writer) { + (void)cbm_private_file_lock_release(&writer); + } + char matrix_path[PRIVATE_LOCK_TEST_PATH_CAP]; +#ifdef _WIN32 + bool matrix_path_ok = private_lock_path(matrix_path, &fixture, "matrix.lock"); + wchar_t *wide_matrix = matrix_path_ok ? cbm_utf8_to_wide(matrix_path) : NULL; + DWORD matrix_attributes = + wide_matrix ? GetFileAttributesW(wide_matrix) : INVALID_FILE_ATTRIBUTES; + stable_file = + matrix_attributes != INVALID_FILE_ATTRIBUTES && + (matrix_attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0; + free(wide_matrix); +#else + struct stat matrix_status; + stable_file = private_lock_path(matrix_path, &fixture, "matrix.lock") && + lstat(matrix_path, &matrix_status) == 0 && S_ISREG(matrix_status.st_mode); +#endif + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(first_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(second_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(blocked_status, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_EQ(one_reader_blocked_status, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_EQ(writer_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(reader_blocked_status, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_TRUE(stable_file); +#ifdef _WIN32 + ASSERT_TRUE(noninheritable); + ASSERT_EQ(device_name_status, CBM_PRIVATE_FILE_LOCK_UNSAFE); +#endif + PASS(); +} + +TEST(private_file_lock_unlock_failure_retains_retryable_lock) { + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + cbm_private_file_lock_t *lock = NULL; + cbm_private_file_lock_status_t acquired = + started ? cbm_private_file_lock_try_acquire(fixture.directory, "release-unlock.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) + : CBM_PRIVATE_FILE_LOCK_IO; + bool fault_set = cbm_private_file_lock_fail_next_release_step_for_test( + lock, CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK); + cbm_private_file_lock_status_t first_release = + fault_set ? cbm_private_file_lock_release(&lock) : CBM_PRIVATE_FILE_LOCK_IO; + bool retained = lock != NULL; + + cbm_private_file_lock_t *contender = NULL; + cbm_private_file_lock_status_t while_failed = + started ? cbm_private_file_lock_try_acquire(fixture.directory, "release-unlock.lock", + CBM_PRIVATE_FILE_LOCK_EX, &contender) + : CBM_PRIVATE_FILE_LOCK_IO; + if (contender) { + (void)cbm_private_file_lock_release(&contender); + } + cbm_private_file_lock_status_t retry = + lock ? cbm_private_file_lock_release(&lock) : CBM_PRIVATE_FILE_LOCK_IO; + if (lock) { + (void)cbm_private_file_lock_release(&lock); + } + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(acquired, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(fault_set); + ASSERT_EQ(first_release, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(retained); + ASSERT_EQ(while_failed, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_EQ(retry, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(lock); + PASS(); +} + +TEST(private_file_lock_close_failure_retries_without_duplicate_unlock) { + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + cbm_private_file_lock_t *lock = NULL; + cbm_private_file_lock_status_t acquired = + started ? cbm_private_file_lock_try_acquire(fixture.directory, "release-close.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) + : CBM_PRIVATE_FILE_LOCK_IO; + bool close_fault_set = cbm_private_file_lock_fail_next_release_step_for_test( + lock, CBM_PRIVATE_FILE_LOCK_RELEASE_CLOSE); + cbm_private_file_lock_status_t first_release = + close_fault_set ? cbm_private_file_lock_release(&lock) : CBM_PRIVATE_FILE_LOCK_IO; + bool retained = lock != NULL; + unsigned int unlock_attempts = cbm_private_file_lock_release_step_attempts_for_test( + lock, CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK); + unsigned int close_attempts = cbm_private_file_lock_release_step_attempts_for_test( + lock, CBM_PRIVATE_FILE_LOCK_RELEASE_CLOSE); + + cbm_private_file_lock_t *contender = NULL; + cbm_private_file_lock_status_t after_unlock = + started ? cbm_private_file_lock_try_acquire(fixture.directory, "release-close.lock", + CBM_PRIVATE_FILE_LOCK_EX, &contender) + : CBM_PRIVATE_FILE_LOCK_IO; + if (contender) { + (void)cbm_private_file_lock_release(&contender); + } + bool duplicate_unlock_fault_set = cbm_private_file_lock_fail_next_release_step_for_test( + lock, CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK); + cbm_private_file_lock_status_t retry = + lock ? cbm_private_file_lock_release(&lock) : CBM_PRIVATE_FILE_LOCK_IO; + if (lock) { + (void)cbm_private_file_lock_release(&lock); + } + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(acquired, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(close_fault_set); + ASSERT_EQ(first_release, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(retained); + ASSERT_EQ(unlock_attempts, 1); + ASSERT_EQ(close_attempts, 1); + ASSERT_EQ(after_unlock, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(duplicate_unlock_fault_set); + ASSERT_EQ(retry, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(lock); + PASS(); +} + +TEST(private_file_lock_consumed_close_error_never_retries_recycled_fd) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX close descriptor-consumption semantics run on POSIX"); +#else + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + cbm_private_file_lock_t *lock = NULL; + cbm_private_file_lock_status_t acquired = + started ? cbm_private_file_lock_try_acquire(fixture.directory, "consumed-close.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) + : CBM_PRIVATE_FILE_LOCK_IO; + int consumed_fd = cbm_private_file_lock_native_fd_for_test(lock); + bool fault_set = cbm_private_file_lock_fail_close_after_consuming_for_test(lock); + cbm_private_file_lock_status_t first_release = + fault_set ? cbm_private_file_lock_release(&lock) : CBM_PRIVATE_FILE_LOCK_IO; + bool terminally_cleared = lock == NULL; + + int replacement_fd = open("/dev/null", O_RDONLY | O_CLOEXEC); + bool descriptor_reused = replacement_fd >= 0 && replacement_fd == consumed_fd; + if (lock) { + (void)cbm_private_file_lock_release(&lock); + } + bool replacement_alive = replacement_fd >= 0 && fcntl(replacement_fd, F_GETFD) >= 0; + if (replacement_alive) { + (void)close(replacement_fd); + } + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(acquired, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(fault_set); + ASSERT_EQ(first_release, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(terminally_cleared); + ASSERT_TRUE(descriptor_reused); + ASSERT_TRUE(replacement_alive); + PASS(); +#endif +} + +TEST(private_file_lock_post_acquire_failure_returns_cleanup_owner) { + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + bool fault_set = started && cbm_private_lock_directory_fail_post_acquire_cleanup_for_test( + fixture.directory, true, true); + cbm_private_file_lock_t *cleanup = NULL; + cbm_private_file_lock_status_t acquire_status = + fault_set + ? cbm_private_file_lock_try_acquire(fixture.directory, "post-acquire-cleanup.lock", + CBM_PRIVATE_FILE_LOCK_EX, &cleanup) + : CBM_PRIVATE_FILE_LOCK_IO; + bool cleanup_retained = cleanup != NULL; + + cbm_private_file_lock_t *contender = NULL; + cbm_private_file_lock_status_t while_unlock_pending = + started ? cbm_private_file_lock_try_acquire(fixture.directory, "post-acquire-cleanup.lock", + CBM_PRIVATE_FILE_LOCK_EX, &contender) + : CBM_PRIVATE_FILE_LOCK_IO; + if (contender) { + (void)cbm_private_file_lock_release(&contender); + } + cbm_private_file_lock_status_t first_cleanup = + cleanup ? cbm_private_file_lock_release(&cleanup) : CBM_PRIVATE_FILE_LOCK_IO; + bool retained_close_pending = cleanup != NULL; + cbm_private_file_lock_status_t second_cleanup = + cleanup ? cbm_private_file_lock_release(&cleanup) : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_t *after = NULL; + cbm_private_file_lock_status_t after_status = + started ? cbm_private_file_lock_try_acquire(fixture.directory, "post-acquire-cleanup.lock", + CBM_PRIVATE_FILE_LOCK_EX, &after) + : CBM_PRIVATE_FILE_LOCK_IO; + if (after) { + (void)cbm_private_file_lock_release(&after); + } + if (cleanup) { + (void)cbm_private_file_lock_release(&cleanup); + } + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(fault_set); + ASSERT_EQ(acquire_status, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(cleanup_retained); + ASSERT_EQ(while_unlock_pending, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_EQ(first_cleanup, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(retained_close_pending); + ASSERT_EQ(second_cleanup, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(cleanup); + ASSERT_EQ(after_status, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +} + +TEST(private_file_lock_windows_lock_attempt_failure_returns_cleanup_owner) { +#ifndef _WIN32 + SKIP_PLATFORM("LockFileEx cleanup ownership runs on Windows"); +#else + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + bool fault_set = + started && cbm_private_lock_directory_fail_lock_attempt_cleanup_for_test(fixture.directory); + cbm_private_file_lock_t *cleanup = NULL; + cbm_private_file_lock_status_t acquire_status = + fault_set + ? cbm_private_file_lock_try_acquire(fixture.directory, "lock-attempt-cleanup.lock", + CBM_PRIVATE_FILE_LOCK_EX, &cleanup) + : CBM_PRIVATE_FILE_LOCK_IO; + bool retained = cleanup != NULL; + cbm_private_file_lock_status_t cleanup_release = + cleanup ? cbm_private_file_lock_release(&cleanup) : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_t *after = NULL; + cbm_private_file_lock_status_t after_status = + started ? cbm_private_file_lock_try_acquire(fixture.directory, "lock-attempt-cleanup.lock", + CBM_PRIVATE_FILE_LOCK_EX, &after) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t after_release = + after ? cbm_private_file_lock_release(&after) : CBM_PRIVATE_FILE_LOCK_IO; + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(fault_set); + ASSERT_EQ(acquire_status, CBM_PRIVATE_FILE_LOCK_IO); + ASSERT_TRUE(retained); + ASSERT_EQ(cleanup_release, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(cleanup); + ASSERT_EQ(after_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(after_release, CBM_PRIVATE_FILE_LOCK_OK); + PASS(); +#endif +} + +TEST(private_file_lock_rejects_unsafe_entries_and_replaced_root) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX unsafe-entry fixtures run on POSIX"); +#else + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + char target[PRIVATE_LOCK_TEST_PATH_CAP]; + char symlink_path[PRIVATE_LOCK_TEST_PATH_CAP]; + char hardlink_path[PRIVATE_LOCK_TEST_PATH_CAP]; + char mode_path[PRIVATE_LOCK_TEST_PATH_CAP]; + char special_mode_path[PRIVATE_LOCK_TEST_PATH_CAP]; + char fifo_path[PRIVATE_LOCK_TEST_PATH_CAP]; + bool paths_ok = started && private_lock_path(target, &fixture, "target") && + private_lock_path(symlink_path, &fixture, "symlink.lock") && + private_lock_path(hardlink_path, &fixture, "hardlink.lock") && + private_lock_path(mode_path, &fixture, "mode.lock") && + private_lock_path(special_mode_path, &fixture, "special-mode.lock") && + private_lock_path(fifo_path, &fixture, "fifo.lock"); + int target_fd = paths_ok ? open(target, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600) : -1; + bool target_ok = target_fd >= 0 && close(target_fd) == 0; + bool fixtures_ok = + target_ok && symlink("target", symlink_path) == 0 && link(target, hardlink_path) == 0; + int mode_fd = fixtures_ok ? open(mode_path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600) : -1; + fixtures_ok = fixtures_ok && mode_fd >= 0 && fchmod(mode_fd, 0644) == 0 && close(mode_fd) == 0; + int special_mode_fd = + fixtures_ok ? open(special_mode_path, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0600) : -1; + fixtures_ok = fixtures_ok && special_mode_fd >= 0 && fchmod(special_mode_fd, 01600) == 0 && + close(special_mode_fd) == 0 && mkfifo(fifo_path, 0600) == 0; + + cbm_private_file_lock_t *lock = NULL; + cbm_private_file_lock_status_t symlink_status = + fixtures_ok ? cbm_private_file_lock_try_acquire(fixture.directory, "symlink.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t hardlink_status = + fixtures_ok ? cbm_private_file_lock_try_acquire(fixture.directory, "hardlink.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t mode_status = + fixtures_ok ? cbm_private_file_lock_try_acquire(fixture.directory, "mode.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t special_mode_status = + fixtures_ok ? cbm_private_file_lock_try_acquire(fixture.directory, "special-mode.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) + : CBM_PRIVATE_FILE_LOCK_IO; + if (lock) { + (void)cbm_private_file_lock_release(&lock); + } + cbm_private_file_lock_status_t fifo_status = + fixtures_ok ? cbm_private_file_lock_try_acquire(fixture.directory, "fifo.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) + : CBM_PRIVATE_FILE_LOCK_IO; + + bool special_root_set = fixtures_ok && chmod(fixture.root, 01700) == 0; + cbm_private_file_lock_status_t special_root_status = + special_root_set ? cbm_private_file_lock_try_acquire(fixture.directory, "special-root.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) + : CBM_PRIVATE_FILE_LOCK_IO; + if (lock) { + (void)cbm_private_file_lock_release(&lock); + } + bool special_root_restored = special_root_set && chmod(fixture.root, 0700) == 0; + + (void)unlink(symlink_path); + (void)unlink(hardlink_path); + (void)unlink(mode_path); + (void)unlink(special_mode_path); + (void)unlink(fifo_path); + char special_root_path[PRIVATE_LOCK_TEST_PATH_CAP]; + if (private_lock_path(special_root_path, &fixture, "special-root.lock")) { + (void)unlink(special_root_path); + } + (void)unlink(target); + + char moved_root[PRIVATE_LOCK_TEST_PATH_CAP]; + int moved_written = snprintf(moved_root, sizeof(moved_root), "%s-old", fixture.root); + bool replaced = fixtures_ok && moved_written > 0 && moved_written < (int)sizeof(moved_root) && + rename(fixture.root, moved_root) == 0 && mkdir(fixture.root, 0700) == 0; + cbm_private_file_lock_status_t replaced_status = + replaced ? cbm_private_file_lock_try_acquire(fixture.directory, "replaced.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) + : CBM_PRIVATE_FILE_LOCK_IO; + + cbm_private_lock_directory_close(fixture.directory); + fixture.directory = NULL; + if (replaced) { + (void)rmdir(fixture.root); + (void)rmdir(moved_root); + (void)rmdir(fixture.parent); + } else { + private_lock_fixture_finish(&fixture); + } + + ASSERT_TRUE(started); + ASSERT_TRUE(fixtures_ok); + ASSERT_EQ(symlink_status, CBM_PRIVATE_FILE_LOCK_UNSAFE); + ASSERT_EQ(hardlink_status, CBM_PRIVATE_FILE_LOCK_UNSAFE); + ASSERT_EQ(mode_status, CBM_PRIVATE_FILE_LOCK_UNSAFE); + ASSERT_EQ(special_mode_status, CBM_PRIVATE_FILE_LOCK_UNSAFE); + ASSERT_EQ(fifo_status, CBM_PRIVATE_FILE_LOCK_UNSAFE); + ASSERT_TRUE(special_root_set); + ASSERT_EQ(special_root_status, CBM_PRIVATE_FILE_LOCK_UNSAFE); + ASSERT_TRUE(special_root_restored); + ASSERT_TRUE(replaced); + ASSERT_EQ(replaced_status, CBM_PRIVATE_FILE_LOCK_UNSAFE); + PASS(); +#endif +} + +#ifdef __APPLE__ +TEST(private_file_lock_macos_rejects_directory_acl_added_after_adoption) { + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + char lock_path[PRIVATE_LOCK_TEST_PATH_CAP] = {0}; + bool path_ok = + started && private_lock_path(lock_path, &fixture, "acl-directory.lock"); + int acl_fixture = + path_ok ? private_lock_macos_set_mutating_acl(fixture.root) : -1; + if (acl_fixture == 0) { + private_lock_fixture_finish(&fixture); + SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); + } + struct stat root_status = {0}; + bool mode_stayed_private = + acl_fixture == 1 && lstat(fixture.root, &root_status) == 0 && + S_ISDIR(root_status.st_mode) && (root_status.st_mode & 07777) == 0700; + + cbm_private_file_lock_t *lock = NULL; + cbm_private_file_lock_status_t acquire_status = + mode_stayed_private + ? cbm_private_file_lock_try_acquire( + fixture.directory, "acl-directory.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) + : CBM_PRIVATE_FILE_LOCK_IO; + struct stat unexpected = {0}; + errno = 0; + bool file_was_not_created = + lstat(lock_path, &unexpected) != 0 && errno == ENOENT; + if (lock) { + (void)cbm_private_file_lock_release(&lock); + } + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(path_ok); + ASSERT_EQ(acl_fixture, 1); + ASSERT_TRUE(mode_stayed_private); + ASSERT_EQ(acquire_status, CBM_PRIVATE_FILE_LOCK_UNSAFE); + ASSERT_NULL(lock); + ASSERT_TRUE(file_was_not_created); + PASS(); +} + +TEST(private_file_lock_macos_rejects_file_acl_added_after_acquisition) { + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + char lock_path[PRIVATE_LOCK_TEST_PATH_CAP] = {0}; + bool path_ok = + started && private_lock_path(lock_path, &fixture, "acl-file.lock"); + cbm_private_file_lock_t *lock = NULL; + cbm_private_file_lock_status_t acquire_status = + path_ok ? cbm_private_file_lock_try_acquire( + fixture.directory, "acl-file.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) + : CBM_PRIVATE_FILE_LOCK_IO; + int acl_fixture = + acquire_status == CBM_PRIVATE_FILE_LOCK_OK + ? private_lock_macos_set_mutating_acl(lock_path) + : -1; + if (acl_fixture == 0) { + if (lock) { + (void)cbm_private_file_lock_release(&lock); + } + private_lock_fixture_finish(&fixture); + SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); + } + struct stat file_status = {0}; + bool mode_stayed_private = + acl_fixture == 1 && lstat(lock_path, &file_status) == 0 && + S_ISREG(file_status.st_mode) && (file_status.st_mode & 07777) == 0600; + static const unsigned char payload[] = {'a', 'c', 'l'}; + cbm_private_file_lock_status_t payload_status = + mode_stayed_private + ? cbm_private_file_lock_payload_write(lock, payload, + sizeof(payload)) + : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t release_status = + lock ? cbm_private_file_lock_release(&lock) : CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_t *reopened = NULL; + cbm_private_file_lock_status_t reopen_status = + cbm_private_file_lock_try_acquire( + fixture.directory, "acl-file.lock", CBM_PRIVATE_FILE_LOCK_EX, + &reopened); + if (reopened) { + (void)cbm_private_file_lock_release(&reopened); + } + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(path_ok); + ASSERT_EQ(acquire_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(acl_fixture, 1); + ASSERT_TRUE(mode_stayed_private); + ASSERT_EQ(payload_status, CBM_PRIVATE_FILE_LOCK_UNSAFE); + ASSERT_EQ(release_status, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(lock); + ASSERT_EQ(reopen_status, CBM_PRIVATE_FILE_LOCK_UNSAFE); + ASSERT_NULL(reopened); + PASS(); +} +#endif + +TEST(private_file_lock_fork_child_cannot_unlock_or_retain_parent_lock) { +#ifdef _WIN32 + SKIP_PLATFORM("fork inheritance applies only to POSIX"); +#else + private_lock_fixture_t fixture; + bool started = private_lock_fixture_start(&fixture); + cbm_private_file_lock_t *parent_lock = NULL; + cbm_private_file_lock_status_t acquired = + started ? cbm_private_file_lock_try_acquire(fixture.directory, "fork.lock", + CBM_PRIVATE_FILE_LOCK_EX, &parent_lock) + : CBM_PRIVATE_FILE_LOCK_IO; + bool cloexec = cbm_private_file_lock_is_cloexec_for_test(parent_lock); + int command_pipe[2] = {-1, -1}; + int result_pipe[2] = {-1, -1}; + bool pipes_ok = + acquired == CBM_PRIVATE_FILE_LOCK_OK && pipe(command_pipe) == 0 && pipe(result_pipe) == 0; + pid_t child = pipes_ok ? fork() : -1; + if (child == 0) { + (void)close(command_pipe[1]); + (void)close(result_pipe[0]); + cbm_private_file_lock_status_t child_release = cbm_private_file_lock_release(&parent_lock); + char report = child_release == CBM_PRIVATE_FILE_LOCK_OK ? 'R' : 'E'; + bool reported = private_lock_fd_write_byte(result_pipe[1], report); + char command = 0; + bool commanded = private_lock_fd_read_byte(command_pipe[0], &command); + (void)close(command_pipe[0]); + (void)close(result_pipe[1]); + _exit(reported && commanded && command == 'X' ? 0 : 1); + } + + char child_report = 0; + bool child_released = false; + cbm_private_file_lock_t *contender = NULL; + cbm_private_file_lock_status_t while_parent = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t after_parent = CBM_PRIVATE_FILE_LOCK_IO; + bool parent_released = false; + bool child_commanded = false; + int child_status = -1; + if (child > 0) { + (void)close(command_pipe[0]); + (void)close(result_pipe[1]); + child_released = + private_lock_fd_read_byte(result_pipe[0], &child_report) && child_report == 'R'; + while_parent = cbm_private_file_lock_try_acquire(fixture.directory, "fork.lock", + CBM_PRIVATE_FILE_LOCK_EX, &contender); + parent_released = cbm_private_file_lock_release(&parent_lock) == CBM_PRIVATE_FILE_LOCK_OK; + after_parent = cbm_private_file_lock_try_acquire(fixture.directory, "fork.lock", + CBM_PRIVATE_FILE_LOCK_EX, &contender); + if (contender) { + (void)cbm_private_file_lock_release(&contender); + } + child_commanded = private_lock_fd_write_byte(command_pipe[1], 'X'); + (void)close(command_pipe[1]); + (void)close(result_pipe[0]); + (void)waitpid(child, &child_status, 0); + } + if (parent_lock) { + (void)cbm_private_file_lock_release(&parent_lock); + } + if (command_pipe[0] >= 0 && child <= 0) { + (void)close(command_pipe[0]); + (void)close(command_pipe[1]); + } + if (result_pipe[0] >= 0 && child <= 0) { + (void)close(result_pipe[0]); + (void)close(result_pipe[1]); + } + private_lock_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_EQ(acquired, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(cloexec); + ASSERT_TRUE(pipes_ok); + ASSERT_GT(child, 0); + ASSERT_TRUE(child_released); + ASSERT_EQ(while_parent, CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_TRUE(parent_released); + ASSERT_EQ(after_parent, CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_TRUE(child_commanded); + ASSERT_TRUE(WIFEXITED(child_status)); + ASSERT_EQ(WEXITSTATUS(child_status), 0); + PASS(); +#endif +} + +SUITE(private_file_lock) { + RUN_TEST(private_file_lock_shared_and_exclusive_matrix); + RUN_TEST(private_file_lock_payload_requires_exclusive_write_and_survives_reopen); + RUN_TEST(private_file_lock_unlock_failure_retains_retryable_lock); + RUN_TEST(private_file_lock_close_failure_retries_without_duplicate_unlock); + RUN_TEST(private_file_lock_consumed_close_error_never_retries_recycled_fd); + RUN_TEST(private_file_lock_post_acquire_failure_returns_cleanup_owner); + RUN_TEST(private_file_lock_windows_lock_attempt_failure_returns_cleanup_owner); + RUN_TEST(private_file_lock_rejects_unsafe_entries_and_replaced_root); +#ifdef __APPLE__ + RUN_TEST(private_file_lock_macos_rejects_directory_acl_added_after_adoption); + RUN_TEST(private_file_lock_macos_rejects_file_acl_added_after_acquisition); +#endif + RUN_TEST(private_file_lock_fork_child_cannot_unlock_or_retain_parent_lock); +} diff --git a/tests/test_project_lock.c b/tests/test_project_lock.c new file mode 100644 index 000000000..75377b138 --- /dev/null +++ b/tests/test_project_lock.c @@ -0,0 +1,74 @@ +/* RED contract for daemon/local-CLI cross-process project coordination. */ +#include "test_framework.h" +#include "test_helpers.h" + +#include "daemon/ipc.h" +#include "daemon/project_lock.h" +#include "foundation/compat_fs.h" +#include "foundation/platform.h" + +#include +#include + +enum { PROJECT_LOCK_TEST_PATH_CAP = 1024 }; + +static void project_lock_test_release(cbm_project_lock_lease_t **lease) { + while (lease && *lease && + cbm_project_lock_lease_release(lease) != CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } +} + +TEST(project_lock_coordinates_instances_projects_wildcard_and_case_aliases) { + char runtime_parent[PROJECT_LOCK_TEST_PATH_CAP]; + (void)snprintf(runtime_parent, sizeof(runtime_parent), + "%s/cbm-project-lock-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(runtime_parent)); + + cbm_daemon_ipc_endpoint_t *endpoint = + cbm_daemon_ipc_endpoint_new("0123456789abcdef", runtime_parent); + cbm_project_lock_manager_t *first = + cbm_project_lock_manager_new(endpoint); + cbm_project_lock_manager_t *second = + cbm_project_lock_manager_new(endpoint); + ASSERT_NOT_NULL(endpoint); + ASSERT_NOT_NULL(first); + ASSERT_NOT_NULL(second); + + cbm_project_lock_lease_t *foo = NULL; + cbm_project_lock_lease_t *alias = NULL; + cbm_project_lock_lease_t *bar = NULL; + ASSERT_EQ(cbm_project_lock_try_acquire(first, "Foo", &foo), + CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NOT_NULL(foo); + ASSERT_EQ(cbm_project_lock_try_acquire(second, "foo", &alias), + CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_NULL(alias); + ASSERT_EQ(cbm_project_lock_acquire(second, "bar", UINT64_MAX, NULL, &bar), + CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NOT_NULL(bar); + + project_lock_test_release(&bar); + project_lock_test_release(&foo); + + cbm_project_lock_lease_t *all = NULL; + ASSERT_EQ(cbm_project_lock_acquire(first, "*", UINT64_MAX, NULL, &all), + CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NOT_NULL(all); + ASSERT_EQ(cbm_project_lock_try_acquire(second, "unrelated", &bar), + CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_NULL(bar); + + project_lock_test_release(&all); + ASSERT_EQ(cbm_project_lock_manager_free(&second), CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(cbm_project_lock_manager_free(&first), CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(second); + ASSERT_NULL(first); + cbm_daemon_ipc_endpoint_free(endpoint); + (void)th_rmtree(runtime_parent); + PASS(); +} + +SUITE(project_lock) { + RUN_TEST(project_lock_coordinates_instances_projects_wildcard_and_case_aliases); +} diff --git a/tests/test_semantic.c b/tests/test_semantic.c index f8d7787ac..c4030a0fa 100644 --- a/tests/test_semantic.c +++ b/tests/test_semantic.c @@ -5,10 +5,13 @@ * proximity, diffuse, corpus lifecycle, get_config. */ #include "test_framework.h" +#include "../src/foundation/compat.h" +#include "../src/foundation/compat_thread.h" #include #include #include +#include #include #include @@ -341,6 +344,63 @@ TEST(sem_get_config_defaults) { /* ── RaBitQ estimator quality (from-paper 4-bit quantization) ────── */ +typedef struct { + atomic_int *ready; + atomic_int *start; + float value; + cbm_rsq_code_t code; +} rotsq_thread_ctx_t; + +static void *rotsq_concurrent_first_encode(void *opaque) { + rotsq_thread_ctx_t *ctx = opaque; + float vec[CBM_RSQ_IN_DIM]; + for (int i = 0; i < CBM_RSQ_IN_DIM; i++) { + vec[i] = ctx->value + (float)i / (float)CBM_RSQ_IN_DIM; + } + atomic_fetch_add_explicit(ctx->ready, 1, memory_order_release); + while (atomic_load_explicit(ctx->start, memory_order_acquire) == 0) { + cbm_usleep(1000); + } + cbm_rsq_encode(vec, &ctx->code); + return NULL; +} + +/* The daemon can initialize semantic encoders from multiple request threads. + * Run this first so ThreadSanitizer observes the one-time initialization. */ +TEST(sem_rotsq_concurrent_first_encode) { + atomic_int ready; + atomic_int start; + atomic_init(&ready, 0); + atomic_init(&start, 0); + rotsq_thread_ctx_t ctx[2] = { + {.ready = &ready, .start = &start, .value = 0.25F}, + {.ready = &ready, .start = &start, .value = -0.5F}, + }; + cbm_thread_t threads[2]; + bool started0 = cbm_thread_create(&threads[0], 0, rotsq_concurrent_first_encode, &ctx[0]) == 0; + bool started1 = cbm_thread_create(&threads[1], 0, rotsq_concurrent_first_encode, &ctx[1]) == 0; + for (int spins = 0; started0 && started1 && spins < 5000 && + atomic_load_explicit(&ready, memory_order_acquire) < 2; + spins++) { + cbm_usleep(1000); + } + bool both_ready = atomic_load_explicit(&ready, memory_order_acquire) == 2; + atomic_store_explicit(&start, 1, memory_order_release); + if (started0) { + (void)cbm_thread_join(&threads[0]); + } + if (started1) { + (void)cbm_thread_join(&threads[1]); + } + + ASSERT_TRUE(started0); + ASSERT_TRUE(started1); + ASSERT_TRUE(both_ready); + ASSERT_TRUE(ctx[0].code.scale > 0.0F); + ASSERT_TRUE(ctx[1].code.scale > 0.0F); + PASS(); +} + /* Deterministic pseudo-random unit vectors; validates that the quantized * inner-product estimator tracks the exact float IP within tight bounds. * These bounds gate the semantic pass's use of the codes: cosine scores are @@ -400,6 +460,7 @@ TEST(sem_rotsq_ip_error_bounds) { /* ── Suite ───────────────────────────────────────────────────────── */ SUITE(semantic) { + RUN_TEST(sem_rotsq_concurrent_first_encode); RUN_TEST(sem_rotsq_ip_error_bounds); RUN_TEST(sem_tokenize_camel); RUN_TEST(sem_tokenize_snake); diff --git a/tests/test_store_checkpoint.c b/tests/test_store_checkpoint.c index 9097befd7..407bec929 100644 --- a/tests/test_store_checkpoint.c +++ b/tests/test_store_checkpoint.c @@ -183,7 +183,20 @@ TEST(dump_install_ignores_stale_wal_sidecar) { PASS(); } +TEST(remove_db_sidecars_rejects_truncated_suffix_path) { + /* The implementation's sidecar buffer is 4096 bytes. Before the fix, + * snprintf truncation simply skipped every unlink and still returned + * success, violating the helper's fail-closed contract. */ + char db_path[4096]; + memset(db_path, 'x', sizeof(db_path) - 1); + db_path[sizeof(db_path) - 1] = '\0'; + + ASSERT_TRUE(cbm_remove_db_sidecars(db_path) != 0); + PASS(); +} + SUITE(store_checkpoint) { RUN_TEST(checkpoint_does_not_truncate_wal); RUN_TEST(dump_install_ignores_stale_wal_sidecar); + RUN_TEST(remove_db_sidecars_rejects_truncated_suffix_path); } diff --git a/tests/test_subprocess.c b/tests/test_subprocess.c index c7dad2fc9..fc564184d 100644 --- a/tests/test_subprocess.c +++ b/tests/test_subprocess.c @@ -10,11 +10,18 @@ */ #include "test_framework.h" #include "../src/foundation/subprocess.h" +#include "../src/foundation/compat.h" +#include "../src/foundation/platform.h" +#include +#include +#include #include #ifndef _WIN32 +#include #include +#include #endif /* ── Layer 1: pure classifier (all platforms) ─────────────────────────────── */ @@ -37,6 +44,7 @@ TEST(subprocess_classify_windows_crash_codes) { ASSERT_EQ(cbm_proc_classify(true, (int)0xC0000005u, 0, false), CBM_PROC_CRASH); ASSERT_EQ(cbm_proc_classify(true, (int)0xC00000FDu, 0, false), CBM_PROC_CRASH); ASSERT_EQ(cbm_proc_classify(true, (int)0xC000001Du, 0, false), CBM_PROC_CRASH); + ASSERT_EQ(cbm_proc_classify(true, (int)0xC000013Au, 0, false), CBM_PROC_KILLED); PASS(); } @@ -168,7 +176,456 @@ TEST(subprocess_run_null_bin_rejected) { PASS(); } -/* ── Layer 3: Windows command-line quoting (pure; every platform) ───────────── +/* ── Layer 3: nonblocking handle + whole-tree cancellation (POSIX) ────────── + * + * The daemon cannot block its coordinator thread in cbm_subprocess_run(). It + * owns an opaque handle, polls it, and requests cancellation when the final job + * subscriber leaves. These probes deliberately use /bin/sh descendants which + * ignore SIGTERM: a direct-child-only implementation leaves the grandchild alive, + * while a correct process-group implementation escalates and reports quiescence. + * All test-side waits have explicit monotonic deadlines. */ + +#ifndef _WIN32 + +static void subprocess_test_pause(void) { + const struct timespec delay = {0, 10000000L}; /* 10 ms */ + (void)cbm_nanosleep(&delay, NULL); +} + +static bool poll_until_terminal(cbm_subprocess_t *process, int timeout_ms, cbm_proc_result_t *out) { + uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms; + do { + cbm_proc_poll_t state = cbm_subprocess_poll(process, out); + if (state == CBM_PROC_POLL_TERMINAL) { + return true; + } + if (state == CBM_PROC_POLL_ERROR) { + return false; + } + subprocess_test_pause(); + } while (cbm_now_ms() < deadline); + return cbm_subprocess_poll(process, out) == CBM_PROC_POLL_TERMINAL; +} + +static bool make_tree_pid_path(char path[64]) { + strcpy(path, "/tmp/cbm-subprocess-tree-XXXXXX"); + int fd = cbm_mkstemp(path); + if (fd < 0) { + return false; + } + (void)close(fd); + return unlink(path) == 0; /* child creates it only after both traps are installed */ +} + +static bool wait_for_tree_pids(const char *path, cbm_subprocess_t *process, pid_t *parent_pid, + pid_t *grandchild_pid, int timeout_ms) { + uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms; + do { + FILE *f = fopen(path, "r"); + if (f) { + long parent_value = 0; + long grandchild_value = 0; + int fields = fscanf(f, "%ld %ld", &parent_value, &grandchild_value); + fclose(f); + if (fields == 2 && parent_value > 1 && grandchild_value > 1) { + *parent_pid = (pid_t)parent_value; + *grandchild_pid = (pid_t)grandchild_value; + return true; + } + } + cbm_proc_result_t ignored; + if (cbm_subprocess_poll(process, &ignored) != CBM_PROC_POLL_RUNNING) { + return false; + } + subprocess_test_pause(); + } while (cbm_now_ms() < deadline); + return false; +} + +static bool wait_pid_gone(pid_t pid, int timeout_ms) { + uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms; + do { + errno = 0; + if (kill(pid, 0) < 0 && errno == ESRCH) { + return true; + } + subprocess_test_pause(); + } while (cbm_now_ms() < deadline); + errno = 0; + return kill(pid, 0) < 0 && errno == ESRCH; +} + +/* Best-effort cleanup for a failing implementation, so a red tree test does not + * leave its TERM-ignoring probes behind for later tests. The production API must + * still report terminal itself; callers never use this escape hatch. */ +static void force_probe_cleanup(pid_t parent_pid, pid_t grandchild_pid) { + if (parent_pid > 1) { + (void)kill(-parent_pid, SIGKILL); + (void)kill(parent_pid, SIGKILL); + } + if (grandchild_pid > 1) { + (void)kill(grandchild_pid, SIGKILL); + } +} + +static int spawn_ignoring_tree(const char *pid_path, int quiet_timeout_ms, int cancel_grace_ms, + cbm_subprocess_t **out) { + /* The direct child installs its trap before starting a nested shell. The two + * PIDs are written only after the nested process exists, eliminating the + * cancellation-before-trap race from the test. */ + const char *script = "trap '' TERM; " + "/bin/sh -c 'trap \"\" TERM; while :; do sleep 1; done' cbm-grandchild & " + "grandchild=$!; echo \"$$ $grandchild\" > \"$1\"; wait"; + const char *argv[] = {"/bin/sh", "-c", script, "cbm-parent", pid_path, NULL}; + cbm_proc_opts_t opts = {0}; + opts.bin = "/bin/sh"; + opts.argv = argv; + opts.quiet_timeout_ms = quiet_timeout_ms; + opts.cancel_grace_ms = cancel_grace_ms; + return cbm_subprocess_spawn(&opts, out); +} + +static void slow_log_callback(const char *line, void *opaque) { + (void)line; + int *count = opaque; + (*count)++; + const struct timespec delay = {0, 2000000L}; /* 2 ms per delivered line */ + (void)cbm_nanosleep(&delay, NULL); +} + +#endif /* !_WIN32 */ + +TEST(subprocess_spawn_returns_while_child_is_running) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX /bin/sh nonblocking spawn probe; native Job Object coverage pending"); +#else + const char *argv[] = {"/bin/sh", "-c", "sleep 4", NULL}; + cbm_proc_opts_t opts = {0}; + opts.bin = "/bin/sh"; + opts.argv = argv; + opts.cancel_grace_ms = 100; + cbm_subprocess_t *process = NULL; + + uint64_t before_spawn = cbm_now_ms(); + int spawn_rc = cbm_subprocess_spawn(&opts, &process); + uint64_t spawn_elapsed = cbm_now_ms() - before_spawn; + ASSERT_EQ(spawn_rc, 0); + ASSERT_NOT_NULL(process); + + cbm_proc_result_t result; + uint64_t before_poll = cbm_now_ms(); + cbm_proc_poll_t first_poll = cbm_subprocess_poll(process, &result); + uint64_t poll_elapsed = cbm_now_ms() - before_poll; + bool cancel_accepted = cbm_subprocess_request_cancel(process); + bool terminal = poll_until_terminal(process, 2000, &result); + if (terminal) { + cbm_subprocess_destroy(process); + } + + /* Generous thresholds distinguish a nonblocking call from waiting for the + * four-second probe, without making normal scheduler jitter test-significant. */ + ASSERT_LT(spawn_elapsed, 1500); + ASSERT_LT(poll_elapsed, 1000); + ASSERT_EQ(first_poll, CBM_PROC_POLL_RUNNING); + ASSERT_TRUE(cancel_accepted); + ASSERT_TRUE(terminal); + ASSERT_TRUE(result.cancellation_requested); + ASSERT_TRUE(result.tree_quiesced); + PASS(); +#endif +} + +TEST(subprocess_natural_completion_is_cached_across_polls) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX /bin/sh completion-cache probe; native Job Object coverage pending"); +#else + const char *argv[] = {"/bin/sh", "-c", "exit 7", NULL}; + cbm_proc_opts_t opts = {0}; + opts.bin = "/bin/sh"; + opts.argv = argv; + opts.cancel_grace_ms = 100; + cbm_subprocess_t *process = NULL; + ASSERT_EQ(cbm_subprocess_spawn(&opts, &process), 0); + ASSERT_NOT_NULL(process); + + cbm_proc_result_t first; + ASSERT_TRUE(poll_until_terminal(process, 2000, &first)); + cbm_proc_result_t second; + cbm_proc_result_t third; + cbm_proc_poll_t second_poll = cbm_subprocess_poll(process, &second); + cbm_proc_poll_t third_poll = cbm_subprocess_poll(process, &third); + cbm_subprocess_destroy(process); + + ASSERT_EQ(second_poll, CBM_PROC_POLL_TERMINAL); + ASSERT_EQ(third_poll, CBM_PROC_POLL_TERMINAL); + ASSERT_EQ(first.outcome, CBM_PROC_EXIT_NONZERO); + ASSERT_EQ(first.exit_code, 7); + ASSERT_EQ(second.outcome, first.outcome); + ASSERT_EQ(second.exit_code, first.exit_code); + ASSERT_EQ(second.term_signal, first.term_signal); + ASSERT_EQ(second.cancellation_requested, first.cancellation_requested); + ASSERT_EQ(second.forced, first.forced); + ASSERT_EQ(second.tree_quiesced, first.tree_quiesced); + ASSERT_EQ(third.outcome, first.outcome); + ASSERT_EQ(third.exit_code, first.exit_code); + ASSERT_EQ(third.term_signal, first.term_signal); + ASSERT_EQ(third.cancellation_requested, first.cancellation_requested); + ASSERT_EQ(third.forced, first.forced); + ASSERT_EQ(third.tree_quiesced, first.tree_quiesced); + ASSERT_FALSE(first.cancellation_requested); + ASSERT_FALSE(first.forced); + ASSERT_TRUE(first.tree_quiesced); + PASS(); +#endif +} + +TEST(subprocess_cancel_is_idempotent_and_kills_ignoring_tree) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX process-group probe; native Windows Job Object tree probe pending"); +#else + char pid_path[64]; + ASSERT_TRUE(make_tree_pid_path(pid_path)); + cbm_subprocess_t *process = NULL; + ASSERT_EQ(spawn_ignoring_tree(pid_path, 0, 100, &process), 0); + ASSERT_NOT_NULL(process); + + pid_t parent_pid = -1; + pid_t grandchild_pid = -1; + bool ready = wait_for_tree_pids(pid_path, process, &parent_pid, &grandchild_pid, 1000); + bool first_cancel = ready && cbm_subprocess_request_cancel(process); + bool second_cancel = ready && cbm_subprocess_request_cancel(process); + cbm_proc_result_t result; + bool terminal = ready && poll_until_terminal(process, 2500, &result); + bool parent_gone = terminal && wait_pid_gone(parent_pid, 1000); + bool grandchild_gone = terminal && wait_pid_gone(grandchild_pid, 1000); + if (!terminal) { + force_probe_cleanup(parent_pid, grandchild_pid); + cbm_proc_result_t cleanup_result; + if (poll_until_terminal(process, 1000, &cleanup_result)) { + cbm_subprocess_destroy(process); + } + } else { + cbm_subprocess_destroy(process); + } + (void)unlink(pid_path); + + ASSERT_TRUE(ready); + ASSERT_TRUE(first_cancel); + ASSERT_TRUE(second_cancel); + ASSERT_TRUE(terminal); + ASSERT_EQ(result.outcome, CBM_PROC_KILLED); + ASSERT_TRUE(result.cancellation_requested); + ASSERT_TRUE(result.forced); + ASSERT_TRUE(result.tree_quiesced); + ASSERT_TRUE(parent_gone); + ASSERT_TRUE(grandchild_gone); + PASS(); +#endif +} + +TEST(subprocess_quiet_timeout_kills_ignoring_tree) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX process-group probe; native Windows Job Object tree probe pending"); +#else + char pid_path[64]; + ASSERT_TRUE(make_tree_pid_path(pid_path)); + cbm_subprocess_t *process = NULL; + ASSERT_EQ(spawn_ignoring_tree(pid_path, 750, 100, &process), 0); + ASSERT_NOT_NULL(process); + + pid_t parent_pid = -1; + pid_t grandchild_pid = -1; + bool ready = wait_for_tree_pids(pid_path, process, &parent_pid, &grandchild_pid, 500); + cbm_proc_result_t result; + bool terminal = ready && poll_until_terminal(process, 3000, &result); + bool parent_gone = terminal && wait_pid_gone(parent_pid, 1000); + bool grandchild_gone = terminal && wait_pid_gone(grandchild_pid, 1000); + if (!terminal) { + force_probe_cleanup(parent_pid, grandchild_pid); + cbm_proc_result_t cleanup_result; + if (poll_until_terminal(process, 1000, &cleanup_result)) { + cbm_subprocess_destroy(process); + } + } else { + cbm_subprocess_destroy(process); + } + (void)unlink(pid_path); + + ASSERT_TRUE(ready); + ASSERT_TRUE(terminal); + ASSERT_EQ(result.outcome, CBM_PROC_HANG); + ASSERT_FALSE(result.cancellation_requested); + ASSERT_TRUE(result.forced); + ASSERT_TRUE(result.tree_quiesced); + ASSERT_TRUE(parent_gone); + ASSERT_TRUE(grandchild_gone); + PASS(); +#endif +} + +TEST(subprocess_cancel_grace_is_hard_capped) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX process-group grace cap probe; native Windows coverage pending"); +#else + char pid_path[64]; + ASSERT_TRUE(make_tree_pid_path(pid_path)); + cbm_subprocess_t *process = NULL; + ASSERT_EQ(spawn_ignoring_tree(pid_path, 0, INT_MAX, &process), 0); + ASSERT_NOT_NULL(process); + + pid_t parent_pid = -1; + pid_t grandchild_pid = -1; + bool ready = wait_for_tree_pids(pid_path, process, &parent_pid, &grandchild_pid, 1000); + bool cancel_accepted = ready && cbm_subprocess_request_cancel(process); + cbm_proc_result_t result = {0}; + bool terminal = cancel_accepted && poll_until_terminal(process, 3500, &result); + if (!terminal) { + force_probe_cleanup(parent_pid, grandchild_pid); + cbm_proc_result_t cleanup_result; + if (poll_until_terminal(process, 1000, &cleanup_result)) { + cbm_subprocess_destroy(process); + } + } else { + cbm_subprocess_destroy(process); + } + (void)unlink(pid_path); + + ASSERT_TRUE(ready); + ASSERT_TRUE(cancel_accepted); + ASSERT_TRUE(terminal); + ASSERT_TRUE(result.forced); + ASSERT_TRUE(result.tree_quiesced); + PASS(); +#endif +} + +TEST(subprocess_poll_has_a_strict_log_delivery_budget) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX shell log budget probe; native Windows coverage pending"); +#else + char log_path[] = "/tmp/cbm-subprocess-log-budget-XXXXXX"; + int log_fd = cbm_mkstemp(log_path); + ASSERT_TRUE(log_fd >= 0); + (void)close(log_fd); + + const char *script = "i=0; while [ $i -lt 400 ]; do echo line-$i; i=$((i+1)); done; sleep 4"; + const char *argv[] = {"/bin/sh", "-c", script, NULL}; + int delivered = 0; + cbm_proc_opts_t opts = {0}; + opts.bin = "/bin/sh"; + opts.argv = argv; + opts.log_file = log_path; + opts.on_log_line = slow_log_callback; + opts.log_ud = &delivered; + opts.cancel_grace_ms = 100; + cbm_subprocess_t *process = NULL; + ASSERT_EQ(cbm_subprocess_spawn(&opts, &process), 0); + ASSERT_NOT_NULL(process); + + const struct timespec fill_delay = {0, 150000000L}; + (void)cbm_nanosleep(&fill_delay, NULL); + cbm_proc_result_t result = {0}; + uint64_t before = cbm_now_ms(); + cbm_proc_poll_t first = cbm_subprocess_poll(process, &result); + uint64_t elapsed = cbm_now_ms() - before; + bool cancel_accepted = cbm_subprocess_request_cancel(process); + bool terminal = poll_until_terminal(process, 3000, &result); + if (terminal) { + cbm_subprocess_destroy(process); + } + (void)unlink(log_path); + + ASSERT_EQ(first, CBM_PROC_POLL_RUNNING); + ASSERT_LT(elapsed, 500); + ASSERT_LT(delivered, 400); + ASSERT_TRUE(cancel_accepted); + ASSERT_TRUE(terminal); + PASS(); +#endif +} + +TEST(subprocess_posix_child_closes_unrelated_descriptors) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX descriptor-inheritance probe; Windows uses a handle allow-list"); +#else + char sentinel_path[] = "/tmp/cbm-subprocess-sentinel-XXXXXX"; + int sentinel = cbm_mkstemp(sentinel_path); + ASSERT_TRUE(sentinel > STDERR_FILENO); + int flags = fcntl(sentinel, F_GETFD); + ASSERT_TRUE(flags >= 0); + ASSERT_EQ(fcntl(sentinel, F_SETFD, flags & ~FD_CLOEXEC), 0); + char fd_text[32]; + snprintf(fd_text, sizeof(fd_text), "%d", sentinel); + const char *script = "if [ -e /dev/fd/$1 ]; then exit 42; else exit 0; fi"; + const char *argv[] = {"/bin/sh", "-c", script, "cbm-fd-probe", fd_text, NULL}; + cbm_proc_opts_t opts = {0}; + opts.bin = "/bin/sh"; + opts.argv = argv; + cbm_proc_result_t result; + int run_rc = cbm_subprocess_run(&opts, &result); + (void)close(sentinel); + (void)unlink(sentinel_path); + + ASSERT_EQ(run_rc, 0); + ASSERT_EQ(result.outcome, CBM_PROC_CLEAN); + ASSERT_EQ(result.exit_code, 0); + PASS(); +#endif +} + +TEST(subprocess_root_exit_drains_surviving_descendant) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX process-group descendant probe; native Windows coverage pending"); +#else + char pid_path[64]; + ASSERT_TRUE(make_tree_pid_path(pid_path)); + const char *script = "sleep 30 & child=$!; echo $child > \"$1\"; exit 0"; + const char *argv[] = {"/bin/sh", "-c", script, "cbm-root-exit", pid_path, NULL}; + cbm_proc_opts_t opts = {0}; + opts.bin = "/bin/sh"; + opts.argv = argv; + opts.cancel_grace_ms = 100; + cbm_subprocess_t *process = NULL; + ASSERT_EQ(cbm_subprocess_spawn(&opts, &process), 0); + ASSERT_NOT_NULL(process); + + pid_t descendant = -1; + uint64_t deadline = cbm_now_ms() + 1000; + while (descendant <= 1 && cbm_now_ms() < deadline) { + FILE *file = fopen(pid_path, "r"); + long value = -1; + if (file) { + if (fscanf(file, "%ld", &value) == 1) { + descendant = (pid_t)value; + } + (void)fclose(file); + } + subprocess_test_pause(); + } + cbm_proc_result_t result = {0}; + bool terminal = descendant > 1 && poll_until_terminal(process, 2500, &result); + bool descendant_gone = terminal && wait_pid_gone(descendant, 1000); + if (!terminal) { + force_probe_cleanup(-1, descendant); + cbm_proc_result_t cleanup_result; + if (poll_until_terminal(process, 1000, &cleanup_result)) { + cbm_subprocess_destroy(process); + } + } else { + cbm_subprocess_destroy(process); + } + (void)unlink(pid_path); + + ASSERT_TRUE(descendant > 1); + ASSERT_TRUE(terminal); + ASSERT_TRUE(descendant_gone); + ASSERT_EQ(result.outcome, CBM_PROC_CLEAN); + ASSERT_TRUE(result.tree_quiesced); + PASS(); +#endif +} + +/* ── Layer 4: Windows command-line quoting (pure; every platform) ───────────── * * The Windows index-worker "crash" was a quoting bug: the Windows spawn wrapped each * argv element in bare quotes without escaping, so a JSON argument like @@ -264,10 +721,18 @@ static bool cmdline_roundtrips(const char *const *argv) { * Round-trips, AND the emitted line must contain an ESCAPED quote (\") — the bare * `"%s"` wrap that caused the bug never would. */ TEST(win_cmdline_index_worker_json) { - const char *const argv[] = {"C:/bin/cbm.exe", "cli", - "--index-worker", "index_repository", - "{\"repo_path\":\"C:/r\"}", "--response-out", - "C:/c/w.response", NULL}; + const char *const argv[] = { + "C:/bin/cbm.exe", + "cli", + "--index-worker", + "--index-worker-build", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "index_repository", + "{\"repo_path\":\"C:/r\"}", + "--response-out", + "C:/c/w.response", + NULL, + }; ASSERT(cmdline_roundtrips(argv)); char cmd[4096]; ASSERT(cbm_build_win_cmdline(cmd, sizeof(cmd), argv)); @@ -330,6 +795,14 @@ SUITE(subprocess) { RUN_TEST(subprocess_run_hang_is_hang); RUN_TEST(subprocess_run_spawn_failure); RUN_TEST(subprocess_run_null_bin_rejected); + RUN_TEST(subprocess_spawn_returns_while_child_is_running); + RUN_TEST(subprocess_natural_completion_is_cached_across_polls); + RUN_TEST(subprocess_cancel_is_idempotent_and_kills_ignoring_tree); + RUN_TEST(subprocess_quiet_timeout_kills_ignoring_tree); + RUN_TEST(subprocess_cancel_grace_is_hard_capped); + RUN_TEST(subprocess_poll_has_a_strict_log_delivery_budget); + RUN_TEST(subprocess_posix_child_closes_unrelated_descriptors); + RUN_TEST(subprocess_root_exit_drains_surviving_descendant); RUN_TEST(win_cmdline_index_worker_json); RUN_TEST(win_cmdline_roundtrip_battery); RUN_TEST(win_cmdline_overflow_rejected); diff --git a/tests/test_ui.c b/tests/test_ui.c index 412522e6b..daabbc05d 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -11,6 +11,9 @@ #include "ui/embedded_assets.h" #include "ui/layout3d.h" #include "store/store.h" +#ifdef _WIN32 +#include "foundation/win_utf8.h" +#endif #include #include @@ -62,7 +65,7 @@ TEST(config_save_and_reload) { /* Save */ cbm_ui_config_t cfg = {.ui_enabled = true, .ui_port = 8080}; - cbm_ui_config_save(&cfg); + ASSERT_TRUE(cbm_ui_config_save(&cfg)); /* Reload */ cbm_ui_config_t loaded; @@ -79,6 +82,80 @@ TEST(config_save_and_reload) { PASS(); } +TEST(config_save_atomically_replaces_a_complete_generation) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_test_config_atomic_XXXXXX"); + char *td = cbm_mkdtemp(tmpdir); + ASSERT_NOT_NULL(td); + + char *old_cache = getenv("CBM_CACHE_DIR") + ? strdup(getenv("CBM_CACHE_DIR")) + : NULL; + ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", td, 1), 0); + + cbm_ui_config_t old_generation = { + .ui_enabled = false, + .ui_port = 11111, + }; + ASSERT_TRUE(cbm_ui_config_save(&old_generation)); + + char path[1024]; + cbm_ui_config_path(path, (int)sizeof(path)); +#ifdef _WIN32 + wchar_t *wide_path = cbm_utf8_to_wide(path); + ASSERT_NOT_NULL(wide_path); + HANDLE old_handle = CreateFileW( + wide_path, GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + free(wide_path); + ASSERT_TRUE(old_handle != INVALID_HANDLE_VALUE); +#else + FILE *old_handle = cbm_fopen(path, "rb"); + ASSERT_NOT_NULL(old_handle); +#endif + + cbm_ui_config_t new_generation = { + .ui_enabled = true, + .ui_port = 22222, + }; + ASSERT_TRUE(cbm_ui_config_save(&new_generation)); + + char old_bytes[512] = {0}; +#ifdef _WIN32 + DWORD old_length = 0; + ASSERT_TRUE(ReadFile(old_handle, old_bytes, + (DWORD)sizeof(old_bytes) - 1U, &old_length, + NULL) != 0); + ASSERT_GT(old_length, 0); + ASSERT_TRUE(CloseHandle(old_handle) != 0); +#else + size_t old_length = fread(old_bytes, 1, sizeof(old_bytes) - 1, + old_handle); + ASSERT_GT(old_length, 0); + ASSERT_EQ(fclose(old_handle), 0); +#endif + + cbm_ui_config_t loaded = {0}; + cbm_ui_config_load(&loaded); + + if (old_cache) { + (void)cbm_setenv("CBM_CACHE_DIR", old_cache, 1); + } else { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(old_cache); + (void)th_rmtree(td); + + /* An in-place truncate/rewrite mutates the already-open handle. Atomic + * replacement leaves it attached to the complete prior generation. */ + ASSERT_NOT_NULL(strstr(old_bytes, "11111")); + ASSERT_NULL(strstr(old_bytes, "22222")); + ASSERT_TRUE(loaded.ui_enabled); + ASSERT_EQ(loaded.ui_port, 22222); + PASS(); +} + TEST(config_overwrite) { char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_test_config_XXXXXX"); @@ -90,11 +167,11 @@ TEST(config_overwrite) { /* Save with ui_enabled=true */ cbm_ui_config_t cfg1 = {.ui_enabled = true, .ui_port = 9749}; - cbm_ui_config_save(&cfg1); + ASSERT_TRUE(cbm_ui_config_save(&cfg1)); /* Overwrite with ui_enabled=false */ cbm_ui_config_t cfg2 = {.ui_enabled = false, .ui_port = 9749}; - cbm_ui_config_save(&cfg2); + ASSERT_TRUE(cbm_ui_config_save(&cfg2)); /* Reload should show false */ cbm_ui_config_t loaded; @@ -787,6 +864,7 @@ SUITE(ui) { /* Config */ RUN_TEST(config_load_defaults); RUN_TEST(config_save_and_reload); + RUN_TEST(config_save_atomically_replaces_a_complete_generation); RUN_TEST(config_overwrite); RUN_TEST(config_corrupt_file); RUN_TEST(config_missing_fields); diff --git a/tests/test_version_cohort.c b/tests/test_version_cohort.c new file mode 100644 index 000000000..04133ad25 --- /dev/null +++ b/tests/test_version_cohort.c @@ -0,0 +1,960 @@ +/* RED contract for exact-build admission shared by one-shot CLI and daemon. */ +#include "test_framework.h" +#include "test_helpers.h" + +#include "daemon/ipc.h" +#include "daemon/service.h" +#include "daemon/version_cohort.h" +#include "foundation/compat.h" +#include "foundation/compat_fs.h" +#include "foundation/compat_thread.h" +#include "foundation/platform.h" +#include "foundation/subprocess.h" + +#include +#include +#include +#include + +#ifdef _WIN32 +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#else +#include +#include +#endif + +enum { VERSION_COHORT_TEST_PATH_CAP = 1024 }; + +static const char VERSION_COHORT_BUILD_A[] = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +static const char VERSION_COHORT_BUILD_B[] = + "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +typedef struct { + char parent[VERSION_COHORT_TEST_PATH_CAP]; + cbm_daemon_ipc_endpoint_t *endpoint; +} version_cohort_fixture_t; + +typedef struct { + cbm_version_cohort_manager_t *manager; + uint64_t deadline_ms; + atomic_int callback_count; + atomic_bool callback_seen; + atomic_bool finished; + cbm_version_cohort_status_t status; + cbm_version_cohort_quiesce_result_t quiesce_result; + cbm_version_cohort_lease_t *lease; +} version_cohort_mutation_wait_t; + +static cbm_daemon_build_identity_t version_cohort_identity( + const char *version, const char *build) { + cbm_daemon_build_identity_t identity = { + .semantic_version = version, + .build_fingerprint = build, + .protocol_abi = 3, + .store_abi = 11, + .feature_abi = 7, + }; + return identity; +} + +static bool version_cohort_fixture_start(version_cohort_fixture_t *fixture, + const char *tag) { + memset(fixture, 0, sizeof(*fixture)); + int written = snprintf(fixture->parent, sizeof(fixture->parent), + "%s/cbm-version-cohort-%s-XXXXXX", cbm_tmpdir(), tag); + if (written <= 0 || written >= (int)sizeof(fixture->parent) || + !cbm_mkdtemp(fixture->parent)) { + return false; + } + fixture->endpoint = + cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture->parent); + return fixture->endpoint != NULL; +} + +static void version_cohort_release(cbm_version_cohort_lease_t **lease) { + while (lease && *lease && + cbm_version_cohort_lease_release(lease) != + CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } +} + +static void version_cohort_manager_close( + cbm_version_cohort_manager_t **manager) { + while (manager && *manager && + cbm_version_cohort_manager_free(manager) != + CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } +} + +static void version_cohort_fixture_finish(version_cohort_fixture_t *fixture) { + cbm_daemon_ipc_endpoint_free(fixture->endpoint); + if (fixture->parent[0]) { + (void)th_rmtree(fixture->parent); + } + memset(fixture, 0, sizeof(*fixture)); +} + +static void version_cohort_mutation_wait_init( + version_cohort_mutation_wait_t *wait, + cbm_version_cohort_manager_t *manager, uint64_t deadline_ms) { + memset(wait, 0, sizeof(*wait)); + wait->manager = manager; + wait->deadline_ms = deadline_ms; + wait->status = CBM_VERSION_COHORT_IO; + wait->quiesce_result = CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + atomic_init(&wait->callback_count, 0); + atomic_init(&wait->callback_seen, false); + atomic_init(&wait->finished, false); +} + +static cbm_version_cohort_quiesce_result_t +version_cohort_test_request_quiesce(void *context) { + version_cohort_mutation_wait_t *wait = context; + (void)atomic_fetch_add_explicit(&wait->callback_count, 1, + memory_order_relaxed); + atomic_store_explicit(&wait->callback_seen, true, memory_order_release); + return CBM_VERSION_COHORT_QUIESCE_REQUESTED; +} + +static void *version_cohort_mutation_wait_thread(void *context) { + version_cohort_mutation_wait_t *wait = context; + wait->status = cbm_version_cohort_reserve_for_mutation( + wait->manager, wait->deadline_ms, version_cohort_test_request_quiesce, + wait, &wait->quiesce_result, &wait->lease); + atomic_store_explicit(&wait->finished, true, memory_order_release); + return NULL; +} + +static bool version_cohort_wait_for_atomic(atomic_bool *value, + uint64_t deadline_ms) { + while (!atomic_load_explicit(value, memory_order_acquire) && + cbm_now_ms() < deadline_ms) { + cbm_usleep(1000); + } + return atomic_load_explicit(value, memory_order_acquire); +} + +TEST(version_cohort_shares_exact_build_rejects_conflict_and_turns_over) { + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "matrix")); + cbm_version_cohort_manager_t *first = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *second = + cbm_version_cohort_manager_new(fixture.endpoint); + ASSERT_NOT_NULL(first); + ASSERT_NOT_NULL(second); + + cbm_daemon_build_identity_t build_a = + version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_daemon_build_identity_t build_b = + version_cohort_identity("2.5.0", VERSION_COHORT_BUILD_B); + cbm_version_cohort_lease_t *a_first = NULL; + cbm_version_cohort_lease_t *a_second = NULL; + cbm_version_cohort_lease_t *b_lease = NULL; + cbm_daemon_conflict_t conflict; + + ASSERT_EQ(cbm_version_cohort_acquire(first, &build_a, UINT64_MAX, + &a_first, &conflict), + CBM_VERSION_COHORT_OK); + ASSERT_NOT_NULL(a_first); + ASSERT_EQ(cbm_version_cohort_acquire(second, &build_a, UINT64_MAX, + &a_second, &conflict), + CBM_VERSION_COHORT_OK); + ASSERT_NOT_NULL(a_second); + ASSERT_EQ(cbm_version_cohort_acquire(second, &build_b, cbm_now_ms(), + &b_lease, &conflict), + CBM_VERSION_COHORT_CONFLICT); + ASSERT_NULL(b_lease); + ASSERT_EQ(conflict.status, CBM_DAEMON_HELLO_VERSION_CONFLICT); + ASSERT_STR_EQ(conflict.active_version, "2.4.0"); + ASSERT_STR_EQ(conflict.requested_version, "2.5.0"); + + version_cohort_release(&a_second); + version_cohort_release(&a_first); + ASSERT_EQ(cbm_version_cohort_acquire(second, &build_b, UINT64_MAX, + &b_lease, &conflict), + CBM_VERSION_COHORT_OK); + ASSERT_NOT_NULL(b_lease); + + version_cohort_release(&b_lease); + version_cohort_manager_close(&second); + version_cohort_manager_close(&first); + version_cohort_fixture_finish(&fixture); + PASS(); +} + +TEST(version_cohort_rejects_same_hash_with_different_abi) { + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "abi")); + cbm_version_cohort_manager_t *first = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *second = + cbm_version_cohort_manager_new(fixture.endpoint); + ASSERT_NOT_NULL(first); + ASSERT_NOT_NULL(second); + cbm_daemon_build_identity_t active = + version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_daemon_build_identity_t requested = active; + requested.feature_abi++; + cbm_version_cohort_lease_t *active_lease = NULL; + cbm_version_cohort_lease_t *requested_lease = NULL; + cbm_daemon_conflict_t conflict; + + ASSERT_EQ(cbm_version_cohort_acquire(first, &active, UINT64_MAX, + &active_lease, &conflict), + CBM_VERSION_COHORT_OK); + ASSERT_EQ(cbm_version_cohort_acquire(second, &requested, cbm_now_ms(), + &requested_lease, &conflict), + CBM_VERSION_COHORT_CONFLICT); + ASSERT_NULL(requested_lease); + ASSERT_EQ(conflict.status, CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT); + + version_cohort_release(&active_lease); + version_cohort_manager_close(&second); + version_cohort_manager_close(&first); + version_cohort_fixture_finish(&fixture); + PASS(); +} + +TEST(version_cohort_exclusive_activation_blocks_and_is_blocked_by_participants) { + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "activation")); + cbm_version_cohort_manager_t *participant_manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *activation_manager = + cbm_version_cohort_manager_new(fixture.endpoint); + ASSERT_NOT_NULL(participant_manager); + ASSERT_NOT_NULL(activation_manager); + cbm_daemon_build_identity_t build_a = + version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_version_cohort_lease_t *participant = NULL; + cbm_version_cohort_lease_t *activation = NULL; + cbm_daemon_conflict_t conflict; + + ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, + UINT64_MAX, &participant, &conflict), + CBM_VERSION_COHORT_OK); + ASSERT_EQ(cbm_version_cohort_reserve_exclusive( + activation_manager, cbm_now_ms(), &activation), + CBM_VERSION_COHORT_BUSY); + ASSERT_NULL(activation); + version_cohort_release(&participant); + + ASSERT_EQ(cbm_version_cohort_reserve_exclusive( + activation_manager, UINT64_MAX, &activation), + CBM_VERSION_COHORT_OK); + ASSERT_NOT_NULL(activation); + ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, + cbm_now_ms(), &participant, &conflict), + CBM_VERSION_COHORT_BUSY); + ASSERT_NULL(participant); + version_cohort_release(&activation); + + ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, + UINT64_MAX, &participant, &conflict), + CBM_VERSION_COHORT_OK); + version_cohort_release(&participant); + version_cohort_manager_close(&activation_manager); + version_cohort_manager_close(&participant_manager); + version_cohort_fixture_finish(&fixture); + PASS(); +} + +TEST(version_cohort_mutation_intent_fails_new_admission_and_spans_lease) { + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "mutation-intent")); + cbm_version_cohort_manager_t *participant_manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *mutation_manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *contender_manager = + cbm_version_cohort_manager_new(fixture.endpoint); + ASSERT_NOT_NULL(participant_manager); + ASSERT_NOT_NULL(mutation_manager); + ASSERT_NOT_NULL(contender_manager); + + cbm_daemon_build_identity_t build_a = + version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_daemon_conflict_t conflict; + cbm_version_cohort_lease_t *participant = NULL; + cbm_version_cohort_lease_t *contender = NULL; + ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, + UINT64_MAX, &participant, &conflict), + CBM_VERSION_COHORT_OK); + cbm_version_cohort_maintenance_presence_t before = + cbm_version_cohort_maintenance_presence(contender_manager); + + version_cohort_mutation_wait_t wait; + version_cohort_mutation_wait_init(&wait, mutation_manager, + cbm_now_ms() + 5000U); + cbm_thread_t thread; + bool started = cbm_thread_create( + &thread, 0, version_cohort_mutation_wait_thread, + &wait) == 0; + bool callback_seen = + started && version_cohort_wait_for_atomic(&wait.callback_seen, + cbm_now_ms() + 2000U); + cbm_version_cohort_maintenance_presence_t during = + callback_seen + ? cbm_version_cohort_maintenance_presence(contender_manager) + : CBM_VERSION_COHORT_MAINTENANCE_IO; + cbm_version_cohort_status_t racing_status = + callback_seen + ? cbm_version_cohort_acquire(contender_manager, &build_a, + UINT64_MAX, &contender, &conflict) + : CBM_VERSION_COHORT_IO; + bool racing_lease_absent = contender == NULL; + bool still_draining = + callback_seen && + !atomic_load_explicit(&wait.finished, memory_order_acquire); + + version_cohort_release(&contender); + version_cohort_release(&participant); + bool finished = + started && version_cohort_wait_for_atomic(&wait.finished, + cbm_now_ms() + 5500U); + bool joined = started && cbm_thread_join(&thread) == 0; + cbm_version_cohort_maintenance_presence_t retained = + finished && wait.lease + ? cbm_version_cohort_maintenance_presence(contender_manager) + : CBM_VERSION_COHORT_MAINTENANCE_IO; + cbm_version_cohort_status_t retained_admission_status = + finished && wait.lease + ? cbm_version_cohort_acquire(contender_manager, &build_a, + UINT64_MAX, &contender, &conflict) + : CBM_VERSION_COHORT_IO; + bool retained_admission_absent = contender == NULL; + version_cohort_release(&contender); + version_cohort_release(&wait.lease); + cbm_version_cohort_maintenance_presence_t after = + cbm_version_cohort_maintenance_presence(contender_manager); + cbm_version_cohort_status_t post_status = + cbm_version_cohort_acquire(contender_manager, &build_a, UINT64_MAX, + &contender, &conflict); + + version_cohort_release(&contender); + version_cohort_manager_close(&contender_manager); + version_cohort_manager_close(&mutation_manager); + version_cohort_manager_close(&participant_manager); + version_cohort_fixture_finish(&fixture); + + ASSERT_EQ(before, CBM_VERSION_COHORT_MAINTENANCE_ABSENT); + ASSERT_TRUE(started); + ASSERT_TRUE(callback_seen); + ASSERT_EQ(during, CBM_VERSION_COHORT_MAINTENANCE_REQUESTED); + ASSERT_EQ(racing_status, CBM_VERSION_COHORT_BUSY); + ASSERT_TRUE(racing_lease_absent); + ASSERT_TRUE(still_draining); + ASSERT_TRUE(finished); + ASSERT_TRUE(joined); + ASSERT_EQ(wait.status, CBM_VERSION_COHORT_OK); + ASSERT_EQ(wait.quiesce_result, + CBM_VERSION_COHORT_QUIESCE_REQUESTED); + ASSERT_EQ(atomic_load_explicit(&wait.callback_count, + memory_order_relaxed), + 1); + ASSERT_EQ(retained, CBM_VERSION_COHORT_MAINTENANCE_REQUESTED); + ASSERT_EQ(retained_admission_status, CBM_VERSION_COHORT_BUSY); + ASSERT_TRUE(retained_admission_absent); + ASSERT_EQ(after, CBM_VERSION_COHORT_MAINTENANCE_ABSENT); + ASSERT_EQ(post_status, CBM_VERSION_COHORT_OK); + PASS(); +} + +TEST(version_cohort_mutation_waits_for_every_lifetime_participant) { + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "mutation-drain")); + cbm_version_cohort_manager_t *first_manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *second_manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *mutation_manager = + cbm_version_cohort_manager_new(fixture.endpoint); + ASSERT_NOT_NULL(first_manager); + ASSERT_NOT_NULL(second_manager); + ASSERT_NOT_NULL(mutation_manager); + + cbm_daemon_build_identity_t build_a = + version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_daemon_conflict_t conflict; + cbm_version_cohort_lease_t *first = NULL; + cbm_version_cohort_lease_t *second = NULL; + ASSERT_EQ(cbm_version_cohort_acquire(first_manager, &build_a, UINT64_MAX, + &first, &conflict), + CBM_VERSION_COHORT_OK); + ASSERT_EQ(cbm_version_cohort_acquire(second_manager, &build_a, + UINT64_MAX, &second, &conflict), + CBM_VERSION_COHORT_OK); + + version_cohort_mutation_wait_t wait; + version_cohort_mutation_wait_init(&wait, mutation_manager, + cbm_now_ms() + 5000U); + cbm_thread_t thread; + bool started = cbm_thread_create( + &thread, 0, version_cohort_mutation_wait_thread, + &wait) == 0; + bool callback_seen = + started && version_cohort_wait_for_atomic(&wait.callback_seen, + cbm_now_ms() + 2000U); + version_cohort_release(&first); + cbm_usleep(20000); + bool finished_after_one = + atomic_load_explicit(&wait.finished, memory_order_acquire); + version_cohort_release(&second); + bool finished = + started && version_cohort_wait_for_atomic(&wait.finished, + cbm_now_ms() + 5500U); + bool joined = started && cbm_thread_join(&thread) == 0; + + version_cohort_release(&wait.lease); + version_cohort_manager_close(&mutation_manager); + version_cohort_manager_close(&second_manager); + version_cohort_manager_close(&first_manager); + version_cohort_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(callback_seen); + ASSERT_FALSE(finished_after_one); + ASSERT_TRUE(finished); + ASSERT_TRUE(joined); + ASSERT_EQ(wait.status, CBM_VERSION_COHORT_OK); + ASSERT_EQ(wait.quiesce_result, + CBM_VERSION_COHORT_QUIESCE_REQUESTED); + ASSERT_EQ(atomic_load_explicit(&wait.callback_count, + memory_order_relaxed), + 1); + PASS(); +} + +TEST(version_cohort_mutation_timeout_releases_all_guards) { + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "mutation-timeout")); + cbm_version_cohort_manager_t *participant_manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *mutation_manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *probe_manager = + cbm_version_cohort_manager_new(fixture.endpoint); + ASSERT_NOT_NULL(participant_manager); + ASSERT_NOT_NULL(mutation_manager); + ASSERT_NOT_NULL(probe_manager); + + cbm_daemon_build_identity_t build_a = + version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_daemon_conflict_t conflict; + cbm_version_cohort_lease_t *participant = NULL; + cbm_version_cohort_lease_t *mutation = NULL; + cbm_version_cohort_lease_t *probe = NULL; + ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, + UINT64_MAX, &participant, &conflict), + CBM_VERSION_COHORT_OK); + + version_cohort_mutation_wait_t callback; + version_cohort_mutation_wait_init(&callback, mutation_manager, + cbm_now_ms() + 25U); + cbm_version_cohort_quiesce_result_t quiesce_result = + CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + cbm_version_cohort_status_t timeout_status = + cbm_version_cohort_reserve_for_mutation( + mutation_manager, callback.deadline_ms, + version_cohort_test_request_quiesce, &callback, &quiesce_result, + &mutation); + bool no_mutation_authority = mutation == NULL; + cbm_version_cohort_maintenance_presence_t after_timeout = + cbm_version_cohort_maintenance_presence(probe_manager); + cbm_version_cohort_status_t admission_status = + cbm_version_cohort_acquire(probe_manager, &build_a, UINT64_MAX, + &probe, &conflict); + + version_cohort_release(&probe); + version_cohort_release(&participant); + cbm_version_cohort_quiesce_result_t invalid_result = + CBM_VERSION_COHORT_QUIESCE_REQUESTED; + cbm_version_cohort_status_t unbounded_status = + cbm_version_cohort_reserve_for_mutation( + mutation_manager, UINT64_MAX, version_cohort_test_request_quiesce, + &callback, &invalid_result, &mutation); + bool unbounded_lease_absent = mutation == NULL; + cbm_version_cohort_quiesce_result_t retry_result = + CBM_VERSION_COHORT_QUIESCE_REQUESTED; + cbm_version_cohort_status_t retry_status = + cbm_version_cohort_reserve_for_mutation( + mutation_manager, cbm_now_ms() + 250U, + version_cohort_test_request_quiesce, &callback, &retry_result, + &mutation); + cbm_version_cohort_maintenance_presence_t during_retry = + mutation ? cbm_version_cohort_maintenance_presence(probe_manager) + : CBM_VERSION_COHORT_MAINTENANCE_IO; + int callback_count_after_retry = atomic_load_explicit( + &callback.callback_count, memory_order_relaxed); + + version_cohort_release(&mutation); + cbm_version_cohort_maintenance_presence_t after_retry = + cbm_version_cohort_maintenance_presence(probe_manager); + version_cohort_manager_close(&probe_manager); + version_cohort_manager_close(&mutation_manager); + version_cohort_manager_close(&participant_manager); + version_cohort_fixture_finish(&fixture); + + ASSERT_EQ(timeout_status, CBM_VERSION_COHORT_BUSY); + ASSERT_TRUE(no_mutation_authority); + ASSERT_EQ(quiesce_result, CBM_VERSION_COHORT_QUIESCE_REQUESTED); + ASSERT_EQ(atomic_load_explicit(&callback.callback_count, + memory_order_relaxed), + 1); + ASSERT_EQ(after_timeout, CBM_VERSION_COHORT_MAINTENANCE_ABSENT); + ASSERT_EQ(admission_status, CBM_VERSION_COHORT_OK); + ASSERT_EQ(unbounded_status, CBM_VERSION_COHORT_UNSAFE); + ASSERT_EQ(invalid_result, CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED); + ASSERT_TRUE(unbounded_lease_absent); + ASSERT_EQ(retry_status, CBM_VERSION_COHORT_OK); + ASSERT_EQ(retry_result, CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED); + ASSERT_EQ(during_retry, CBM_VERSION_COHORT_MAINTENANCE_REQUESTED); + ASSERT_EQ(callback_count_after_retry, 1); + ASSERT_EQ(after_retry, CBM_VERSION_COHORT_MAINTENANCE_ABSENT); + PASS(); +} + +TEST(version_cohort_does_not_repurpose_daemon_startup_lock_for_lifetime) { + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "startup-independent")); + cbm_daemon_ipc_startup_lock_t *startup = NULL; + ASSERT_EQ(cbm_daemon_ipc_startup_lock_try_acquire(fixture.endpoint, + &startup), + 1); + ASSERT_NOT_NULL(startup); + cbm_version_cohort_manager_t *manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_daemon_build_identity_t build_a = + version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_version_cohort_lease_t *lease = NULL; + cbm_daemon_conflict_t conflict; + ASSERT_NOT_NULL(manager); + ASSERT_EQ(cbm_version_cohort_acquire(manager, &build_a, UINT64_MAX, + &lease, &conflict), + CBM_VERSION_COHORT_OK); + ASSERT_NOT_NULL(lease); + + cbm_daemon_ipc_startup_lock_release(&startup); + version_cohort_release(&lease); + version_cohort_manager_close(&manager); + version_cohort_fixture_finish(&fixture); + PASS(); +} + +TEST(version_cohort_distinguishes_coordinated_daemon_without_connecting) { + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "daemon-marker")); + cbm_version_cohort_manager_t *manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_daemon_claim_t *claim = NULL; + cbm_daemon_ipc_lifetime_reservation_t *lifetime = NULL; + cbm_daemon_ipc_startup_lock_t *startup = NULL; + ASSERT_NOT_NULL(manager); + + ASSERT_EQ(cbm_version_cohort_daemon_presence(manager, fixture.endpoint), + CBM_VERSION_COHORT_DAEMON_ABSENT); + + /* Startup is also part of the migration boundary: on POSIX the legacy and + * current startup locks are the same; on Windows current startup retains + * the security-validated legacy mutex as an interlock. */ + ASSERT_EQ(cbm_daemon_ipc_startup_lock_try_acquire(fixture.endpoint, + &startup), + 1); + ASSERT_NOT_NULL(startup); + ASSERT_EQ(cbm_version_cohort_daemon_presence(manager, fixture.endpoint), + CBM_VERSION_COHORT_DAEMON_UNCOORDINATED); + cbm_daemon_ipc_startup_lock_release(&startup); + startup = NULL; + ASSERT_EQ(cbm_version_cohort_daemon_presence(manager, fixture.endpoint), + CBM_VERSION_COHORT_DAEMON_ABSENT); + + /* A pre-cohort daemon owns the stable daemon lifetime reservation but + * cannot own the new crash-released coordination marker. The local CLI + * must fail closed without opening a protocol connection. */ + ASSERT_EQ(cbm_daemon_ipc_lifetime_reservation_try_acquire( + fixture.endpoint, &lifetime), + 1); + ASSERT_NOT_NULL(lifetime); + ASSERT_EQ(cbm_version_cohort_daemon_presence(manager, fixture.endpoint), + CBM_VERSION_COHORT_DAEMON_UNCOORDINATED); + + ASSERT_EQ(cbm_version_cohort_daemon_claim_acquire(manager, &claim), + CBM_VERSION_COHORT_OK); + ASSERT_NOT_NULL(claim); + ASSERT_EQ(cbm_version_cohort_daemon_presence(manager, fixture.endpoint), + CBM_VERSION_COHORT_DAEMON_COORDINATED); + + /* RED for daemon turnover: listener/lifetime teardown precedes final + * application/log cleanup. The still-held exact-generation marker is + * authoritative during that window, so a new bootstrap waits instead of + * starting a replacement against the old daemon's live state. */ + cbm_daemon_ipc_lifetime_reservation_release(lifetime); + lifetime = NULL; + ASSERT_EQ(cbm_version_cohort_daemon_presence(manager, fixture.endpoint), + CBM_VERSION_COHORT_DAEMON_COORDINATED); + + ASSERT_EQ(cbm_version_cohort_daemon_claim_release(&claim), + CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(claim); + ASSERT_EQ(cbm_version_cohort_daemon_presence(manager, fixture.endpoint), + CBM_VERSION_COHORT_DAEMON_ABSENT); + + version_cohort_manager_close(&manager); + version_cohort_fixture_finish(&fixture); + PASS(); +} + +TEST(version_cohort_transition_presence_is_authoritative_and_marker_checked) { + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, + "transition-presence")); + cbm_version_cohort_manager_t *manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_daemon_ipc_local_transition_t *transition = NULL; + cbm_daemon_ipc_lifetime_reservation_t *lifetime = NULL; + cbm_version_cohort_daemon_claim_t *claim = NULL; + ASSERT_NOT_NULL(manager); + + ASSERT_EQ(cbm_daemon_ipc_local_transition_try_acquire( + fixture.endpoint, &transition), + 1); + ASSERT_NOT_NULL(transition); + ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( + manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_UNSAFE); + ASSERT_EQ(cbm_daemon_ipc_local_transition_seal_legacy(transition), 1); + ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( + manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_ABSENT); + ASSERT_TRUE(cbm_daemon_ipc_local_transition_release(&transition)); + ASSERT_NULL(transition); + + ASSERT_EQ(cbm_daemon_ipc_lifetime_reservation_try_acquire( + fixture.endpoint, &lifetime), + 1); + ASSERT_NOT_NULL(lifetime); + ASSERT_EQ(cbm_daemon_ipc_local_transition_try_acquire( + fixture.endpoint, &transition), + 1); + ASSERT_NOT_NULL(transition); + ASSERT_EQ(cbm_daemon_ipc_local_transition_seal_legacy(transition), 1); + ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( + manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_UNCOORDINATED); + + ASSERT_EQ(cbm_version_cohort_daemon_claim_acquire(manager, &claim), + CBM_VERSION_COHORT_OK); + ASSERT_NOT_NULL(claim); + ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( + manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_COORDINATED); + + ASSERT_EQ(cbm_version_cohort_daemon_claim_release(&claim), + CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_NULL(claim); + ASSERT_TRUE(cbm_daemon_ipc_local_transition_release(&transition)); + ASSERT_NULL(transition); + cbm_daemon_ipc_lifetime_reservation_release(lifetime); + lifetime = NULL; + version_cohort_manager_close(&manager); + version_cohort_fixture_finish(&fixture); + PASS(); +} + +TEST(version_cohort_transition_shutdown_order_has_no_false_conflict) { + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, + "transition-shutdown")); + cbm_version_cohort_manager_t *manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_daemon_ipc_lifetime_reservation_t *lifetime = NULL; + cbm_version_cohort_daemon_claim_t *claim = NULL; + cbm_daemon_ipc_local_transition_t *transition = NULL; + ASSERT_NOT_NULL(manager); + + ASSERT_EQ(cbm_daemon_ipc_lifetime_reservation_try_acquire( + fixture.endpoint, &lifetime), + 1); + ASSERT_EQ(cbm_version_cohort_daemon_claim_acquire(manager, &claim), + CBM_VERSION_COHORT_OK); + ASSERT_EQ(cbm_daemon_ipc_local_transition_try_acquire( + fixture.endpoint, &transition), + 1); + ASSERT_EQ(cbm_daemon_ipc_local_transition_seal_legacy(transition), 1); + ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( + manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_COORDINATED); + + /* Host teardown closes listener/lifetime before its daemon marker. The + * overlap is still coordinated, never the pre-cohort conflict state. */ + cbm_daemon_ipc_lifetime_reservation_release(lifetime); + lifetime = NULL; + ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( + manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_COORDINATED); + ASSERT_EQ(cbm_version_cohort_daemon_claim_release(&claim), + CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( + manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_ABSENT); + + ASSERT_TRUE(cbm_daemon_ipc_local_transition_release(&transition)); + version_cohort_manager_close(&manager); + version_cohort_fixture_finish(&fixture); + PASS(); +} + +TEST(version_cohort_presence_recovers_current_posix_listener_crash) { +#ifdef _WIN32 + PASS(); +#else + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "daemon-crash")); + int ready_pipe[2] = {-1, -1}; + ASSERT_EQ(pipe(ready_pipe), 0); + pid_t child = fork(); + if (child == 0) { + (void)close(ready_pipe[0]); + cbm_daemon_ipc_listener_t *listener = + cbm_daemon_ipc_listen(fixture.endpoint); + char ready = listener ? 'R' : 'E'; + ssize_t reported = write(ready_pipe[1], &ready, 1); + (void)close(ready_pipe[1]); + /* Simulate a process crash: no listener_close and therefore no + * userspace socket/identity cleanup. */ + _exit(listener && reported == 1 ? 0 : 1); + } + ASSERT_GT(child, 0); + (void)close(ready_pipe[1]); + char ready = 0; + ASSERT_EQ(read(ready_pipe[0], &ready, 1), 1); + (void)close(ready_pipe[0]); + ASSERT_EQ(ready, 'R'); + int child_status = 0; + ASSERT_EQ(waitpid(child, &child_status, 0), child); + ASSERT_TRUE(WIFEXITED(child_status)); + ASSERT_EQ(WEXITSTATUS(child_status), 0); + ASSERT_EQ(cbm_daemon_ipc_lifetime_reservation_probe(fixture.endpoint), 0); + ASSERT_EQ(cbm_daemon_ipc_endpoint_probe(fixture.endpoint, 1), 1); + + cbm_version_cohort_manager_t *manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_daemon_ipc_local_transition_t *transition = NULL; + ASSERT_NOT_NULL(manager); + ASSERT_EQ(cbm_daemon_ipc_local_transition_try_acquire( + fixture.endpoint, &transition), + 1); + ASSERT_NOT_NULL(transition); + ASSERT_EQ(cbm_daemon_ipc_local_transition_seal_legacy(transition), 1); + ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( + manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_ABSENT); + ASSERT_EQ(cbm_daemon_ipc_endpoint_probe(fixture.endpoint, 1), 0); + + ASSERT_TRUE(cbm_daemon_ipc_local_transition_release(&transition)); + ASSERT_NULL(transition); + version_cohort_manager_close(&manager); + version_cohort_fixture_finish(&fixture); + PASS(); +#endif +} + +TEST(version_cohort_crash_releases_process_lifetime_lease) { +#ifdef _WIN32 + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "crash-win")); + char ready_path[VERSION_COHORT_TEST_PATH_CAP]; + char self[MAX_PATH]; + DWORD self_length = GetModuleFileNameA(NULL, self, sizeof(self)); + int ready_length = snprintf(ready_path, sizeof(ready_path), "%s/ready", + fixture.parent); + bool launch_ready = self_length > 0 && self_length < sizeof(self) && + ready_length > 0 && + ready_length < (int)sizeof(ready_path); + const char *const argv[] = { + self, + "__cbm_version_cohort_crash_holder", + "0123456789abcdef", + fixture.parent, + ready_path, + NULL, + }; + cbm_proc_opts_t options = { + .bin = self, + .argv = argv, + .quiet_timeout_ms = 2000, + .cancel_grace_ms = 1, + }; + cbm_subprocess_t *child = NULL; + int spawn_status = + launch_ready ? cbm_subprocess_spawn(&options, &child) : -1; + bool ready = false; + bool terminal = false; + cbm_proc_result_t process_result = {0}; + uint64_t ready_deadline = cbm_now_ms() + 5000U; + while (child && cbm_now_ms() < ready_deadline) { + cbm_proc_poll_t poll = cbm_subprocess_poll(child, &process_result); + if (poll == CBM_PROC_POLL_TERMINAL) { + terminal = true; + break; + } + if (poll == CBM_PROC_POLL_ERROR) { + break; + } + FILE *marker = cbm_fopen(ready_path, "rb"); + if (marker) { + ready = fgetc(marker) == 'R'; + (void)fclose(marker); + } + if (ready) { + break; + } + cbm_usleep(1000); + } + + cbm_version_cohort_manager_t *manager = + ready ? cbm_version_cohort_manager_new(fixture.endpoint) : NULL; + cbm_daemon_build_identity_t build_b = + version_cohort_identity("2.5.0", VERSION_COHORT_BUILD_B); + cbm_version_cohort_lease_t *lease = NULL; + cbm_daemon_conflict_t conflict; + cbm_version_cohort_status_t conflict_status = + manager ? cbm_version_cohort_acquire(manager, &build_b, cbm_now_ms(), + &lease, &conflict) + : CBM_VERSION_COHORT_IO; + bool conflict_lease_absent = lease == NULL; + version_cohort_release(&lease); + bool cancel_requested = + child && !terminal && cbm_subprocess_request_cancel(child); + uint64_t terminal_deadline = cbm_now_ms() + 5000U; + while (child && !terminal && cbm_now_ms() < terminal_deadline) { + cbm_proc_poll_t poll = cbm_subprocess_poll(child, &process_result); + if (poll == CBM_PROC_POLL_TERMINAL) { + terminal = true; + break; + } + if (poll == CBM_PROC_POLL_ERROR) { + break; + } + cbm_usleep(1000); + } + cbm_version_cohort_status_t turnover_status = + manager && terminal + ? cbm_version_cohort_acquire(manager, &build_b, UINT64_MAX, + &lease, &conflict) + : CBM_VERSION_COHORT_IO; + + version_cohort_release(&lease); + version_cohort_manager_close(&manager); + if (child && terminal) { + cbm_subprocess_destroy(child); + child = NULL; + } + (void)cbm_unlink(ready_path); + version_cohort_fixture_finish(&fixture); + + ASSERT_TRUE(launch_ready); + ASSERT_EQ(spawn_status, 0); + ASSERT_TRUE(ready); + ASSERT_EQ(conflict_status, CBM_VERSION_COHORT_CONFLICT); + ASSERT_TRUE(conflict_lease_absent); + ASSERT_EQ(conflict.status, CBM_DAEMON_HELLO_VERSION_CONFLICT); + ASSERT_TRUE(cancel_requested); + ASSERT_TRUE(terminal); + ASSERT_TRUE(process_result.tree_quiesced); + ASSERT_FALSE(process_result.supervision_failed); + ASSERT_EQ(process_result.outcome, CBM_PROC_KILLED); + ASSERT_EQ(turnover_status, CBM_VERSION_COHORT_OK); + PASS(); +#else + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "crash")); + int ready_pipe[2] = {-1, -1}; + int command_pipe[2] = {-1, -1}; + ASSERT_EQ(pipe(ready_pipe), 0); + ASSERT_EQ(pipe(command_pipe), 0); + pid_t child = fork(); + if (child == 0) { + (void)close(ready_pipe[0]); + (void)close(command_pipe[1]); + cbm_version_cohort_manager_t *manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_daemon_build_identity_t build_a = + version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_version_cohort_lease_t *lease = NULL; + cbm_daemon_conflict_t conflict; + bool acquired = manager && + cbm_version_cohort_acquire( + manager, &build_a, UINT64_MAX, &lease, &conflict) == + CBM_VERSION_COHORT_OK; + char ready = acquired ? 'R' : 'E'; + ssize_t ignored = write(ready_pipe[1], &ready, 1); + (void)ignored; + (void)close(ready_pipe[1]); + char command = 0; + ssize_t commanded = read(command_pipe[0], &command, 1); + (void)close(command_pipe[0]); + _exit(acquired && commanded == 1 && command == 'X' ? 0 : 1); + /* no release: kernel must drop the lease */ + } + (void)close(ready_pipe[1]); + (void)close(command_pipe[0]); + char ready = 0; + ssize_t received = read(ready_pipe[0], &ready, 1); + (void)close(ready_pipe[0]); + ASSERT_EQ(received, 1); + ASSERT_EQ(ready, 'R'); + ASSERT_GT(child, 0); + + cbm_version_cohort_manager_t *manager = + cbm_version_cohort_manager_new(fixture.endpoint); + cbm_daemon_build_identity_t build_b = + version_cohort_identity("2.5.0", VERSION_COHORT_BUILD_B); + cbm_version_cohort_lease_t *lease = NULL; + cbm_daemon_conflict_t conflict; + ASSERT_NOT_NULL(manager); + ASSERT_EQ(cbm_version_cohort_acquire(manager, &build_b, cbm_now_ms(), + &lease, &conflict), + CBM_VERSION_COHORT_CONFLICT); + ASSERT_NULL(lease); + ASSERT_EQ(conflict.status, CBM_DAEMON_HELLO_VERSION_CONFLICT); + + char exit_command = 'X'; + ASSERT_EQ(write(command_pipe[1], &exit_command, 1), 1); + (void)close(command_pipe[1]); + int child_status = 0; + ASSERT_EQ(waitpid(child, &child_status, 0), child); + ASSERT_TRUE(WIFEXITED(child_status)); + ASSERT_EQ(WEXITSTATUS(child_status), 0); + + ASSERT_EQ(cbm_version_cohort_acquire(manager, &build_b, UINT64_MAX, + &lease, &conflict), + CBM_VERSION_COHORT_OK); + version_cohort_release(&lease); + version_cohort_manager_close(&manager); + version_cohort_fixture_finish(&fixture); + PASS(); +#endif +} + +SUITE(version_cohort) { + RUN_TEST(version_cohort_shares_exact_build_rejects_conflict_and_turns_over); + RUN_TEST(version_cohort_rejects_same_hash_with_different_abi); + RUN_TEST(version_cohort_exclusive_activation_blocks_and_is_blocked_by_participants); + RUN_TEST(version_cohort_mutation_intent_fails_new_admission_and_spans_lease); + RUN_TEST(version_cohort_mutation_waits_for_every_lifetime_participant); + RUN_TEST(version_cohort_mutation_timeout_releases_all_guards); + RUN_TEST(version_cohort_does_not_repurpose_daemon_startup_lock_for_lifetime); + RUN_TEST(version_cohort_distinguishes_coordinated_daemon_without_connecting); + RUN_TEST(version_cohort_transition_presence_is_authoritative_and_marker_checked); + RUN_TEST(version_cohort_transition_shutdown_order_has_no_false_conflict); + RUN_TEST(version_cohort_presence_recovers_current_posix_listener_crash); + RUN_TEST(version_cohort_crash_releases_process_lifetime_lease); +} diff --git a/tests/test_watcher.c b/tests/test_watcher.c index 6e3c32f7b..2b07e31ca 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -8,6 +8,7 @@ #include "../src/foundation/platform.h" #include "test_framework.h" #include "test_helpers.h" +#include #include #include #include @@ -254,6 +255,37 @@ static void prune_fixture_teardown(prune_fixture_t *f) { th_rmtree(f->cachedir); } +typedef struct { + const char *root_to_restore; + bool allow; + bool restore_root; + int begins; + int ends; + int pruned; +} prune_guard_probe_t; + +static bool prune_guard_probe_begin(void *context, const char *project) { + (void)project; + prune_guard_probe_t *probe = context; + probe->begins++; + if (probe->restore_root && probe->root_to_restore) { + (void)cbm_mkdir_p(probe->root_to_restore, 0755); + } + return probe->allow; +} + +static void prune_guard_probe_end(void *context, const char *project) { + (void)project; + prune_guard_probe_t *probe = context; + probe->ends++; +} + +static void prune_guard_probe_pruned(void *context, const char *project) { + (void)project; + prune_guard_probe_t *probe = context; + probe->pruned++; +} + TEST(watcher_prunes_sustained_missing_root) { /* Positive prune path. Grace window 0s isolates the streak-threshold * logic; the time gate is guarded by watcher_grace_window_blocks_prune. */ @@ -298,6 +330,180 @@ TEST(watcher_prunes_sustained_missing_root) { PASS(); } +TEST(watcher_prune_waits_for_daemon_project_mutation) { + /* The daemon application owns project-operation coordination. Supplying + * its watcher must route destructive stale-root pruning through that + * coordination boundary: a busy project is retained and retried after + * the active operation releases its lease, never unlinked directly. */ + prune_fixture_t f; + if (!prune_fixture_setup(&f, "0")) { + FAIL("prune fixture setup failed"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_daemon_application_config_t config = {.watcher = w}; + cbm_daemon_application_t *application = + cbm_daemon_application_new(&config); + if (!store || !w || !application) { + cbm_daemon_application_free(application); + cbm_watcher_free(w); + cbm_store_close(store); + prune_fixture_teardown(&f); + FAIL("coordinated prune fixture setup failed"); + } + + cbm_watcher_watch(w, "stale-project", f.rootdir); + cbm_watcher_poll_once(w); /* establish the existing-root baseline */ + th_rmtree(f.rootdir); + + bool mutation_held = + cbm_daemon_application_project_mutation_try_begin(application, + "stale-project"); + for (int miss = 0; mutation_held && miss < 3; miss++) { + cbm_watcher_touch(w, "stale-project"); + cbm_watcher_poll_once(w); + } + + bool watch_preserved_while_busy = cbm_watcher_watch_count(w) == 1; + bool files_preserved_while_busy = access(f.db_path, F_OK) == 0 && + access(f.wal_path, F_OK) == 0 && + access(f.shm_path, F_OK) == 0; + + if (mutation_held) { + cbm_daemon_application_project_mutation_end(application, "stale-project"); + } + if (cbm_watcher_watch_count(w) == 1) { + cbm_watcher_touch(w, "stale-project"); + cbm_watcher_poll_once(w); + } + bool pruned_after_release = cbm_watcher_watch_count(w) == 0 && + access(f.db_path, F_OK) != 0 && + access(f.wal_path, F_OK) != 0 && + access(f.shm_path, F_OK) != 0; + + cbm_daemon_application_free(application); + cbm_watcher_free(w); + cbm_store_close(store); + prune_fixture_teardown(&f); + + ASSERT_TRUE(mutation_held); + ASSERT_TRUE(watch_preserved_while_busy); + ASSERT_TRUE(files_preserved_while_busy); + ASSERT_TRUE(pruned_after_release); + PASS(); +} + +TEST(watcher_prune_guard_denial_and_success_are_balanced) { + prune_fixture_t f; + if (!prune_fixture_setup(&f, "0")) { + FAIL("prune fixture setup failed"); + } + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + prune_guard_probe_t probe = {0}; + cbm_watcher_set_project_mutation_guard( + w, prune_guard_probe_begin, prune_guard_probe_end, + prune_guard_probe_pruned, &probe); + cbm_watcher_watch(w, "stale-project", f.rootdir); + cbm_watcher_poll_once(w); + th_rmtree(f.rootdir); + for (int miss = 0; miss < 3; miss++) { + cbm_watcher_touch(w, "stale-project"); + cbm_watcher_poll_once(w); + } + bool denied_preserved = cbm_watcher_watch_count(w) == 1 && + access(f.db_path, F_OK) == 0 && probe.begins == 1 && + probe.ends == 0 && probe.pruned == 0; + + probe.allow = true; + cbm_watcher_touch(w, "stale-project"); + cbm_watcher_poll_once(w); + bool success_balanced = cbm_watcher_watch_count(w) == 0 && + access(f.db_path, F_OK) != 0 && probe.begins == 2 && + probe.ends == 1 && probe.pruned == 1; + + cbm_watcher_set_project_mutation_guard(w, NULL, NULL, NULL, NULL); + cbm_watcher_free(w); + cbm_store_close(store); + prune_fixture_teardown(&f); + ASSERT_TRUE(denied_preserved); + ASSERT_TRUE(success_balanced); + PASS(); +} + +TEST(watcher_prune_restats_root_after_guard_acquisition) { + prune_fixture_t f; + if (!prune_fixture_setup(&f, "0")) { + FAIL("prune fixture setup failed"); + } + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + prune_guard_probe_t probe = { + .root_to_restore = f.rootdir, + .allow = true, + .restore_root = true, + }; + cbm_watcher_set_project_mutation_guard( + w, prune_guard_probe_begin, prune_guard_probe_end, + prune_guard_probe_pruned, &probe); + cbm_watcher_watch(w, "stale-project", f.rootdir); + cbm_watcher_poll_once(w); + th_rmtree(f.rootdir); + for (int miss = 0; miss < 3; miss++) { + cbm_watcher_touch(w, "stale-project"); + cbm_watcher_poll_once(w); + } + + bool restored = access(f.rootdir, F_OK) == 0; + bool retained = cbm_watcher_watch_count(w) == 1 && + access(f.db_path, F_OK) == 0 && access(f.wal_path, F_OK) == 0 && + access(f.shm_path, F_OK) == 0; + bool balanced = probe.begins == 1 && probe.ends == 1 && probe.pruned == 0; + cbm_watcher_set_project_mutation_guard(w, NULL, NULL, NULL, NULL); + cbm_watcher_free(w); + cbm_store_close(store); + prune_fixture_teardown(&f); + ASSERT_TRUE(restored); + ASSERT_TRUE(retained); + ASSERT_TRUE(balanced); + PASS(); +} + +TEST(watcher_prune_delete_failure_retains_watch_for_retry) { + prune_fixture_t f; + if (!prune_fixture_setup(&f, "0")) { + FAIL("prune fixture setup failed"); + } + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "stale-project", f.rootdir); + cbm_watcher_poll_once(w); + th_rmtree(f.rootdir); + + /* A directory at the main DB path makes unlink fail deterministically on + * POSIX and Windows. The watcher must remain registered for a later retry + * and must not delete recoverable sidecars after that failure. */ + bool failure_ready = cbm_unlink(f.db_path) == 0 && + cbm_mkdir_p(f.db_path, 0755); + for (int miss = 0; failure_ready && miss < 3; miss++) { + cbm_watcher_touch(w, "stale-project"); + cbm_watcher_poll_once(w); + } + bool watch_retained = cbm_watcher_watch_count(w) == 1; + bool artifacts_retained = access(f.db_path, F_OK) == 0 && + access(f.wal_path, F_OK) == 0 && + access(f.shm_path, F_OK) == 0; + + cbm_watcher_free(w); + cbm_store_close(store); + prune_fixture_teardown(&f); + ASSERT_TRUE(failure_ready); + ASSERT_TRUE(watch_retained); + ASSERT_TRUE(artifacts_retained); + PASS(); +} + TEST(watcher_grace_window_blocks_prune) { /* 3+ missing polls but elapsed < grace → NOT pruned. Uses an explicit * 600s window so a fast poll burst can never satisfy the time gate. */ @@ -553,6 +759,52 @@ TEST(watcher_detects_dirty_worktree) { PASS(); } +TEST(watcher_identical_watch_preserves_dirty_baseline) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_same_root_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + FAIL("cbm_mkdtemp failed"); + + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } + wt_git(tmpdir, "add file.txt"); + wt_git(tmpdir, "commit -q -m init"); + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + + cbm_watcher_watch(w, "same-root-repo", tmpdir); + index_call_count = 0; + + /* Establish the clean baseline before making the worktree dirty. */ + ASSERT_EQ(cbm_watcher_poll_once(w), 0); + ASSERT_EQ(index_call_count, 0); + + { + char p[300]; + th_append_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "modified\n"); + } + + cbm_watcher_touch(w, "same-root-repo"); + + /* An identical registration must preserve the established baseline. */ + cbm_watcher_watch(w, "same-root-repo", tmpdir); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + ASSERT_EQ(cbm_watcher_poll_once(w), 1); + ASSERT_EQ(index_call_count, 1); + + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(tmpdir); + PASS(); +} + TEST(watcher_detects_new_file) { /* Create a temporary git repo */ char tmpdir[256]; @@ -2042,6 +2294,145 @@ TEST(watcher_unwatch_drains_pending_free) { PASS(); } +typedef struct { + cbm_watcher_t *watcher; + const char *first_project; + const char *second_project; + int calls; +} unwatch_snapshot_ctx_t; + +static int unwatch_snapshot_callback(const char *name, const char *path, void *ud) { + (void)name; + (void)path; + unwatch_snapshot_ctx_t *ctx = ud; + ctx->calls++; + if (ctx->calls == 1) { + /* Both states are already present in poll_once's pointer snapshot. + * Removing them must invalidate the not-yet-admitted callback. */ + cbm_watcher_unwatch(ctx->watcher, ctx->first_project); + cbm_watcher_unwatch(ctx->watcher, ctx->second_project); + } + return 0; +} + +TEST(watcher_unwatch_invalidates_remaining_poll_snapshot) { + char first[256]; + char second[256]; + snprintf(first, sizeof(first), "/tmp/cbm_watcher_unwatch_snap_a_XXXXXX"); + snprintf(second, sizeof(second), "/tmp/cbm_watcher_unwatch_snap_b_XXXXXX"); + if (!cbm_mkdtemp(first) || !cbm_mkdtemp(second)) { + th_rmtree(first); + th_rmtree(second); + FAIL("cbm_mkdtemp failed"); + } + if (wt_git(first, "init -q") != 0 || wt_git(second, "init -q") != 0) { + th_rmtree(first); + th_rmtree(second); + FAIL("git init failed"); + } + char path[300]; + th_write_file(wt_path(path, sizeof(path), first, "file.txt"), "first\n"); + th_write_file(wt_path(path, sizeof(path), second, "file.txt"), "second\n"); + wt_git(first, "add file.txt"); + wt_git(first, "commit -q -m init"); + wt_git(second, "add file.txt"); + wt_git(second, "commit -q -m init"); + + cbm_store_t *store = cbm_store_open_memory(); + unwatch_snapshot_ctx_t ctx = { + .first_project = "unwatch-snapshot-a", + .second_project = "unwatch-snapshot-b", + }; + cbm_watcher_t *w = cbm_watcher_new(store, unwatch_snapshot_callback, &ctx); + ctx.watcher = w; + cbm_watcher_watch(w, ctx.first_project, first); + cbm_watcher_watch(w, ctx.second_project, second); + (void)cbm_watcher_poll_once(w); + + th_append_file(wt_path(path, sizeof(path), first, "file.txt"), "dirty\n"); + th_append_file(wt_path(path, sizeof(path), second, "file.txt"), "dirty\n"); + cbm_watcher_touch(w, ctx.first_project); + cbm_watcher_touch(w, ctx.second_project); + int reindexed = cbm_watcher_poll_once(w); + int watches_after_callback = cbm_watcher_watch_count(w); + (void)cbm_watcher_poll_once(w); /* drain the two deferred states */ + + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(first); + th_rmtree(second); + + ASSERT_EQ(ctx.calls, 1); + ASSERT_EQ(reindexed, 1); + ASSERT_EQ(watches_after_callback, 0); + PASS(); +} + +typedef struct { + cbm_watcher_t *watcher; + const char *replacement_root; + int calls; +} replace_during_poll_ctx_t; + +static int replace_during_poll_callback(const char *name, const char *path, void *ud) { + (void)path; + replace_during_poll_ctx_t *ctx = ud; + ctx->calls++; + /* Re-registering can happen when a second daemon session initializes while + * this project's watcher snapshot is being polled. The old state must stay + * alive until the snapshot finishes using it. */ + cbm_watcher_watch(ctx->watcher, name, ctx->replacement_root); + return 0; +} + +TEST(watcher_replace_during_poll_defers_old_state_free) { + char original[256]; + char replacement[256]; + snprintf(original, sizeof(original), "/tmp/cbm_watcher_replace_old_XXXXXX"); + snprintf(replacement, sizeof(replacement), "/tmp/cbm_watcher_replace_new_XXXXXX"); + if (!cbm_mkdtemp(original) || !cbm_mkdtemp(replacement)) { + th_rmtree(original); + th_rmtree(replacement); + FAIL("cbm_mkdtemp failed"); + } + if (wt_git(original, "init -q") != 0) { + th_rmtree(original); + th_rmtree(replacement); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), original, "file.txt"), "hello\n"); + } + wt_git(original, "add file.txt"); + wt_git(original, "commit -q -m init"); + + cbm_store_t *store = cbm_store_open_memory(); + replace_during_poll_ctx_t ctx = {.replacement_root = replacement}; + cbm_watcher_t *w = cbm_watcher_new(store, replace_during_poll_callback, &ctx); + ASSERT_NOT_NULL(w); + ctx.watcher = w; + cbm_watcher_watch(w, "replace-repo", original); + + cbm_watcher_poll_once(w); /* baseline */ + { + char p[300]; + th_append_file(wt_path(p, sizeof(p), original, "file.txt"), "dirty\n"); + } + cbm_watcher_touch(w, "replace-repo"); + ASSERT_EQ(cbm_watcher_poll_once(w), 1); + ASSERT_EQ(ctx.calls, 1); + ASSERT_EQ(cbm_watcher_watch_count(w), 1); + + /* Drains the deferred old snapshot state after the prior poll completed. */ + (void)cbm_watcher_poll_once(w); + cbm_watcher_free(w); + cbm_store_close(store); + th_rmtree(original); + th_rmtree(replacement); + PASS(); +} + TEST(watcher_null_poll_once) { /* poll_once(NULL) → 0 */ int reindexed = cbm_watcher_poll_once(NULL); @@ -2078,6 +2469,10 @@ SUITE(watcher) { RUN_TEST(watcher_poll_no_projects); RUN_TEST(watcher_poll_nonexistent_path); RUN_TEST(watcher_prunes_sustained_missing_root); + RUN_TEST(watcher_prune_waits_for_daemon_project_mutation); + RUN_TEST(watcher_prune_guard_denial_and_success_are_balanced); + RUN_TEST(watcher_prune_restats_root_after_guard_acquisition); + RUN_TEST(watcher_prune_delete_failure_retains_watch_for_retry); RUN_TEST(watcher_grace_window_blocks_prune); RUN_TEST(watcher_root_missing_errno_classification); RUN_TEST(watcher_root_restore_resets_prune_streak); @@ -2087,6 +2482,7 @@ SUITE(watcher) { /* Git change detection */ RUN_TEST(watcher_detects_git_commit); RUN_TEST(watcher_detects_dirty_worktree); + RUN_TEST(watcher_identical_watch_preserves_dirty_baseline); RUN_TEST(watcher_detects_new_file); RUN_TEST(watcher_no_change_no_reindex); RUN_TEST(watcher_dirty_state_reindexes_once_issue937); @@ -2137,6 +2533,8 @@ SUITE(watcher) { RUN_TEST(watcher_stop_prevents_run); RUN_TEST(watcher_watch_unwatch_rapid_cycle); RUN_TEST(watcher_unwatch_drains_pending_free); + RUN_TEST(watcher_unwatch_invalidates_remaining_poll_snapshot); + RUN_TEST(watcher_replace_during_poll_defers_old_state_free); RUN_TEST(watcher_callback_data_passed); RUN_TEST(watcher_null_poll_once); RUN_TEST(watcher_null_watch_count); diff --git a/tests/test_worker_watchdog.sh b/tests/test_worker_watchdog.sh index c0e5317af..44b165a75 100755 --- a/tests/test_worker_watchdog.sh +++ b/tests/test_worker_watchdog.sh @@ -9,13 +9,16 @@ # Strategy: launch the worker under a wrapper "parent" on a fixture where the # test-only injector (CBM_TEST_HANG_ON) busy-spins on one file, so the worker # is guaranteed to still be mid-index when the wrapper is killed — the guard -# cannot pass vacuously via the worker simply finishing. CBM_INDEX_SINGLE_THREAD -# + CBM_INDEX_MARKER_FILE (the supervisor's own recovery knobs) give a +# cannot pass vacuously via the worker simply finishing. The hidden +# --index-worker-single-thread + --index-worker-marker recovery knobs give a # deterministic "worker is AT the hang file" sync point: the worker writes the # rel_path it is about to process before touching it. After kill -9 of the -# wrapper, the worker-mode watchdog must notice the changed ppid and _exit -# within a few seconds; without it the busy-spin keeps the orphan alive -# forever (RED). Skipped on Windows-like shells (the watchdog is POSIX-only). +# wrapper, the worker-mode watchdog must notice the changed ppid and kill its +# isolated process group within a few seconds. A test-only worker descendant is +# kept alive in that group to prove teardown is tree-wide rather than merely a +# root-process exit. The recovery knobs are passed as the supervisor's hidden +# worker argv, never by mutating the supervisor environment. Skipped on +# Windows-like shells (the watchdog is POSIX-only). set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" @@ -33,6 +36,21 @@ if [[ ! -x "${BINARY}" ]]; then exit 2 fi +if command -v shasum >/dev/null 2>&1; then + BUILD_FINGERPRINT="$(shasum -a 256 "${BINARY}" | awk '{print $1}')" +elif command -v sha256sum >/dev/null 2>&1; then + BUILD_FINGERPRINT="$(sha256sum "${BINARY}" | awk '{print $1}')" +elif command -v openssl >/dev/null 2>&1; then + BUILD_FINGERPRINT="$(openssl dgst -sha256 "${BINARY}" | awk '{print $NF}')" +else + echo "no SHA-256 command available for worker build binding" >&2 + exit 2 +fi +if [[ ! "${BUILD_FINGERPRINT}" =~ ^[0-9a-f]{64}$ ]]; then + echo "invalid worker build fingerprint: ${BUILD_FINGERPRINT}" >&2 + exit 2 +fi + tmpdir="$(mktemp -d)" wrapper_pid="" cleanup() { @@ -41,6 +59,11 @@ cleanup() { child_pid="$(cat "${tmpdir}/child.pid" 2>/dev/null || true)" [[ -n "${child_pid}" ]] && kill -9 "${child_pid}" 2>/dev/null || true fi + if [[ -s "${tmpdir}/descendant.pid" ]]; then + local descendant_pid + descendant_pid="$(cat "${tmpdir}/descendant.pid" 2>/dev/null || true)" + [[ -n "${descendant_pid}" ]] && kill -9 "${descendant_pid}" 2>/dev/null || true + fi [[ -n "${wrapper_pid}" ]] && kill -9 "${wrapper_pid}" 2>/dev/null || true rm -rf "${tmpdir}" } @@ -56,18 +79,22 @@ printf 'def slow():\n return 2\n' > "${tmpdir}/repo/hang_me.py" cat >"${tmpdir}/wrapper.sh" <<'SH' #!/usr/bin/env bash set -euo pipefail -"${CBM_BINARY}" cli --index-worker index_repository "${ARGS_JSON}" \ +"${CBM_BINARY}" cli --index-worker \ + --index-worker-build "${BUILD_FINGERPRINT}" \ + index_repository "${ARGS_JSON}" \ --response-out "${TMPDIR_PATH}/resp" \ + --index-worker-single-thread \ + --index-worker-marker "${TMPDIR_PATH}/marker" \ >/dev/null 2>"${TMPDIR_PATH}/child.err" & echo "$!" >"${TMPDIR_PATH}/child.pid" wait SH chmod +x "${tmpdir}/wrapper.sh" -CBM_BINARY="${BINARY}" TMPDIR_PATH="${tmpdir}" \ +CBM_BINARY="${BINARY}" BUILD_FINGERPRINT="${BUILD_FINGERPRINT}" TMPDIR_PATH="${tmpdir}" \ ARGS_JSON="{\"repo_path\":\"${tmpdir}/repo\"}" \ - CBM_TEST_HANG_ON=hang_me CBM_INDEX_SINGLE_THREAD=1 \ - CBM_INDEX_MARKER_FILE="${tmpdir}/marker" \ + CBM_TEST_HANG_ON=hang_me \ + CBM_TEST_WORKER_DESCENDANT_PID_FILE="${tmpdir}/descendant.pid" \ "${tmpdir}/wrapper.sh" & wrapper_pid=$! @@ -83,6 +110,31 @@ if [[ ! -s "${tmpdir}/child.pid" ]]; then fi child_pid="$(cat "${tmpdir}/child.pid")" +# The validated worker creates its own process group before starting this +# deliberate long-lived descendant. Both must be alive and in that group before +# the wrapper is killed, otherwise the tree assertion would be vacuous. +for _ in {1..50}; do + [[ -s "${tmpdir}/descendant.pid" ]] && break + sleep 0.1 +done +if [[ ! -s "${tmpdir}/descendant.pid" ]]; then + echo "worker descendant pid file was not written" >&2 + [[ -s "${tmpdir}/child.err" ]] && cat "${tmpdir}/child.err" >&2 + exit 3 +fi +descendant_pid="$(cat "${tmpdir}/descendant.pid")" +if ! kill -0 "${descendant_pid}" 2>/dev/null; then + echo "worker descendant exited before supervisor death" >&2 + exit 3 +fi +child_pgid="$(ps -p "${child_pid}" -o pgid= 2>/dev/null | tr -d '[:space:]')" +descendant_pgid="$(ps -p "${descendant_pid}" -o pgid= 2>/dev/null | tr -d '[:space:]')" +if [[ -z "${child_pgid}" || "${child_pgid}" != "${child_pid}" || + "${descendant_pgid}" != "${child_pgid}" ]]; then + echo "worker tree is not in its expected isolated process group" >&2 + exit 3 +fi + # Sync point: once the marker names the hang file, the worker is provably # mid-index (busy-spinning in extraction) and long past watchdog installation. for _ in {1..100}; do @@ -109,22 +161,25 @@ fi kill -9 "${wrapper_pid}" wait "${wrapper_pid}" 2>/dev/null || true +process_gone_or_zombie() { + local pid="$1" + if ! kill -0 "${pid}" 2>/dev/null; then + return 0 + fi + local state + state="$(ps -p "${pid}" -o stat= 2>/dev/null | tr -d '[:space:]' || true)" + [[ "${state}" == Z* ]] +} + deadline=$((SECONDS + 15)) while (( SECONDS < deadline )); do - if ! kill -0 "${child_pid}" 2>/dev/null; then - echo "ok: worker ${child_pid} exited after supervisor death" - exit 0 - fi - # A zombie no longer indexes; kill -0 still reports it until it is reaped, - # so treat that as a successful exit (same as test_parent_watchdog.sh). - child_state="$(ps -p "${child_pid}" -o stat= 2>/dev/null | tr -d '[:space:]' || true)" - if [[ "${child_state}" == Z* ]]; then - echo "ok: worker ${child_pid} exited after supervisor death (zombie awaiting reap)" + if process_gone_or_zombie "${child_pid}" && process_gone_or_zombie "${descendant_pid}"; then + echo "ok: worker ${child_pid} and descendant ${descendant_pid} exited after supervisor death" exit 0 fi sleep 0.2 done -echo "index worker ${child_pid} survived supervisor death (#845)" >&2 +echo "index worker tree survived supervisor death (worker=${child_pid}, descendant=${descendant_pid})" >&2 [[ -s "${tmpdir}/child.err" ]] && cat "${tmpdir}/child.err" >&2 exit 1 From 83c137d2a5e86056c66040994ce22dd70722f5fa Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 01:26:08 +0200 Subject: [PATCH 02/23] feat: complete shared daemon lifecycle Signed-off-by: Martin Vogel --- .github/workflows/_build.yml | 48 +- .github/workflows/_smoke.yml | 45 +- .github/workflows/_test.yml | 4 + .github/workflows/pr.yml | 10 +- Makefile.cbm | 54 +- README.md | 32 +- docs/CONFIGURATION.md | 12 +- install.ps1 | 97 +- pkg/go/cmd/codebase-memory-mcp/main.go | 569 +++- pkg/go/cmd/codebase-memory-mcp/main_test.go | 197 ++ .../codebase-memory-mcp/pair_lock_other.go | 10 + .../codebase-memory-mcp/pair_lock_windows.go | 21 + pkg/npm/bin.js | 54 +- pkg/npm/install.js | 473 ++- pkg/npm/package.json | 1 + pkg/npm/test/install-failure.test.js | 53 + pkg/npm/test/package-publication.test.js | 137 + pkg/npm/test/windows-launcher.test.js | 116 + pkg/pypi/src/codebase_memory_mcp/_cli.py | 275 +- pkg/pypi/tests/test_cli.py | 37 + scripts/security-fuzz.sh | 26 +- scripts/smoke-test.sh | 218 +- scripts/test-windows.ps1 | 49 +- scripts/test.sh | 12 + scripts/test_mcp_interactive.py | 248 ++ src/cli/activation_transaction.c | 769 ++--- src/cli/activation_transaction.h | 25 +- src/cli/cli.c | 2949 +++++++++++++---- src/cli/cli.h | 45 +- src/cli/hook_augment.c | 55 +- src/cli/progress_sink.c | 22 +- src/cli/progress_sink.h | 3 +- src/cli/windows_launcher_state.c | 1851 +++++++++++ src/cli/windows_launcher_state.h | 126 + src/cypher/cypher.c | 8 +- src/daemon/application.c | 1300 ++++++-- src/daemon/application.h | 67 +- src/daemon/application_internal.h | 6 + src/daemon/bootstrap.c | 120 +- src/daemon/bootstrap.h | 20 +- src/daemon/daemon.c | 49 +- src/daemon/daemon.h | 34 +- src/daemon/frontend.c | 405 +-- src/daemon/frontend.h | 32 +- src/daemon/host.c | 497 ++- src/daemon/host_internal.h | 30 +- src/daemon/ipc.c | 2588 ++++++--------- src/daemon/ipc.h | 77 +- src/daemon/ipc_internal.h | 26 +- src/daemon/project_lock.c | 81 +- src/daemon/project_lock.h | 24 +- src/daemon/runtime.c | 1283 +++---- src/daemon/runtime.h | 93 +- src/daemon/service.c | 230 +- src/daemon/service.h | 25 +- src/daemon/service_internal.h | 13 +- src/daemon/version_cohort.c | 525 ++- src/daemon/version_cohort.h | 47 +- src/discover/discover.c | 209 +- src/discover/discover.h | 15 + src/foundation/compat.c | 3 +- src/foundation/compat.h | 53 +- src/foundation/compat_fs.c | 8 +- src/foundation/lock_registry.c | 86 +- src/foundation/lock_registry.h | 11 +- src/foundation/lock_registry_internal.h | 3 +- src/foundation/macos_acl.c | 44 + src/foundation/macos_acl.h | 6 + src/foundation/mem.c | 3 +- src/foundation/mem.h | 3 +- src/foundation/platform.c | 92 +- src/foundation/private_file_lock.c | 77 +- src/foundation/private_file_lock_internal.h | 15 +- src/foundation/subprocess.c | 5 +- src/foundation/subprocess.h | 3 +- src/launcher/windows_launcher.c | 1140 +++++++ src/main.c | 685 ++-- src/mcp/index_supervisor.c | 72 +- src/mcp/index_supervisor.h | 35 +- src/mcp/mcp.c | 490 ++- src/mcp/mcp.h | 25 +- src/mcp/mcp_internal.h | 16 +- src/pipeline/pass_cross_repo.c | 4 +- src/pipeline/pipeline.c | 28 +- src/pipeline/pipeline_incremental.c | 7 +- src/pipeline/pipeline_internal.h | 5 +- src/ui/config.c | 71 +- src/ui/http_server.c | 39 +- src/ui/http_server.h | 10 +- src/watcher/watcher.c | 36 +- src/watcher/watcher.h | 8 +- tests/repro/repro_issue557.c | 12 +- tests/test_activation_transaction.c | 344 +- tests/test_cli.c | 505 ++- tests/test_daemon.c | 14 +- tests/test_daemon_application.c | 2179 ++++++++---- tests/test_daemon_bootstrap.c | 65 +- tests/test_daemon_frontend.c | 1050 +++++- tests/test_daemon_ipc.c | 1923 +++++------ tests/test_daemon_runtime.c | 2363 ++++++------- tests/test_daemon_smoke.py | 117 +- tests/test_daemon_version.c | 70 +- tests/test_discover.c | 92 + tests/test_httpd.c | 43 +- tests/test_incremental.c | 18 +- tests/test_index_supervisor.c | 155 +- tests/test_integration.c | 35 +- tests/test_lock_registry.c | 170 +- tests/test_main.c | 189 +- .../test_makefile_ts_runtime_dependencies.sh | 56 + tests/test_mcp.c | 475 ++- tests/test_mem.c | 6 +- tests/test_parent_watchdog.sh | 21 +- tests/test_pipeline.c | 6 +- tests/test_platform.c | 82 +- tests/test_private_file_lock.c | 74 +- tests/test_project_lock.c | 22 +- tests/test_security_fuzz_harness.sh | 100 + tests/test_semantic.c | 40 +- tests/test_store_checkpoint.c | 7 +- tests/test_subprocess.c | 25 +- tests/test_ui.c | 19 +- tests/test_version_cohort.c | 509 ++- tests/test_watcher.c | 53 +- tests/test_windows_bundle_contract.sh | 544 +++ tests/test_windows_launcher_state.c | 395 +++ tests/windows/test_windows_launcher.py | 1074 ++++++ 127 files changed, 21949 insertions(+), 10433 deletions(-) create mode 100644 pkg/go/cmd/codebase-memory-mcp/main_test.go create mode 100644 pkg/go/cmd/codebase-memory-mcp/pair_lock_other.go create mode 100644 pkg/go/cmd/codebase-memory-mcp/pair_lock_windows.go create mode 100644 pkg/npm/test/install-failure.test.js create mode 100644 pkg/npm/test/package-publication.test.js create mode 100644 pkg/npm/test/windows-launcher.test.js create mode 100644 pkg/pypi/tests/test_cli.py create mode 100644 scripts/test_mcp_interactive.py create mode 100644 src/cli/windows_launcher_state.c create mode 100644 src/cli/windows_launcher_state.h create mode 100644 src/launcher/windows_launcher.c create mode 100644 tests/test_makefile_ts_runtime_dependencies.sh create mode 100644 tests/test_security_fuzz_harness.sh create mode 100644 tests/test_windows_bundle_contract.sh create mode 100644 tests/test_windows_launcher_state.c create mode 100644 tests/windows/test_windows_launcher.py diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml index 44189c938..b7dfc6f23 100644 --- a/.github/workflows/_build.yml +++ b/.github/workflows/_build.yml @@ -170,11 +170,15 @@ jobs: - name: Archive standard binary shell: msys2 {0} run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - cp "$BIN" codebase-memory-mcp.exe + make -f Makefile.cbm build/c/codebase-memory-mcp-launcher.exe CC=clang CXX=clang++ + PAYLOAD=build/c/codebase-memory-mcp + [ -f "${PAYLOAD}.exe" ] && PAYLOAD="${PAYLOAD}.exe" + cp build/c/codebase-memory-mcp-launcher.exe codebase-memory-mcp.exe + cp "$PAYLOAD" codebase-memory-mcp.payload.exe scripts/gen-third-party-notices.sh THIRD_PARTY_NOTICES.md - zip codebase-memory-mcp-windows-amd64.zip codebase-memory-mcp.exe LICENSE install.ps1 THIRD_PARTY_NOTICES.md + zip codebase-memory-mcp-windows-amd64.zip \ + codebase-memory-mcp.exe codebase-memory-mcp.payload.exe \ + LICENSE install.ps1 THIRD_PARTY_NOTICES.md - name: Attest standard binary provenance if: ${{ inputs.attest }} @@ -196,11 +200,15 @@ jobs: - name: Archive UI binary shell: msys2 {0} run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - cp "$BIN" codebase-memory-mcp.exe + make -f Makefile.cbm build/c/codebase-memory-mcp-launcher.exe CC=clang CXX=clang++ + PAYLOAD=build/c/codebase-memory-mcp + [ -f "${PAYLOAD}.exe" ] && PAYLOAD="${PAYLOAD}.exe" + cp build/c/codebase-memory-mcp-launcher.exe codebase-memory-mcp.exe + cp "$PAYLOAD" codebase-memory-mcp.payload.exe scripts/gen-third-party-notices.sh THIRD_PARTY_NOTICES.md - zip codebase-memory-mcp-ui-windows-amd64.zip codebase-memory-mcp.exe LICENSE install.ps1 THIRD_PARTY_NOTICES.md + zip codebase-memory-mcp-ui-windows-amd64.zip \ + codebase-memory-mcp.exe codebase-memory-mcp.payload.exe \ + LICENSE install.ps1 THIRD_PARTY_NOTICES.md - name: Attest UI binary provenance if: ${{ inputs.attest }} @@ -251,11 +259,15 @@ jobs: - name: Archive standard binary shell: msys2 {0} run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - cp "$BIN" codebase-memory-mcp.exe + make -f Makefile.cbm build/c/codebase-memory-mcp-launcher.exe CC=clang CXX=clang++ + PAYLOAD=build/c/codebase-memory-mcp + [ -f "${PAYLOAD}.exe" ] && PAYLOAD="${PAYLOAD}.exe" + cp build/c/codebase-memory-mcp-launcher.exe codebase-memory-mcp.exe + cp "$PAYLOAD" codebase-memory-mcp.payload.exe scripts/gen-third-party-notices.sh THIRD_PARTY_NOTICES.md - zip codebase-memory-mcp-windows-arm64.zip codebase-memory-mcp.exe LICENSE install.ps1 THIRD_PARTY_NOTICES.md + zip codebase-memory-mcp-windows-arm64.zip \ + codebase-memory-mcp.exe codebase-memory-mcp.payload.exe \ + LICENSE install.ps1 THIRD_PARTY_NOTICES.md - name: Attest standard binary provenance if: ${{ inputs.attest }} @@ -277,11 +289,15 @@ jobs: - name: Archive UI binary shell: msys2 {0} run: | - BIN=build/c/codebase-memory-mcp - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - cp "$BIN" codebase-memory-mcp.exe + make -f Makefile.cbm build/c/codebase-memory-mcp-launcher.exe CC=clang CXX=clang++ + PAYLOAD=build/c/codebase-memory-mcp + [ -f "${PAYLOAD}.exe" ] && PAYLOAD="${PAYLOAD}.exe" + cp build/c/codebase-memory-mcp-launcher.exe codebase-memory-mcp.exe + cp "$PAYLOAD" codebase-memory-mcp.payload.exe scripts/gen-third-party-notices.sh THIRD_PARTY_NOTICES.md - zip codebase-memory-mcp-ui-windows-arm64.zip codebase-memory-mcp.exe LICENSE install.ps1 THIRD_PARTY_NOTICES.md + zip codebase-memory-mcp-ui-windows-arm64.zip \ + codebase-memory-mcp.exe codebase-memory-mcp.payload.exe \ + LICENSE install.ps1 THIRD_PARTY_NOTICES.md - name: Attest UI binary provenance if: ${{ inputs.attest }} diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index 97b2e5802..fd1025e13 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -139,6 +139,7 @@ jobs: run: scripts/smoke-test.sh ./codebase-memory-mcp env: SMOKE_DOWNLOAD_URL: http://localhost:18080 + SMOKE_UPDATE_FIXTURE_DIR: /tmp/smoke-server - name: Security audits run: | @@ -210,17 +211,23 @@ jobs: SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} ARCH=${{ matrix.arch }} unzip -o "codebase-memory-mcp${SUFFIX}-windows-${ARCH}.zip" - [ -n "$SUFFIX" ] && cp "codebase-memory-mcp${SUFFIX}.exe" codebase-memory-mcp.exe || true + test -s codebase-memory-mcp.exe + test -s codebase-memory-mcp.payload.exe + ./codebase-memory-mcp.payload.exe --version + ./codebase-memory-mcp.exe --version - name: Start artifact server shell: msys2 {0} run: | mkdir -p /tmp/smoke-server - cp codebase-memory-mcp.exe /tmp/smoke-server/ + cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe \ + LICENSE install.ps1 THIRD_PARTY_NOTICES.md /tmp/smoke-server/ SUFFIX=${{ matrix.variant == 'ui' && '-ui' || '' }} ARCH=${{ matrix.arch }} cd /tmp/smoke-server - zip -q "codebase-memory-mcp${SUFFIX}-windows-${ARCH}.zip" codebase-memory-mcp.exe + zip -q "codebase-memory-mcp${SUFFIX}-windows-${ARCH}.zip" \ + codebase-memory-mcp.exe codebase-memory-mcp.payload.exe \ + LICENSE install.ps1 THIRD_PARTY_NOTICES.md if [ -n "$SUFFIX" ]; then cp "codebase-memory-mcp${SUFFIX}-windows-${ARCH}.zip" "codebase-memory-mcp-windows-${ARCH}.zip" fi @@ -235,32 +242,36 @@ jobs: run: scripts/smoke-test.sh ./codebase-memory-mcp.exe env: SMOKE_DOWNLOAD_URL: http://127.0.0.1:18080 + SMOKE_UPDATE_FIXTURE_DIR: /tmp/smoke-server SMOKE_ARCH: ${{ matrix.arch }} - name: Security audits shell: msys2 {0} run: | scripts/security-strings.sh ./codebase-memory-mcp.exe + scripts/security-strings.sh ./codebase-memory-mcp.payload.exe scripts/security-install.sh ./codebase-memory-mcp.exe - name: Windows Defender scan shell: pwsh run: | & "C:\Program Files\Windows Defender\MpCmdRun.exe" -SignatureUpdate 2>$null - $result = & "C:\Program Files\Windows Defender\MpCmdRun.exe" -Scan -ScanType 3 -File "$PWD\codebase-memory-mcp.exe" -DisableRemediation - $code = $LASTEXITCODE - Write-Host $result - # MpCmdRun -Scan exit codes: 0 = clean, 2 = threat found. Any OTHER non-zero - # means the scan engine could not run at all (e.g. hr=0x800106ba: the Defender - # antimalware service is unavailable on the runner) — that is NOT a detection. - # Fail soft on an engine failure so a transient runner-side AV outage can't - # false-fail a release; only a real detection (exit 2) hard-blocks. - if ($code -eq 2) { - Write-Host "BLOCKED: Windows Defender flagged binary!"; exit 1 - } elseif ($code -ne 0) { - Write-Host "::warning::Windows Defender scan could not run (exit $code) - skipping AV gate on this runner" - } else { - Write-Host "=== Windows Defender: clean ===" + foreach ($binary in @("codebase-memory-mcp.exe", "codebase-memory-mcp.payload.exe")) { + $result = & "C:\Program Files\Windows Defender\MpCmdRun.exe" -Scan -ScanType 3 -File "$PWD\$binary" -DisableRemediation + $code = $LASTEXITCODE + Write-Host $result + # MpCmdRun -Scan exit codes: 0 = clean, 2 = threat found. Any OTHER non-zero + # means the scan engine could not run at all (e.g. hr=0x800106ba: the Defender + # antimalware service is unavailable on the runner) — that is NOT a detection. + # Fail soft on an engine failure so a transient runner-side AV outage can't + # false-fail a release; only a real detection (exit 2) hard-blocks. + if ($code -eq 2) { + Write-Host "BLOCKED: Windows Defender flagged $binary!"; exit 1 + } elseif ($code -ne 0) { + Write-Host "::warning::Windows Defender scan could not run for $binary (exit $code) - skipping AV gate on this runner" + } else { + Write-Host "=== Windows Defender: $binary clean ===" + } } smoke-linux-portable: diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index a610bcfa7..de6e049e4 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -162,6 +162,10 @@ jobs: with: node-version: "22" + - name: Test npm wrapper failure handling + working-directory: pkg/npm + run: npm test + - name: Build product binary with embedded UI shell: msys2 {0} # --with-ui builds the frontend (npm) and embeds it, so the drive-picker diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 8d49dc6ed..e33482003 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -114,9 +114,13 @@ jobs: shell: msys2 {0} run: | scripts/build.sh CC=clang CXX=clang++ - BIN="$(pwd)/build/c/codebase-memory-mcp" - [ -f "${BIN}.exe" ] && BIN="${BIN}.exe" - scripts/smoke-test.sh "$BIN" + SMOKE_DIR="$(mktemp -d)" + trap 'rm -rf "$SMOKE_DIR"' EXIT + cp build/c/codebase-memory-mcp-launcher.exe \ + "$SMOKE_DIR/codebase-memory-mcp.exe" + cp build/c/codebase-memory-mcp.exe \ + "$SMOKE_DIR/codebase-memory-mcp.payload.exe" + scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" ci-ok: # The one required context (besides dco) — fails unless every PR stage diff --git a/Makefile.cbm b/Makefile.cbm index cfece1721..d2b1ea3ff 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -160,6 +160,13 @@ LSP_UNITY_DEPS = $(CBM_DIR)/lsp_all.c \ # Tree-sitter runtime TS_RUNTIME_SRC = $(CBM_DIR)/ts_runtime.c +TS_RUNTIME_DEPS = \ + $(TS_RUNTIME_SRC) \ + $(wildcard $(CBM_DIR)/vendored/ts_runtime/include/tree_sitter/*.h) \ + $(wildcard $(CBM_DIR)/vendored/ts_runtime/src/*.c) \ + $(wildcard $(CBM_DIR)/vendored/ts_runtime/src/*.h) \ + $(wildcard $(CBM_DIR)/vendored/ts_runtime/src/portable/*.h) \ + $(wildcard $(CBM_DIR)/vendored/ts_runtime/src/unicode/*.h) # All 64 grammar shim files GRAMMAR_SRCS = $(wildcard $(CBM_DIR)/grammar_*.c) @@ -262,7 +269,12 @@ GIT_SRCS = src/git/git_context.c CLI_SRCS = src/cli/cli.c src/cli/progress_sink.c src/cli/hook_augment.c \ src/cli/agent_clients.c src/cli/agent_profiles.c \ src/cli/config_json_like.c src/cli/config_toml_edit.c src/cli/config_yaml_edit.c \ - src/cli/config_text_edit.c src/cli/activation_transaction.c + src/cli/config_text_edit.c src/cli/activation_transaction.c \ + src/cli/windows_launcher_state.c + +# Tiny permanent Windows launcher. It deliberately links only the stable +# launcher-state module and Windows SDK libraries, never the parser/indexer. +WINDOWS_LAUNCHER_SRC = src/launcher/windows_launcher.c # UI module (graph visualization) UI_SRCS = \ @@ -435,7 +447,8 @@ TEST_TRACES_SRCS = tests/test_traces.c TEST_CLI_SRCS = tests/test_cli.c tests/test_agent_clients.c tests/test_agent_profiles.c \ tests/test_config_json_like.c \ tests/test_config_toml_edit.c tests/test_config_yaml_edit.c tests/test_config_text_edit.c \ - tests/test_activation_transaction.c + tests/test_activation_transaction.c \ + tests/test_windows_launcher_state.c TEST_MEM_SRCS = tests/test_mem.c @@ -545,7 +558,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o # ── Targets ────────────────────────────────────────────────────── -.PHONY: test test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security +.PHONY: test test-repro test-foundation test-tsan test-daemon-smoke cbm cbm-launcher cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format security $(BUILD_DIR): mkdir -p $(BUILD_DIR) @@ -563,7 +576,7 @@ test-foundation: $(BUILD_DIR)/test-foundation $(BUILD_DIR)/%.o: $(CBM_DIR)/%.c | $(BUILD_DIR) $(CC) $(GRAMMAR_CFLAGS_TEST) -c -o $@ $< -$(BUILD_DIR)/ts_runtime.o: $(CBM_DIR)/ts_runtime.c | $(BUILD_DIR) +$(BUILD_DIR)/ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR) $(CC) $(GRAMMAR_CFLAGS_TEST) -c -o $@ $< $(BUILD_DIR)/lsp_all.o: $(LSP_UNITY_DEPS) | $(BUILD_DIR) @@ -575,7 +588,7 @@ $(BUILD_DIR)/preprocessor.o: $(CBM_DIR)/preprocessor.cpp | $(BUILD_DIR) $(BUILD_DIR)/tsan_%.o: $(CBM_DIR)/%.c | $(BUILD_DIR) $(CC) $(GRAMMAR_CFLAGS_TSAN) -c -o $@ $< -$(BUILD_DIR)/tsan_ts_runtime.o: $(CBM_DIR)/ts_runtime.c | $(BUILD_DIR) +$(BUILD_DIR)/tsan_ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR) $(CC) $(GRAMMAR_CFLAGS_TSAN) -c -o $@ $< $(BUILD_DIR)/tsan_lsp_all.o: $(LSP_UNITY_DEPS) | $(BUILD_DIR) @@ -725,7 +738,7 @@ PP_OBJ_PROD = $(BUILD_DIR)/prod_preprocessor.o $(BUILD_DIR)/prod_%.o: $(CBM_DIR)/%.c | $(BUILD_DIR) $(CC) $(GRAMMAR_CFLAGS) -c -o $@ $< -$(BUILD_DIR)/prod_ts_runtime.o: $(CBM_DIR)/ts_runtime.c | $(BUILD_DIR) +$(BUILD_DIR)/prod_ts_runtime.o: $(TS_RUNTIME_DEPS) | $(BUILD_DIR) $(CC) $(GRAMMAR_CFLAGS) -c -o $@ $< $(BUILD_DIR)/prod_lsp_all.o: $(LSP_UNITY_DEPS) | $(BUILD_DIR) @@ -750,6 +763,26 @@ OBJS_VENDORED_PROD = $(MIMALLOC_OBJ_PROD) $(SQLITE3_OBJ_PROD) $(TRE_OBJ_PROD) $( MAIN_SRC = src/main.c +WINDOWS_LAUNCHER_TARGET := +ifeq ($(IS_MINGW),yes) +WINDOWS_LAUNCHER_TARGET := $(BUILD_DIR)/codebase-memory-mcp-launcher.exe +endif + +# Keep the explicit .exe target even on non-Windows hosts: packaging contracts +# and native-Windows invocations share this single artifact name. A non-Windows +# direct invocation builds a diagnostic stub and is not part of `cbm`. +$(BUILD_DIR)/codebase-memory-mcp-launcher.exe: $(WINDOWS_LAUNCHER_SRC) src/cli/windows_launcher_state.c src/cli/windows_launcher_state.h | $(BUILD_DIR) + $(CC) $(CFLAGS_PROD) -o $@ \ + $(WINDOWS_LAUNCHER_SRC) src/cli/windows_launcher_state.c \ + $(if $(filter yes,$(IS_MINGW)),-municode -lbcrypt $(WIN32_LIBS),) + +# Native-Windows compatibility fixture: its launcher ABI intentionally does +# not fit the production payload's default [1,1] range. It is never packaged. +$(BUILD_DIR)/codebase-memory-mcp-launcher-abi2.exe: $(WINDOWS_LAUNCHER_SRC) src/cli/windows_launcher_state.c src/cli/windows_launcher_state.h | $(BUILD_DIR) + $(CC) $(CFLAGS_PROD) -DCBM_WINDOWS_LAUNCHER_ABI_CURRENT=2 -o $@ \ + $(WINDOWS_LAUNCHER_SRC) src/cli/windows_launcher_state.c \ + $(if $(filter yes,$(IS_MINGW)),-municode -lbcrypt $(WIN32_LIBS),) + $(BUILD_DIR)/codebase-memory-mcp: $(MAIN_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_PROD) | $(BUILD_DIR) $(CC) $(CFLAGS_PROD) -o $@ \ $(MAIN_SRC) $(PROD_SRCS) \ @@ -757,9 +790,12 @@ $(BUILD_DIR)/codebase-memory-mcp: $(MAIN_SRC) $(PROD_SRCS) $(EXTRACTION_SRCS) $( $(OBJS_VENDORED_PROD) \ $(LDFLAGS) -cbm: $(BUILD_DIR)/codebase-memory-mcp +cbm: $(BUILD_DIR)/codebase-memory-mcp $(WINDOWS_LAUNCHER_TARGET) @echo "Built: $(BUILD_DIR)/codebase-memory-mcp" +cbm-launcher: $(BUILD_DIR)/codebase-memory-mcp-launcher.exe + @echo "Built: $(BUILD_DIR)/codebase-memory-mcp-launcher.exe" + # ── Build with embedded UI (requires Node.js) ─────────────────── # Swap embedded_stub.c for the generated embedded_assets.c @@ -775,7 +811,7 @@ frontend: embed: frontend scripts/embed-frontend.sh graph-ui/dist $(BUILD_DIR)/embedded -cbm-with-ui: embed $(OBJS_VENDORED_PROD) +cbm-with-ui: embed $(OBJS_VENDORED_PROD) $(WINDOWS_LAUNCHER_TARGET) $(CC) $(CFLAGS_PROD) -o $(BUILD_DIR)/codebase-memory-mcp \ $(MAIN_SRC) $(PROD_SRCS_WITH_ASSETS) \ $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) \ @@ -804,7 +840,7 @@ SYSROOT_FLAG = $(if $(SYSROOT),-isysroot $(SYSROOT),) LINT_SRCS = $(FOUNDATION_SRCS) $(STORE_SRCS) $(CYPHER_SRCS) $(MCP_SRCS) $(DAEMON_SRCS) \ $(DISCOVER_SRCS) $(GRAPH_BUFFER_SRCS) $(PIPELINE_SRCS) $(SIMHASH_SRCS) $(SEMANTIC_SRCS) \ $(TRACES_SRCS) $(WATCHER_SRCS) $(CLI_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) \ - $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(MAIN_SRC) + $(ZSTD_SRCS) $(SQLITE_WRITER_SRC) $(MAIN_SRC) $(WINDOWS_LAUNCHER_SRC) LINT_HDRS = $(wildcard src/**/*.h src/*.h $(CBM_DIR)/*.h) LINT_TEST_SRCS = $(ALL_TEST_SRCS) diff --git a/README.md b/README.md index c446076a5..74160bc90 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,19 @@ The `install` command auto-detects installed coding agents and configures their CBM automatically shares one per-account coordination daemon across Claude Code, Codex, OpenCode, and every other configured client. There is no opt-in setting for MCP servers or hook clients: the first daemon-backed CBM session starts it, each session registers its own work, and the final session shuts it down. The daemon owns long-lived background services such as watchers, shared indexing jobs, and the optional UI. Closing one session cancels work owned only by that session, while work still needed by another session continues. -All active CBM processes must run the exact same version, executable build, and coordination ABI. MCP servers, hooks, one-shot CLI commands, temporary index workers, and the daemon share a crash-safe OS admission barrier; starting an ordinary newer or older CBM process while another build is active fails before doing work and records an explicit conflict in `daemon-conflicts.ndjson` in the private log directory. +The detached daemon does not depend on an MCP frontend's stderr. It keeps owner-only durable records under the canonical `${CBM_CACHE_DIR}/logs` directory (default `~/.cache/codebase-memory-mcp/logs`): -The native `install`, `update`, and `uninstall` commands are the deliberate exception to that conflict rule. Download, verification, and private same-filesystem staging happen first so a bad candidate never disrupts active work. Activation then publishes account-wide maintenance intent, asks the daemon and every temporary local operation to cancel, and waits to a finite deadline for all coordinated CBM processes to exit. It holds the admission and lifetime barriers exclusively while changing the active binary, configuration, PATH, or indexes. New CBM work cannot enter during this window. Activation progress and results are recorded in the private `activation-events.ndjson` log, and a successful command tells you to restart open coding-agent sessions so they launch the activated build. +| File | Contents | +|------|----------| +| `cbm-daemon.log` | Daemon lifecycle, watcher/indexing, UI, resource, and error events. | +| `daemon-conflicts.ndjson` | Exact-build, coordination-ABI, and cache-root admission conflicts. | +| `activation-events.ndjson` | Install/update/uninstall activation progress and outcomes. | + +Thin frontends still write immediate startup and session-specific errors to their own stderr; MCP JSON-RPC stdout remains clean. + +All active CBM processes must run the exact same version, executable build, coordination ABI, and canonical cache root. Equivalent `CBM_CACHE_DIR` aliases resolve to the same root; a genuinely different root is rejected while any CBM process is active. MCP servers, hooks, one-shot CLI commands, temporary index workers, and the daemon share a crash-safe OS admission barrier; starting an ordinary conflicting process fails before doing work and records an explicit conflict in `${CBM_CACHE_DIR}/logs/daemon-conflicts.ndjson`. + +The native `install`, `update`, and `uninstall` commands are the deliberate exception to that conflict rule. Download, verification, and private same-filesystem staging happen first so a bad candidate never disrupts active work. Activation then publishes account-wide maintenance intent, asks the daemon and every temporary local operation to cancel, and waits to a finite deadline for all coordinated CBM processes to exit. It holds the admission and lifetime barriers exclusively while changing the active binary, configuration, PATH, or indexes. New CBM work cannot enter during this window. Activation progress and results are recorded in `${CBM_CACHE_DIR}/logs/activation-events.ndjson`, and a successful command tells you to restart open coding-agent sessions so they launch the activated build. Package-manager setup (npm, PyPI, or Go) only verifies and atomically publishes that package's private cached binary; it does not replace the active native installation and therefore does not stop running CBM sessions. When that cached binary is executed, it still enters the same exact-build admission barrier. The shell and PowerShell installers invoke the verified candidate's native `install` command, so they do receive the full account-wide activation guarantee. @@ -264,20 +274,14 @@ codebase-memory-mcp runs **100% locally and collects no telemetry** — your cod ### Capture a diagnostics log -Set `CBM_DIAGNOSTICS=1` before the MCP server starts, then reproduce the problem (let it run as long as it takes — a slow leak needs time to show in the trend). The server writes two files to your system temp directory (`$TMPDIR` or `/tmp` on macOS/Linux, `%TEMP%` on Windows): +Set `CBM_DIAGNOSTICS=1` before the first daemon-backed MCP session starts, then reproduce the problem (let it run as long as it takes — a slow leak needs time to show in the trend). The shared daemon captures this setting from the session that starts it. If it is already running, close all daemon-backed sessions so it exits before changing the setting. The daemon writes two files to your system temp directory (`$TMPDIR` or `/tmp` on macOS/Linux, `%TEMP%` on Windows): | File | What it is | |------|------------| | `cbm-diagnostics-.ndjson` | **The memory trajectory** — one JSON line every 5 s with `rss`, `committed` (Windows commit charge), `peak_*`, `page_faults`, `fd`, and `queries`. **This is the file we need for memory/leak reports** — the *trend over time* is what pinpoints a leak. It is **kept on disk after the server exits** (so you can grab it post-mortem) and rotates to `.ndjson.1` past ~8 MB. | | `cbm-diagnostics-.json` | The latest snapshot only — handy for a quick live check. Removed on clean exit. | -The startup log prints both paths, e.g.: - -``` -level=info msg=diagnostics.start snapshot=/tmp/cbm-diagnostics-12345.json trajectory=/tmp/cbm-diagnostics-12345.ndjson interval=5s -``` - -Set the variable in the `env` block of your agent's MCP server config, or export it before launching the server. +The `` component is the shared daemon's process ID, also recorded by the `daemon.start` event in `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. Set the variable consistently in the `env` block of each agent's MCP server config, or export it before launching the first session. ### What to share @@ -630,14 +634,16 @@ codebase-memory-mcp config reset auto_index # reset to default | Variable | Default | Description | |----------|---------|-------------| | `CBM_ALLOWED_ROOT` | *(unset)* | Restrict `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused; unset imposes no restriction. Useful when the server may be driven by an untrusted caller, e.g. agentic or multi-tenant deployments. | -| `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the database storage directory. All project indexes and config are stored here. | -| `CBM_DIAGNOSTICS` | `false` | Set to `1` or `true` to enable periodic diagnostics output to `/tmp/cbm-diagnostics-.json`. | +| `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the database storage directory. All project indexes and config are stored here. One account can use only one canonical cache root at a time; close active CBM sessions/commands before switching it. | +| `CBM_DIAGNOSTICS` | `false` | Set to `1` or `true` to enable the shared daemon's periodic snapshot and trajectory files in the system temp directory (`cbm-diagnostics-.json` and `cbm-diagnostics-.ndjson`). | | `CBM_DOWNLOAD_URL` | *(GitHub releases)* | Override the download URL for updates. Used for testing or self-hosted deployments. | -| `CBM_LOG_LEVEL` | `info` | Set the minimum log level. Accepted values (case-insensitive): `debug`, `info`, `warn`, `error`, `none` — or their numeric equivalents `0`–`4` matching the internal enum. Logs go to stderr; stdout is reserved for MCP JSON-RPC. | +| `CBM_LOG_LEVEL` | `info` | Set the minimum log level. Accepted values (case-insensitive): `debug`, `info`, `warn`, `error`, `none` — or their numeric equivalents `0`–`4` matching the internal enum. Thin-frontend messages go to that session's stderr; detached daemon events go to `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. Stdout is reserved for MCP JSON-RPC. | | `CBM_WORKERS` | *(detected)* | Override the parallel-indexing worker count returned by `cbm_default_worker_count`. Useful inside containers where `sysconf(_SC_NPROCESSORS_ONLN)` reports host CPUs rather than the cgroup's effective quota. Range 1–256; invalid values are ignored with a warning. | | `CBM_MEM_BUDGET_MB` | *(detected)* | Override the in-memory graph budget with an explicit cap in MiB, taking precedence over the `ram_fraction × total_RAM` default. Useful on bare-metal hosts without a cgroup limit, or to pin a budget *below* the cgroup limit so headroom is left for sibling processes. Must be a positive integer; it is clamped to detected total RAM (logged as `mem.budget.clamped`), and non-numeric or non-positive values are ignored with a warning (`mem.budget.env.invalid`). | | `CBM_DUMP_VERIFY_MIN_RATIO` | `0.5` | After indexing, compare persisted SQLite node count to the in-memory dump count. When persisted nodes fall below this fraction of committed nodes (and committed > 50), `index_repository` returns `status:"degraded"` instead of silent `indexed`. Range 0–1; set `0` to disable. Invalid values are ignored with a warning. | +Environment used by daemon-owned components—such as diagnostics, daemon logging, and process-wide indexing resource limits—is captured from the first daemon-backed session that starts the daemon. Later sessions join that process and cannot replace those values. To change them, close all daemon-backed sessions, update the relevant agent configurations consistently, and restart a session. `CBM_ALLOWED_ROOT` remains session-specific, a conflicting `CBM_CACHE_DIR` is rejected, and one-shot CLI commands read their own environment without starting the daemon. + ```bash # Store indexes in a custom directory export CBM_CACHE_DIR=~/my-projects/cbm-data diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index c5cb96eb8..b178a255f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -10,6 +10,11 @@ This page documents the configuration files that `codebase-memory-mcp` reads or | Per-project custom extension mapping | `{repo_root}/.codebase-memory.json` | JSON | Overrides conflicting global `extra_extensions` entries. | | CLI-managed runtime settings | `${CBM_CACHE_DIR:-~/.cache/codebase-memory-mcp}/_config.db` | SQLite | Written by `codebase-memory-mcp config set/reset`. | | UI settings | `${CBM_CACHE_DIR:-~/.cache/codebase-memory-mcp}/config.json` | JSON | Stores `ui_enabled` and `ui_port`. | +| Daemon operation log | `${CBM_CACHE_DIR:-~/.cache/codebase-memory-mcp}/logs/cbm-daemon.log` | Structured log | Durable daemon lifecycle, watcher/indexing, UI, resource, and error events. | +| Admission conflict log | `${CBM_CACHE_DIR:-~/.cache/codebase-memory-mcp}/logs/daemon-conflicts.ndjson` | NDJSON | Exact-build, ABI, and canonical-cache conflicts. | +| Activation log | `${CBM_CACHE_DIR:-~/.cache/codebase-memory-mcp}/logs/activation-events.ndjson` | NDJSON | Install/update/uninstall activation progress and outcomes. | + +CBM resolves `CBM_CACHE_DIR` to a canonical per-account path before using any of these locations. The log directory and files are private to the account. ## 1. Custom File Extension Mapping @@ -103,6 +108,7 @@ Notes: - If the UI-enabled binary has embedded assets and no UI config file exists yet, the UI auto-enables on first run. - `CBM_CACHE_DIR` changes both the UI config location and the runtime settings database location. +- CBM resolves `CBM_CACHE_DIR` to one canonical per-account cache root. A process configured with a different root fails while any CBM session or command is active; close them before switching roots. ## 4. Environment Variables @@ -112,11 +118,13 @@ These environment variables affect runtime behavior: |---|---|---| | `CBM_ALLOWED_ROOT` | *(unset)* | Restrict `index_repository` to paths within this directory. When set, a `repo_path` that resolves (after symlink / `..` resolution) outside this root is refused; unset imposes no restriction. Useful when the server may be driven by an untrusted caller (agentic or multi-tenant deployments). | | `CBM_CACHE_DIR` | `~/.cache/codebase-memory-mcp` | Override the cache directory used for indexes, `_config.db`, and UI `config.json`. | -| `CBM_DIAGNOSTICS` | `false` | Enable periodic diagnostics output to `/tmp/cbm-diagnostics-.json`. | +| `CBM_DIAGNOSTICS` | `false` | Enable the shared daemon's periodic snapshot and trajectory files in the system temp directory (`cbm-diagnostics-.json` and `cbm-diagnostics-.ndjson`). | | `CBM_DOWNLOAD_URL` | GitHub releases | Override the update download URL. | -| `CBM_LOG_LEVEL` | `info` | Set stderr log level to `debug`, `info`, `warn`, `error`, or `none` (or `0`-`4`). | +| `CBM_LOG_LEVEL` | `info` | Set the log level to `debug`, `info`, `warn`, `error`, or `none` (or `0`-`4`). Thin-frontend messages use that session's stderr; detached daemon events use `${CBM_CACHE_DIR}/logs/cbm-daemon.log`. | | `CBM_WORKERS` | auto-detected | Override the indexing worker count. | +Environment used by daemon-owned components—such as diagnostics, daemon logging, and process-wide indexing resource limits—is captured from the first daemon-backed session that starts the daemon. Later sessions join the existing process and cannot replace those values. To change them, close every daemon-backed session, update the relevant agent configurations consistently, and restart a session. `CBM_ALLOWED_ROOT` remains session-specific, a conflicting `CBM_CACHE_DIR` is rejected, and one-shot CLI commands use their own current environment without starting the daemon. + ## 5. Agent and Editor Integration Files The `install` command can also write MCP entries and instruction blocks into agent/editor config files such as Claude Code, Codex, Gemini, VS Code, Cursor, Zed, and others. diff --git a/install.ps1 b/install.ps1 index 63ac408f8..0cae9a24a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -14,6 +14,14 @@ Add-Type -AssemblyName System.Net.Http $Repo = "DeusData/codebase-memory-mcp" $InstallDir = "$env:LOCALAPPDATA\Programs\codebase-memory-mcp" $BinName = "codebase-memory-mcp.exe" +$PayloadName = "codebase-memory-mcp.payload.exe" +$WindowsArchiveNames = @( + $BinName, + $PayloadName, + "LICENSE", + "install.ps1", + "THIRD_PARTY_NOTICES.md" +) $BaseUrl = if ($env:CBM_DOWNLOAD_URL) { $env:CBM_DOWNLOAD_URL } else { "https://github.com/$Repo/releases/latest/download" } try { $BaseUri = [Uri]$BaseUrl } catch { $BaseUri = $null } @@ -179,27 +187,82 @@ try { exit 1 } -# Extract +# Validate the zip namespace before extraction. Windows paths are +# case-insensitive, so two entries that differ only in case are ambiguous and +# must never be allowed to overwrite each other. The official five entries are +# required at the archive root with their exact release names. +try { + Add-Type -AssemblyName System.IO.Compression.FileSystem + $zip = [System.IO.Compression.ZipFile]::OpenRead("$TmpDir\$Archive") + try { + $seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase) + $archiveCounts = @{} + foreach ($archiveName in $WindowsArchiveNames) { $archiveCounts[$archiveName] = 0 } + foreach ($entry in $zip.Entries) { + $entryName = $entry.FullName.Replace('\', '/') + $isDirectory = $entryName.EndsWith('/') + $pathForSegments = if ($isDirectory) { $entryName.TrimEnd('/') } else { $entryName } + $segments = @($pathForSegments.Split('/')) + if ([string]::IsNullOrEmpty($pathForSegments) -or + $entryName.StartsWith('/') -or + $entryName.Contains(':') -or + $segments -contains '' -or + $segments -contains '.' -or + $segments -contains '..' -or + @($segments | Where-Object { $_.EndsWith('.') -or $_.EndsWith(' ') }).Count -gt 0) { + throw "unsafe zip entry path: $($entry.FullName)" + } + if (-not $seen.Add($pathForSegments)) { + throw "duplicate or case-conflicting zip entry: $($entry.FullName)" + } + if (-not ($WindowsArchiveNames -ccontains $entryName) -or $isDirectory) { + throw "archive contains an unexpected root entry: $($entry.FullName)" + } + $archiveCounts[$entryName] = $archiveCounts[$entryName] + 1 + } + foreach ($archiveName in $WindowsArchiveNames) { + if ($archiveCounts[$archiveName] -ne 1) { + throw "archive must contain exactly one $archiveName" + } + } + if ($seen.Count -ne $WindowsArchiveNames.Count) { + throw "archive does not match the exact Windows release allowlist" + } + } finally { + $zip.Dispose() + } +} catch { + Write-Host "error: unsafe or incomplete release archive: $_" -ForegroundColor Red + Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue + exit 1 +} + +# Extract the complete, validated bundle. The portable launcher owns the +# managed install transaction and contains its adjacent payload. Write-Host "Extracting..." Expand-Archive -Path "$TmpDir\$Archive" -DestinationPath $TmpDir -Force -$DlBin = Join-Path $TmpDir $BinName -if (-not (Test-Path $DlBin)) { - # UI variant may have different name in zip - $UiBin = Join-Path $TmpDir "codebase-memory-mcp-ui.exe" - if (Test-Path $UiBin) { - Rename-Item $UiBin $BinName - $DlBin = Join-Path $TmpDir $BinName - } else { - Write-Host "error: binary not found after extraction" -ForegroundColor Red - Remove-Item -Recurse -Force $TmpDir - exit 1 - } +$DownloadedLauncher = Join-Path $TmpDir $BinName +$DownloadedPayload = Join-Path $TmpDir $PayloadName +if (-not (Test-Path -LiteralPath $DownloadedLauncher -PathType Leaf) -or + -not (Test-Path -LiteralPath $DownloadedPayload -PathType Leaf)) { + Write-Host "error: launcher or payload not found after extraction" -ForegroundColor Red + Remove-Item -Recurse -Force $TmpDir + exit 1 +} +$launcherItem = Get-Item -LiteralPath $DownloadedLauncher +$payloadItem = Get-Item -LiteralPath $DownloadedPayload +if (($launcherItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -or + ($payloadItem.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { + Write-Host "error: refusing reparse-point executable in release archive" -ForegroundColor Red + Remove-Item -Recurse -Force $TmpDir + exit 1 } -# Verify the candidate before it asks all coordinated CBM processes to stop. +# Verify the complete launcher/payload path before it asks all coordinated CBM +# processes to stop. try { - $candidateVersion = & $DlBin --version 2>&1 + $candidateVersion = & $DownloadedLauncher --version 2>&1 if ($LASTEXITCODE -ne 0) { throw "candidate exited with $LASTEXITCODE" } Write-Host "Verified candidate: $candidateVersion" } catch { @@ -211,7 +274,9 @@ try { $Dest = Join-Path $InstallDir $BinName $InstallArgs = @("install", "-y", "--force", "--dir=$InstallDir") if ($SkipConfig) { $InstallArgs += "--skip-config" } -& $DlBin @InstallArgs +# PowerShell's call operator waits for the launcher, which in turn contains the +# install payload in its native job and relays the exact exit status. +& $DownloadedLauncher @InstallArgs if ($LASTEXITCODE -ne 0) { Write-Host "error: coordinated activation failed (exit code $LASTEXITCODE)" -ForegroundColor Red Remove-Item -Recurse -Force $TmpDir -ErrorAction SilentlyContinue diff --git a/pkg/go/cmd/codebase-memory-mcp/main.go b/pkg/go/cmd/codebase-memory-mcp/main.go index 8f89f7150..5677a7909 100644 --- a/pkg/go/cmd/codebase-memory-mcp/main.go +++ b/pkg/go/cmd/codebase-memory-mcp/main.go @@ -14,6 +14,7 @@ import ( "archive/zip" "compress/gzip" "context" + "crypto/rand" "crypto/sha256" "encoding/hex" "fmt" @@ -31,8 +32,10 @@ import ( ) const ( - repo = "DeusData/codebase-memory-mcp" - version = "0.8.1" + repo = "DeusData/codebase-memory-mcp" + version = "0.8.1" + windowsLauncherName = "codebase-memory-mcp.exe" + windowsPayloadName = "codebase-memory-mcp.payload.exe" maxRedirects = 5 requestTimeout = 2 * time.Minute @@ -40,39 +43,111 @@ const ( responseHeaderTimeout = 30 * time.Second candidateTimeout = 15 * time.Second maxChecksumManifestSize = 1024 * 1024 + + windowsPairLockName = ".codebase-memory-mcp-pair.lock" + windowsPairLockWait = 45 * time.Second + windowsPairOwnerlessStale = 30 * time.Second + windowsPairLockPoll = 25 * time.Millisecond + windowsPairLockOwnerFile = "owner" + windowsPairTransactionTagSize = 16 ) +type windowsPairLock struct { + path string + token string +} + func main() { - bin, err := ensureBinary() + executable, err := ensureBinary() if err != nil { fmt.Fprintf(os.Stderr, "codebase-memory-mcp: %v\n", err) os.Exit(1) } - if err := execBinary(bin, os.Args[1:]); err != nil { + if err := execBinary(executable, os.Args[1:]); err != nil { + if runtime.GOOS == "windows" { + if exitErr, ok := err.(*exec.ExitError); ok { + printPortableMutationGuidance(os.Args[1:]) + os.Exit(exitErr.ExitCode()) + } + } fmt.Fprintf(os.Stderr, "codebase-memory-mcp: %v\n", err) os.Exit(1) } } func ensureBinary() (string, error) { - bin := binPath() - if _, err := os.Stat(bin); err == nil { - return bin, nil + payload := binPath() + ready := regularFileExists(payload) + if runtime.GOOS == "windows" { + ready = ready && regularFileExists(windowsLauncherPath()) + if ready { + ready = verifyCandidate(windowsLauncherPath()) == nil + } + } + if ready { + return executionPathForOS(payload, runtime.GOOS), nil } - if err := download(bin); err != nil { + if err := download(payload); err != nil { return "", err } - return bin, nil + return executionPathForOS(payload, runtime.GOOS), nil } func binPath() string { name := "codebase-memory-mcp" if runtime.GOOS == "windows" { - name += ".exe" + // The payload remains the immutable exact-version pair's readiness + // signal. executionPathForOS selects its adjacent permanent launcher. + name = windowsPayloadName } return filepath.Join(cacheDir(), version, name) } +func executionPathForOS(payload, targetOS string) string { + if targetOS == "windows" { + return filepath.Join(filepath.Dir(payload), windowsLauncherName) + } + return payload +} + +func windowsLauncherPath() string { + return filepath.Join(filepath.Dir(binPath()), windowsLauncherName) +} + +func regularFileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && info.Mode().IsRegular() +} + +func printPortableMutationGuidance(args []string) { + action := portableMutationAction(args) + if action == "" { + return + } + packageCommand := "go install github.com/DeusData/codebase-memory-mcp/pkg/go/cmd/codebase-memory-mcp@latest" + if action == "uninstall" { + packageCommand = "Remove-Item (Get-Command codebase-memory-mcp).Source" + } + fmt.Fprintf( + os.Stderr, + "This Go Windows copy is portable. Use %q for package maintenance, or run %q once to create a managed launcher with coordinated self-update/uninstall.\n", + packageCommand, + "codebase-memory-mcp install --yes", + ) +} + +func portableMutationAction(args []string) string { + for _, argument := range args { + switch argument { + case "cli", "hook-augment", "config", "install", "--help", "-h", "--version": + return "" + case "update", "uninstall": + return argument + } + } + return "" +} + func cacheDir() string { if d := os.Getenv("CBM_CACHE_DIR"); d != "" { return d @@ -164,8 +239,18 @@ func download(dest string) error { } binName := "codebase-memory-mcp" + requiredNames := []string{binName} + archiveNames := requiredNames if platform == "windows" { - binName += ".exe" + binName = windowsPayloadName + requiredNames = []string{windowsLauncherName, windowsPayloadName} + archiveNames = []string{ + windowsLauncherName, + windowsPayloadName, + "LICENSE", + "install.ps1", + "THIRD_PARTY_NOTICES.md", + } } if ext == "tar.gz" { @@ -173,15 +258,23 @@ func download(dest string) error { return fmt.Errorf("extraction failed: %w", err) } } else { - if err := extractZip(archivePath, tmp, binName); err != nil { + if err := extractZip(archivePath, tmp, archiveNames, requiredNames); err != nil { return fmt.Errorf("extraction failed: %w", err) } } - extracted := filepath.Join(tmp, binName) - if err := os.Chmod(extracted, 0755); err != nil { - return fmt.Errorf("could not set candidate permissions: %w", err) + for _, name := range requiredNames { + extracted := filepath.Join(tmp, name) + if err := os.Chmod(extracted, 0755); err != nil { + return fmt.Errorf("could not set candidate permissions for %s: %w", name, err) + } } - if err := verifyCandidate(extracted); err != nil { + if platform == "windows" { + // In portable mode the canonical launcher must successfully resolve the + // adjacent payload before the pair is cached. + if err := verifyCandidate(filepath.Join(tmp, windowsLauncherName)); err != nil { + return fmt.Errorf("downloaded launcher failed verification: %w", err) + } + } else if err := verifyCandidate(filepath.Join(tmp, binName)); err != nil { return err } @@ -189,7 +282,11 @@ func download(dest string) error { return fmt.Errorf("could not create cache dir: %w", err) } - if err := installCandidateAtomically(extracted, dest); err != nil { + if platform == "windows" { + if err := installWindowsPairAtomically(tmp, filepath.Dir(dest)); err != nil { + return fmt.Errorf("could not install Windows release pair: %w", err) + } + } else if err := installCandidateAtomically(filepath.Join(tmp, binName), dest); err != nil { return fmt.Errorf("could not install binary: %w", err) } @@ -330,6 +427,7 @@ func extractTarGz(archivePath, destDir, targetFile string) error { } defer gz.Close() tr := tar.NewReader(gz) + found := false for { hdr, err := tr.Next() if err == io.EOF { @@ -338,42 +436,131 @@ func extractTarGz(archivePath, destDir, targetFile string) error { if err != nil { return err } - if filepath.Base(hdr.Name) == targetFile { - out, err := os.Create(filepath.Join(destDir, targetFile)) + name := strings.ReplaceAll(hdr.Name, "\\", "/") + if name == targetFile { + if found { + os.Remove(filepath.Join(destDir, targetFile)) + return fmt.Errorf("duplicate %s in archive", targetFile) + } + found = true + out, err := os.OpenFile( + filepath.Join(destDir, targetFile), + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + 0600, + ) if err != nil { return err } - defer out.Close() - _, err = io.Copy(out, tr) //nolint:gosec - return err + _, copyErr := io.Copy(out, tr) //nolint:gosec + closeErr := out.Close() + if copyErr != nil { + os.Remove(filepath.Join(destDir, targetFile)) + return copyErr + } + if closeErr != nil { + os.Remove(filepath.Join(destDir, targetFile)) + return closeErr + } } } - return fmt.Errorf("%s not found in archive", targetFile) + if !found { + return fmt.Errorf("%s not found in archive", targetFile) + } + return nil } -func extractZip(archivePath, destDir, targetFile string) error { +func extractZip( + archivePath, destDir string, + archiveNames, extractNames []string, +) error { r, err := zip.OpenReader(archivePath) if err != nil { return err } defer r.Close() + seen := make(map[string]struct{}, len(r.File)) + required := make(map[string]struct{}, len(archiveNames)) + targets := make(map[string]*zip.File, len(extractNames)) + for _, name := range archiveNames { + required[name] = struct{}{} + } for _, f := range r.File { - if filepath.Base(f.Name) == targetFile { - rc, err := f.Open() - if err != nil { - return err - } - defer rc.Close() - out, err := os.Create(filepath.Join(destDir, targetFile)) - if err != nil { - return err + name, err := validateWindowsZipMember(f.Name) + if err != nil { + return err + } + key := strings.ToLower(strings.TrimSuffix(name, "/")) + if _, ok := seen[key]; ok { + return fmt.Errorf("duplicate or case-conflicting zip entry: %q", f.Name) + } + seen[key] = struct{}{} + if _, ok := required[name]; !ok || f.FileInfo().IsDir() { + return fmt.Errorf( + "archive must contain only the exact root files: %s", + strings.Join(archiveNames, ", "), + ) + } + for _, extractName := range extractNames { + if name == extractName { + targets[name] = f } - defer out.Close() - _, err = io.Copy(out, rc) //nolint:gosec + } + } + if len(seen) != len(archiveNames) || len(targets) != len(extractNames) { + return fmt.Errorf( + "archive must contain exactly one of each required root file: %s", + strings.Join(archiveNames, ", "), + ) + } + + for _, name := range extractNames { + target := targets[name] + rc, err := target.Open() + if err != nil { return err } + outputPath := filepath.Join(destDir, name) + out, err := os.OpenFile( + outputPath, + os.O_WRONLY|os.O_CREATE|os.O_EXCL, + 0600, + ) + if err != nil { + rc.Close() + return err + } + _, copyErr := io.Copy(out, rc) //nolint:gosec + closeOutErr := out.Close() + closeReadErr := rc.Close() + if copyErr != nil { + os.Remove(outputPath) + return copyErr + } + if closeOutErr != nil { + os.Remove(outputPath) + return closeOutErr + } + if closeReadErr != nil { + os.Remove(outputPath) + return closeReadErr + } } - return fmt.Errorf("%s not found in archive", targetFile) + return nil +} + +func validateWindowsZipMember(raw string) (string, error) { + name := strings.ReplaceAll(raw, "\\", "/") + segmentsName := strings.TrimSuffix(name, "/") + if segmentsName == "" || strings.HasPrefix(name, "/") || strings.Contains(name, ":") { + return "", fmt.Errorf("unsafe zip entry path: %q", raw) + } + for _, segment := range strings.Split(segmentsName, "/") { + if segment == "" || segment == "." || segment == ".." || + strings.HasSuffix(segment, ".") || strings.HasSuffix(segment, " ") { + return "", fmt.Errorf("unsafe zip entry path: %q", raw) + } + } + return name, nil } func verifyCandidate(path string) error { @@ -392,6 +579,45 @@ func verifyCandidate(path string) error { return nil } +func fileSHA256(path string) ([sha256.Size]byte, error) { + var digest [sha256.Size]byte + source, err := os.Open(path) + if err != nil { + return digest, err + } + defer source.Close() + hash := sha256.New() + if _, err := io.Copy(hash, source); err != nil { + return digest, err + } + copy(digest[:], hash.Sum(nil)) + return digest, nil +} + +func filesEqualSHA256(left, right string) (bool, error) { + leftInfo, err := os.Stat(left) + if err != nil { + return false, err + } + rightInfo, err := os.Stat(right) + if err != nil { + return false, err + } + if !leftInfo.Mode().IsRegular() || !rightInfo.Mode().IsRegular() || + leftInfo.Size() != rightInfo.Size() { + return false, nil + } + leftDigest, err := fileSHA256(left) + if err != nil { + return false, err + } + rightDigest, err := fileSHA256(right) + if err != nil { + return false, err + } + return leftDigest == rightDigest, nil +} + func installCandidateAtomically(src, dst string) error { in, err := os.Open(src) if err != nil { @@ -427,18 +653,283 @@ func installCandidateAtomically(src, dst string) error { return err } if err := os.Rename(staged, dst); err != nil { + identical, compareErr := filesEqualSHA256(staged, dst) + if compareErr == nil && identical { + return verifyCandidate(dst) + } return err } return nil } -func execBinary(bin string, args []string) error { +func windowsPairToken() (string, error) { + raw := make([]byte, windowsPairTransactionTagSize) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return hex.EncodeToString(raw), nil +} + +func windowsPairLockOwner(lockPath string) (int, string, bool) { + data, err := os.ReadFile(filepath.Join(lockPath, windowsPairLockOwnerFile)) + if err != nil { + return 0, "", false + } + var pid int + var token string + var extra string + count, _ := fmt.Sscanf(string(data), "%d %s %s", &pid, &token, &extra) + decoded, decodeErr := hex.DecodeString(token) + if count != 2 || pid <= 0 || decodeErr != nil || + len(decoded) != windowsPairTransactionTagSize { + return 0, "", false + } + return pid, token, true +} + +func windowsPairTryReclaimLock(lockPath, contenderToken string) bool { + pid, _, ownerOK := windowsPairLockOwner(lockPath) + reclaim := ownerOK && !cbmWindowsLockProcessAlive(pid) + if !ownerOK { + status, err := os.Stat(lockPath) + if os.IsNotExist(err) { + return true + } + if err != nil { + return false + } + reclaim = time.Since(status.ModTime()) >= windowsPairOwnerlessStale + } + if !reclaim { + return false + } + reclaimed := lockPath + ".reclaimed-" + contenderToken + if err := os.Rename(lockPath, reclaimed); err != nil { + return os.IsNotExist(err) + } + _ = os.RemoveAll(reclaimed) + return true +} + +func acquireWindowsPairLock(dstDir string) (*windowsPairLock, error) { + token, err := windowsPairToken() + if err != nil { + return nil, err + } + lockPath := filepath.Join(dstDir, windowsPairLockName) + deadline := time.Now().Add(windowsPairLockWait) + for { + if err := os.Mkdir(lockPath, 0700); err == nil { + owner := []byte(fmt.Sprintf("%d %s\n", os.Getpid(), token)) + ownerPath := filepath.Join(lockPath, windowsPairLockOwnerFile) + if err := os.WriteFile(ownerPath, owner, 0600); err != nil { + _ = os.RemoveAll(lockPath) + return nil, err + } + return &windowsPairLock{path: lockPath, token: token}, nil + } else if !os.IsExist(err) { + return nil, err + } + if windowsPairTryReclaimLock(lockPath, token) { + continue + } + if !time.Now().Before(deadline) { + return nil, fmt.Errorf( + "timed out waiting for Windows package-cache publication lock") + } + time.Sleep(windowsPairLockPoll) + } +} + +func releaseWindowsPairLock(lock *windowsPairLock) error { + if lock == nil { + return fmt.Errorf("missing Windows package-cache publication lock") + } + pid, token, ok := windowsPairLockOwner(lock.path) + if !ok || pid != os.Getpid() || token != lock.token { + return fmt.Errorf("Windows package-cache publication lock ownership changed") + } + if err := os.Remove(filepath.Join(lock.path, windowsPairLockOwnerFile)); err != nil { + return err + } + return os.Remove(lock.path) +} + +func windowsPairReady(dstDir string, verifier func(string) error) bool { + launcher := filepath.Join(dstDir, windowsLauncherName) + payload := filepath.Join(dstDir, windowsPayloadName) + launcherStatus, launcherErr := os.Lstat(launcher) + payloadStatus, payloadErr := os.Lstat(payload) + return launcherErr == nil && payloadErr == nil && + launcherStatus.Mode().IsRegular() && payloadStatus.Mode().IsRegular() && + verifier(launcher) == nil +} + +func copyWindowsPairStage(src, dst string) error { + input, err := os.Open(src) + if err != nil { + return err + } + output, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + _ = input.Close() + return err + } + _, copyErr := io.Copy(output, input) + closeInputErr := input.Close() + chmodErr := output.Chmod(0755) + syncErr := output.Sync() + closeOutputErr := output.Close() + for _, candidate := range []error{ + copyErr, closeInputErr, chmodErr, syncErr, closeOutputErr, + } { + if candidate != nil { + return candidate + } + } + return nil +} + +func pathMatchesSHA256(path string, expected [sha256.Size]byte) bool { + status, err := os.Lstat(path) + if err != nil || !status.Mode().IsRegular() { + return false + } + actual, err := fileSHA256(path) + return err == nil && actual == expected +} + +func installWindowsPairAtomicallyWithVerifier( + srcDir, dstDir string, verifier func(string) error, +) (result error) { + names := []string{windowsLauncherName, windowsPayloadName} + if err := verifier(filepath.Join(srcDir, windowsLauncherName)); err != nil { + return fmt.Errorf("source Windows release pair failed verification: %w", err) + } + lock, err := acquireWindowsPairLock(dstDir) + if err != nil { + return err + } + defer func() { + if releaseErr := releaseWindowsPairLock(lock); result == nil && releaseErr != nil { + result = releaseErr + } + }() + + // A contender may have committed while this process waited. Preserve that + // complete pair regardless of whether its authenticated release variant is + // byte-identical to this contender. + if windowsPairReady(dstDir, verifier) { + return nil + } + + transaction, err := windowsPairToken() + if err != nil { + return err + } + staged := make(map[string]string, len(names)) + stagedDigests := make(map[string][sha256.Size]byte, len(names)) + backups := make(map[string]string, len(names)) + publishedDigests := make(map[string][sha256.Size]byte, len(names)) + defer func() { + for _, stagedPath := range staged { + _ = os.Remove(stagedPath) + } + }() + + operationErr := func() error { + for _, name := range names { + stagePath := filepath.Join( + dstDir, ".cbm-pair-stage-"+transaction+"-"+name) + if err := copyWindowsPairStage( + filepath.Join(srcDir, name), stagePath); err != nil { + return err + } + staged[name] = stagePath + digest, err := fileSHA256(stagePath) + if err != nil { + return err + } + stagedDigests[name] = digest + } + + for _, name := range names { + target := filepath.Join(dstDir, name) + status, err := os.Lstat(target) + if os.IsNotExist(err) { + continue + } + if err != nil { + return err + } + if !status.Mode().IsRegular() { + return fmt.Errorf( + "refusing unsafe Windows package-cache target: %s", target) + } + backup := filepath.Join( + dstDir, ".cbm-pair-backup-"+transaction+"-"+name) + if err := os.Rename(target, backup); err != nil { + return err + } + backups[name] = backup + } + + // Payload is the readiness signal and is deliberately published last. + for _, name := range names { + target := filepath.Join(dstDir, name) + if err := os.Rename(staged[name], target); err != nil { + return err + } + delete(staged, name) + publishedDigests[name] = stagedDigests[name] + } + if !windowsPairReady(dstDir, verifier) { + return fmt.Errorf("published Windows package-cache pair failed verification") + } + return nil + }() + + if operationErr == nil || windowsPairReady(dstDir, verifier) { + for _, backup := range backups { + _ = os.Remove(backup) + } + return nil + } + + // Roll back only files whose digest proves this transaction published them. + for index := len(names) - 1; index >= 0; index-- { + name := names[index] + digest, ok := publishedDigests[name] + target := filepath.Join(dstDir, name) + if ok && pathMatchesSHA256(target, digest) { + _ = os.Remove(target) + } + } + for _, name := range names { + backup, ok := backups[name] + if !ok { + continue + } + target := filepath.Join(dstDir, name) + if _, err := os.Lstat(target); os.IsNotExist(err) { + _ = os.Rename(backup, target) + } + } + return operationErr +} + +func installWindowsPairAtomically(srcDir, dstDir string) error { + return installWindowsPairAtomicallyWithVerifier( + srcDir, dstDir, verifyCandidate) +} + +func execBinary(executable string, args []string) error { if runtime.GOOS == "windows" { - cmd := exec.Command(bin, args...) + cmd := exec.Command(executable, args...) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() } - return syscall.Exec(bin, append([]string{bin}, args...), os.Environ()) + return syscall.Exec(executable, append([]string{executable}, args...), os.Environ()) } diff --git a/pkg/go/cmd/codebase-memory-mcp/main_test.go b/pkg/go/cmd/codebase-memory-mcp/main_test.go new file mode 100644 index 000000000..083a92479 --- /dev/null +++ b/pkg/go/cmd/codebase-memory-mcp/main_test.go @@ -0,0 +1,197 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func TestExecutionPathForOSUsesAdjacentWindowsLauncher(t *testing.T) { + payload := filepath.Join("cache", version, windowsPayloadName) + want := filepath.Join("cache", version, windowsLauncherName) + + if got := executionPathForOS(payload, "windows"); got != want { + t.Fatalf("Windows execution path = %q, want adjacent launcher %q", got, want) + } + if got := executionPathForOS(payload, "linux"); got != payload { + t.Fatalf("non-Windows execution path = %q, want payload %q", got, payload) + } +} + +func TestPortableMutationActionPreservesPackageManagerRefusal(t *testing.T) { + cases := []struct { + args []string + want string + }{ + {args: []string{"update", "--yes"}, want: "update"}, + {args: []string{"uninstall", "--yes"}, want: "uninstall"}, + {args: []string{"install", "--yes"}, want: ""}, + {args: []string{"cli", "update"}, want: ""}, + } + + for _, testCase := range cases { + if got := portableMutationAction(testCase.args); got != testCase.want { + t.Errorf("portableMutationAction(%q) = %q, want %q", testCase.args, got, testCase.want) + } + } +} + +func writeTestWindowsPair(t *testing.T, directory, tag string) { + t.Helper() + if err := os.MkdirAll(directory, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(directory, windowsLauncherName), + []byte("launcher:"+tag), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(directory, windowsPayloadName), + []byte("payload:"+tag), 0755); err != nil { + t.Fatal(err) + } +} + +func verifyTestWindowsPair(launcherPath string) error { + launcher, err := os.ReadFile(launcherPath) + if err != nil { + return err + } + payload, err := os.ReadFile( + filepath.Join(filepath.Dir(launcherPath), windowsPayloadName)) + if err != nil { + return err + } + const prefix = "launcher:" + if !strings.HasPrefix(string(launcher), prefix) { + return fmt.Errorf("invalid launcher test fixture") + } + tag := strings.TrimPrefix(string(launcher), prefix) + if string(payload) != "payload:"+tag { + return fmt.Errorf("launcher/payload test fixture mismatch") + } + return nil +} + +func assertTestWindowsPair(t *testing.T, directory, tag string) { + t.Helper() + launcher, err := os.ReadFile(filepath.Join(directory, windowsLauncherName)) + if err != nil { + t.Fatal(err) + } + payload, err := os.ReadFile(filepath.Join(directory, windowsPayloadName)) + if err != nil { + t.Fatal(err) + } + if string(launcher) != "launcher:"+tag || string(payload) != "payload:"+tag { + t.Fatalf("pair = %q / %q, want coherent tag %q", launcher, payload, tag) + } +} + +func TestWindowsPairPublicationRepairsCorruptAndPartialCaches(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, destination string) + }{ + { + name: "corrupt launcher", + setup: func(t *testing.T, destination string) { + writeTestWindowsPair(t, destination, "old") + if err := os.WriteFile( + filepath.Join(destination, windowsLauncherName), + []byte("corrupt"), 0755); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "corrupt payload", + setup: func(t *testing.T, destination string) { + writeTestWindowsPair(t, destination, "old") + if err := os.WriteFile( + filepath.Join(destination, windowsPayloadName), + []byte("corrupt"), 0755); err != nil { + t.Fatal(err) + } + }, + }, + { + name: "launcher only", + setup: func(t *testing.T, destination string) { + if err := os.MkdirAll(destination, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile( + filepath.Join(destination, windowsLauncherName), + []byte("launcher:partial"), 0755); err != nil { + t.Fatal(err) + } + }, + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + root := t.TempDir() + source := filepath.Join(root, "source") + destination := filepath.Join(root, "destination") + writeTestWindowsPair(t, source, "candidate") + testCase.setup(t, destination) + + if err := installWindowsPairAtomicallyWithVerifier( + source, destination, verifyTestWindowsPair); err != nil { + t.Fatal(err) + } + assertTestWindowsPair(t, destination, "candidate") + }) + } +} + +func TestWindowsPairPublicationSerializesConcurrentCandidates(t *testing.T) { + root := t.TempDir() + destination := filepath.Join(root, "destination") + if err := os.MkdirAll(destination, 0755); err != nil { + t.Fatal(err) + } + sources := []string{filepath.Join(root, "source-a"), filepath.Join(root, "source-b")} + writeTestWindowsPair(t, sources[0], "a") + writeTestWindowsPair(t, sources[1], "b") + + start := make(chan struct{}) + errors := make(chan error, len(sources)) + var wait sync.WaitGroup + for _, source := range sources { + wait.Add(1) + go func(candidate string) { + defer wait.Done() + <-start + errors <- installWindowsPairAtomicallyWithVerifier( + candidate, destination, verifyTestWindowsPair) + }(source) + } + close(start) + wait.Wait() + close(errors) + for err := range errors { + if err != nil { + t.Fatal(err) + } + } + if err := verifyTestWindowsPair( + filepath.Join(destination, windowsLauncherName)); err != nil { + t.Fatal(err) + } + launcher, err := os.ReadFile(filepath.Join(destination, windowsLauncherName)) + if err != nil { + t.Fatal(err) + } + tag := strings.TrimPrefix(string(launcher), "launcher:") + if tag != "a" && tag != "b" { + t.Fatalf("unexpected concurrent winner tag %q", tag) + } + assertTestWindowsPair(t, destination, tag) +} diff --git a/pkg/go/cmd/codebase-memory-mcp/pair_lock_other.go b/pkg/go/cmd/codebase-memory-mcp/pair_lock_other.go new file mode 100644 index 000000000..40dc78fe0 --- /dev/null +++ b/pkg/go/cmd/codebase-memory-mcp/pair_lock_other.go @@ -0,0 +1,10 @@ +//go:build !windows + +package main + +// Windows pair publication is never invoked by a non-Windows wrapper. Keeping +// the fallback conservative also lets platform-neutral transaction tests run +// without introducing a second process-liveness policy. +func cbmWindowsLockProcessAlive(pid int) bool { + return pid > 0 +} diff --git a/pkg/go/cmd/codebase-memory-mcp/pair_lock_windows.go b/pkg/go/cmd/codebase-memory-mcp/pair_lock_windows.go new file mode 100644 index 000000000..d8bb4ecab --- /dev/null +++ b/pkg/go/cmd/codebase-memory-mcp/pair_lock_windows.go @@ -0,0 +1,21 @@ +//go:build windows + +package main + +import "syscall" + +func cbmWindowsLockProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + // PROCESS_QUERY_LIMITED_INFORMATION is sufficient for an existence probe and + // is available on every Windows version supported by the package wrapper. + handle, err := syscall.OpenProcess(0x1000, false, uint32(pid)) + if err == nil { + _ = syscall.CloseHandle(handle) + return true + } + // ERROR_INVALID_PARAMETER is the documented result for a process identifier + // that no longer exists. Access denial is conservatively treated as live. + return err != syscall.Errno(87) +} diff --git a/pkg/npm/bin.js b/pkg/npm/bin.js index a87b33c9f..f00105e30 100644 --- a/pkg/npm/bin.js +++ b/pkg/npm/bin.js @@ -8,16 +8,37 @@ const fs = require('fs'); const { spawnSync } = require('child_process'); const isWindows = process.platform === 'win32'; -const binName = isWindows ? 'codebase-memory-mcp.exe' : 'codebase-memory-mcp'; +const windowsLauncherName = 'codebase-memory-mcp.exe'; +// npm is a portable one-shot wrapper. On Windows the public .exe in the +// release archive is the permanent launcher, so privately validate the +// release pair, cache both files, and enter the native process through that +// launcher. The adjacent payload remains the immutable cache readiness signal. +const binName = isWindows ? 'codebase-memory-mcp.payload.exe' : 'codebase-memory-mcp'; const binPath = path.join(__dirname, 'bin', binName); +const launcherPath = path.join(__dirname, 'bin', windowsLauncherName); +const executionPath = isWindows ? launcherPath : binPath; +const cacheReady = () => { + if (!fs.existsSync(binPath) || (isWindows && !fs.existsSync(launcherPath))) { + return false; + } + if (!isWindows) return true; + // The launcher resolving --version through its adjacent payload validates + // both halves of the immutable Windows cache pair. + const probe = spawnSync(launcherPath, ['--version'], { + stdio: 'ignore', + timeout: 15_000, + windowsHide: true, + }); + return !probe.error && probe.status === 0; +}; -if (!fs.existsSync(binPath)) { +if (!cacheReady()) { // Binary missing — try running the install script (handles --ignore-scripts case) process.stderr.write('codebase-memory-mcp: binary not found, downloading...\n'); const installResult = spawnSync(process.execPath, [path.join(__dirname, 'install.js')], { stdio: 'inherit', }); - if (installResult.status !== 0 || !fs.existsSync(binPath)) { + if (installResult.status !== 0 || !cacheReady()) { process.stderr.write( 'codebase-memory-mcp: download failed.\n' + 'Try reinstalling: npm install -g codebase-memory-mcp\n' @@ -26,7 +47,17 @@ if (!fs.existsSync(binPath)) { } } -const result = spawnSync(binPath, process.argv.slice(2), { +function portableMutationAction(args) { + for (const argument of args) { + if (['cli', 'hook-augment', 'config', 'install', '--help', '-h', '--version'].includes(argument)) { + return null; + } + if (argument === 'update' || argument === 'uninstall') return argument; + } + return null; +} + +const result = spawnSync(executionPath, process.argv.slice(2), { stdio: 'inherit', windowsHide: false, }); @@ -36,4 +67,19 @@ if (result.error) { process.exit(1); } +const mutation = isWindows && result.status !== 0 + ? portableMutationAction(process.argv.slice(2)) + : null; +if (mutation) { + const packageCommand = mutation === 'update' + ? 'npm install codebase-memory-mcp@latest' + : 'npm uninstall codebase-memory-mcp'; + process.stderr.write( + `This npm Windows copy is portable. Use "${packageCommand}" for package ` + + 'maintenance (add -g for a global install), or run ' + + '"codebase-memory-mcp install --yes" once to create ' + + 'a managed launcher with coordinated self-update/uninstall.\n', + ); +} + process.exit(result.status ?? 0); diff --git a/pkg/npm/install.js b/pkg/npm/install.js index cb20f3a7d..75af5c6f4 100644 --- a/pkg/npm/install.js +++ b/pkg/npm/install.js @@ -18,6 +18,26 @@ const MAX_REDIRECTS = 5; const DOWNLOAD_HOP_TIMEOUT_MS = 120_000; const CANDIDATE_TIMEOUT_MS = 15_000; const MAX_CHECKSUM_MANIFEST_BYTES = 1024 * 1024; +const WINDOWS_LAUNCHER_NAME = 'codebase-memory-mcp.exe'; +const WINDOWS_PAYLOAD_NAME = 'codebase-memory-mcp.payload.exe'; +const UNIX_ARCHIVE_NAMES = [ + 'codebase-memory-mcp', + 'LICENSE', + 'install.sh', + 'THIRD_PARTY_NOTICES.md', +]; +const WINDOWS_ARCHIVE_NAMES = [ + WINDOWS_LAUNCHER_NAME, + WINDOWS_PAYLOAD_NAME, + 'LICENSE', + 'install.ps1', + 'THIRD_PARTY_NOTICES.md', +]; +const WINDOWS_PAIR_LOCK_NAME = '.codebase-memory-mcp-pair.lock'; +const WINDOWS_PAIR_LOCK_WAIT_MS = 45_000; +const WINDOWS_PAIR_OWNERLESS_STALE_MS = 30_000; +const WINDOWS_PAIR_LOCK_POLL_MS = 25; +const SLEEP_WORD = new Int32Array(new SharedArrayBuffer(4)); function getPlatform() { switch (process.platform) { @@ -51,6 +71,44 @@ function validateUrl(rawUrl) { return parsed.href; } +function validateExactTarMemberListing(listing, expectedNames) { + if (listing.includes('\0')) { + throw new Error('tar member listing contains a NUL byte'); + } + const members = listing.split(/\r?\n/); + if (members[members.length - 1] === '') members.pop(); + const expected = new Set(expectedNames); + const seen = new Set(); + for (const member of members) { + if (!member || !expected.has(member) || seen.has(member)) { + throw new Error(`archive contains an unexpected or duplicate root member: ${JSON.stringify(member)}`); + } + seen.add(member); + } + if (members.length !== expectedNames.length || seen.size !== expected.size) { + throw new Error( + `archive must contain exactly these root files: ${expectedNames.join(', ')}`, + ); + } +} + +function extractExactTarArchive( + archivePath, destPath, expectedNames, targetName, runFile = execFileSync, +) { + const listing = runFile('tar', ['-tzf', archivePath], { + encoding: 'utf8', + maxBuffer: MAX_CHECKSUM_MANIFEST_BYTES, + windowsHide: true, + }); + validateExactTarMemberListing(listing, expectedNames); + // Extract only the fixed root executable. Even a malformed tar implementation + // cannot write an unvalidated companion member outside the private temp tree. + runFile('tar', ['-xzf', archivePath, '-C', destPath, targetName], { + stdio: 'inherit', + windowsHide: true, + }); +} + function downloadHop(url, dest, maxBytes) { return new Promise((resolve, reject) => { let settled = false; @@ -155,14 +213,296 @@ function verifyCandidate(candidatePath) { }); } -function extractZipOnWindows(archivePath, destPath) { +function fileSha256(candidatePath) { + const digest = crypto.createHash('sha256'); + const fd = fs.openSync(candidatePath, 'r'); + const buffer = Buffer.allocUnsafe(64 * 1024); + try { + for (;;) { + const count = fs.readSync(fd, buffer, 0, buffer.length, null); + if (count === 0) break; + digest.update(buffer.subarray(0, count)); + } + } finally { + fs.closeSync(fd); + } + return digest.digest('hex'); +} + +function filesEqualSha256(leftPath, rightPath) { + try { + const left = fs.statSync(leftPath); + const right = fs.statSync(rightPath); + return left.isFile() + && right.isFile() + && left.size === right.size + && fileSha256(leftPath) === fileSha256(rightPath); + } catch (_) { + return false; + } +} + +function sleepSync(milliseconds) { + Atomics.wait(SLEEP_WORD, 0, 0, milliseconds); +} + +function processIsAlive(pid) { + if (!Number.isSafeInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (err) { + // Access denial is conservative evidence of a live process. Only an + // explicit missing-process result permits reclamation. + return err && err.code !== 'ESRCH'; + } +} + +function readPairLockOwner(lockPath) { + try { + const owner = JSON.parse( + fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'), + ); + if (!Number.isSafeInteger(owner.pid) || owner.pid <= 0 || + typeof owner.token !== 'string' || !/^[0-9a-f]{32}$/.test(owner.token)) { + return null; + } + return owner; + } catch (_) { + return null; + } +} + +function tryReclaimPairLock(lockPath, contenderToken) { + let reclaim = false; + const owner = readPairLockOwner(lockPath); + if (owner) { + reclaim = !processIsAlive(owner.pid); + } else { + try { + const age = Date.now() - fs.statSync(lockPath).mtimeMs; + reclaim = age >= WINDOWS_PAIR_OWNERLESS_STALE_MS; + } catch (_) { + return true; // The owner released it between observation and inspection. + } + } + if (!reclaim) return false; + + const reclaimed = `${lockPath}.reclaimed-${contenderToken}`; + try { + fs.renameSync(lockPath, reclaimed); + } catch (err) { + if (err.code === 'ENOENT') return true; + return false; + } + fs.rmSync(reclaimed, { recursive: true, force: true }); + return true; +} + +function acquireWindowsPairLock(destDir) { + const lockPath = path.join(destDir, WINDOWS_PAIR_LOCK_NAME); + const token = crypto.randomBytes(16).toString('hex'); + const deadline = Date.now() + WINDOWS_PAIR_LOCK_WAIT_MS; + for (;;) { + try { + fs.mkdirSync(lockPath, { mode: 0o700 }); + try { + fs.writeFileSync( + path.join(lockPath, 'owner.json'), + `${JSON.stringify({ pid: process.pid, token })}\n`, + { encoding: 'utf8', flag: 'wx', mode: 0o600 }, + ); + } catch (err) { + fs.rmSync(lockPath, { recursive: true, force: true }); + throw err; + } + return { lockPath, token }; + } catch (err) { + if (err.code !== 'EEXIST') throw err; + if (tryReclaimPairLock(lockPath, token)) continue; + if (Date.now() >= deadline) { + throw new Error('timed out waiting for Windows package-cache publication lock'); + } + sleepSync(WINDOWS_PAIR_LOCK_POLL_MS); + } + } +} + +function releaseWindowsPairLock(lock) { + const owner = readPairLockOwner(lock.lockPath); + if (!owner || owner.token !== lock.token || owner.pid !== process.pid) { + throw new Error('Windows package-cache publication lock ownership changed'); + } + fs.unlinkSync(path.join(lock.lockPath, 'owner.json')); + fs.rmdirSync(lock.lockPath); +} + +function windowsPairReady(destDir, verifier = verifyCandidate) { + const launcher = path.join(destDir, WINDOWS_LAUNCHER_NAME); + const payload = path.join(destDir, WINDOWS_PAYLOAD_NAME); + try { + const launcherStatus = fs.lstatSync(launcher); + const payloadStatus = fs.lstatSync(payload); + if (!launcherStatus.isFile() || launcherStatus.isSymbolicLink() || + !payloadStatus.isFile() || payloadStatus.isSymbolicLink()) { + return false; + } + verifier(launcher); + return true; + } catch (_) { + return false; + } +} + +function pathMatchesDigest(candidatePath, expectedDigest) { + try { + const status = fs.lstatSync(candidatePath); + return status.isFile() && !status.isSymbolicLink() && + fileSha256(candidatePath) === expectedDigest; + } catch (_) { + return false; + } +} + +function removeOwnedPublishedPair(destDir, publishedDigests) { + for (const name of [WINDOWS_PAYLOAD_NAME, WINDOWS_LAUNCHER_NAME]) { + const digest = publishedDigests.get(name); + const target = path.join(destDir, name); + if (digest && pathMatchesDigest(target, digest)) { + try { fs.unlinkSync(target); } catch (_) { /* recovery continues below */ } + } + } +} + +function restorePairBackups(destDir, backups) { + for (const name of [WINDOWS_LAUNCHER_NAME, WINDOWS_PAYLOAD_NAME]) { + const backup = backups.get(name); + const target = path.join(destDir, name); + if (!backup || fs.existsSync(target)) continue; + try { fs.renameSync(backup, target); } catch (_) { /* preserve backup */ } + } +} + +function installWindowsPairAtomically(sourceDir, destDir, verifier = verifyCandidate) { + // Authenticate the complete source pair before it can contend for the cache. + verifier(path.join(sourceDir, WINDOWS_LAUNCHER_NAME)); + const lock = acquireWindowsPairLock(destDir); + let operationError = null; + try { + // A contender may have completed while this process waited. Its executable + // pair wins and is never moved or deleted. + if (windowsPairReady(destDir, verifier)) return; + + const transaction = crypto.randomBytes(16).toString('hex'); + const stagedPaths = new Map(); + const stagedDigests = new Map(); + const backups = new Map(); + const publishedDigests = new Map(); + try { + for (const name of [WINDOWS_LAUNCHER_NAME, WINDOWS_PAYLOAD_NAME]) { + const staged = path.join(destDir, `.cbm-pair-stage-${transaction}-${name}`); + fs.copyFileSync(path.join(sourceDir, name), staged, fs.constants.COPYFILE_EXCL); + fs.chmodSync(staged, 0o755); + stagedPaths.set(name, staged); + stagedDigests.set(name, fileSha256(staged)); + } + + for (const name of [WINDOWS_LAUNCHER_NAME, WINDOWS_PAYLOAD_NAME]) { + const target = path.join(destDir, name); + const status = fs.lstatSync(target, { throwIfNoEntry: false }); + if (!status) continue; + if (!status.isFile() || status.isSymbolicLink()) { + throw new Error(`refusing unsafe Windows package-cache target: ${target}`); + } + const backup = path.join(destDir, `.cbm-pair-backup-${transaction}-${name}`); + fs.renameSync(target, backup); + backups.set(name, backup); + } + + // Payload is the readiness signal and is deliberately published last. + for (const name of [WINDOWS_LAUNCHER_NAME, WINDOWS_PAYLOAD_NAME]) { + const target = path.join(destDir, name); + fs.renameSync(stagedPaths.get(name), target); + stagedPaths.delete(name); + publishedDigests.set(name, stagedDigests.get(name)); + } + if (!windowsPairReady(destDir, verifier)) { + throw new Error('published Windows package-cache pair failed verification'); + } + for (const backup of backups.values()) { + try { fs.unlinkSync(backup); } catch (_) { /* valid pair is already committed */ } + } + return; + } catch (err) { + // If a non-cooperating contender nevertheless published a valid pair, + // preserve that winner. Otherwise remove only our exact staged bytes and + // restore the prior files when their target names are still absent. + if (windowsPairReady(destDir, verifier)) { + for (const backup of backups.values()) { + try { fs.unlinkSync(backup); } catch (_) { /* winner remains authoritative */ } + } + return; + } + removeOwnedPublishedPair(destDir, publishedDigests); + restorePairBackups(destDir, backups); + throw err; + } finally { + for (const staged of stagedPaths.values()) { + try { fs.unlinkSync(staged); } catch (_) { /* renamed or already absent */ } + } + } + } catch (err) { + operationError = err; + throw err; + } finally { + try { + releaseWindowsPairLock(lock); + } catch (releaseError) { + if (!operationError) throw releaseError; + } + } +} + +function extractZipOnWindows(archivePath, destPath, requiredNames, extractNames) { // -EncodedCommand is a constant program. Paths travel only through the child // environment and are consumed with -LiteralPath, so PowerShell never parses // user/TEMP path bytes as source code or wildcard syntax. const script = [ "$ErrorActionPreference = 'Stop'", - 'Expand-Archive -LiteralPath $env:CBM_NPM_ARCHIVE_PATH ' + - '-DestinationPath $env:CBM_NPM_DEST_PATH -Force', + 'Add-Type -AssemblyName System.IO.Compression.FileSystem', + '$zip = [System.IO.Compression.ZipFile]::OpenRead($env:CBM_NPM_ARCHIVE_PATH)', + "$seen = New-Object 'System.Collections.Generic.HashSet[string]' ([System.StringComparer]::OrdinalIgnoreCase)", + "$requiredNames = @($env:CBM_NPM_REQUIRED_NAMES.Split('|'))", + '$targetCounts = @{}', + 'foreach ($requiredName in $requiredNames) { $targetCounts[$requiredName] = 0 }', + 'try { foreach ($entry in $zip.Entries) { ' + + "$name = $entry.FullName.Replace('\\', '/'); " + + "$directory = $name.EndsWith('/'); " + + "$segmentsPath = if ($directory) { $name.TrimEnd('/') } else { $name }; " + + "$segments = @($segmentsPath.Split('/')); " + + "if ([string]::IsNullOrEmpty($segmentsPath) -or $name.StartsWith('/') -or " + + "$name.Contains(':') -or $segments -contains '' -or " + + "$segments -contains '.' -or $segments -contains '..' -or " + + "@($segments | Where-Object { $_.EndsWith('.') -or $_.EndsWith(' ') }).Count -gt 0) { " + + "throw \"unsafe zip entry path: $($entry.FullName)\" }; " + + 'if (-not $seen.Add($segmentsPath)) { ' + + "throw \"duplicate or case-conflicting zip entry: $($entry.FullName)\" }; " + + 'foreach ($requiredName in $requiredNames) { ' + + 'if ($name -ceq $requiredName) { ' + + '$targetCounts[$requiredName] = $targetCounts[$requiredName] + 1 } } ' + + '} } finally { $zip.Dispose() }', + 'foreach ($requiredName in $requiredNames) { ' + + 'if ($targetCounts[$requiredName] -ne 1) { ' + + 'throw "archive must contain exactly one $requiredName" } }', + 'if ($seen.Count -ne $requiredNames.Count) { ' + + 'throw "archive does not match the exact release root-file allowlist" }', + "$extractNames = @($env:CBM_NPM_EXTRACT_NAMES.Split('|'))", + '$extractZip = [System.IO.Compression.ZipFile]::OpenRead($env:CBM_NPM_ARCHIVE_PATH)', + 'try { foreach ($extractName in $extractNames) { ' + + '$entry = @($extractZip.Entries | Where-Object { $_.FullName -ceq $extractName })[0]; ' + + '[System.IO.Compression.ZipFileExtensions]::ExtractToFile(' + + '$entry, (Join-Path $env:CBM_NPM_DEST_PATH $extractName), $false) ' + + '} } finally { $extractZip.Dispose() }', ].join('; '); const encoded = Buffer.from(script, 'utf16le').toString('base64'); execFileSync('powershell', [ @@ -172,6 +512,8 @@ function extractZipOnWindows(archivePath, destPath) { ...process.env, CBM_NPM_ARCHIVE_PATH: archivePath, CBM_NPM_DEST_PATH: destPath, + CBM_NPM_REQUIRED_NAMES: requiredNames.join('|'), + CBM_NPM_EXTRACT_NAMES: extractNames.join('|'), }, stdio: 'inherit', windowsHide: true, @@ -210,15 +552,32 @@ async function main() { const platform = getPlatform(); const arch = getArch(); const ext = platform === 'windows' ? 'zip' : 'tar.gz'; - const binName = platform === 'windows' ? 'codebase-memory-mcp.exe' : 'codebase-memory-mcp'; + // Package-manager shims are portable one-shot instances on Windows. They + // enter through the cached launcher and never create managed launcher state. + // The payload path remains the immutable pair's readiness signal. + const binName = platform === 'windows' + ? WINDOWS_PAYLOAD_NAME + : 'codebase-memory-mcp'; const binPath = path.join(BIN_DIR, binName); + const cacheNames = platform === 'windows' + ? [WINDOWS_LAUNCHER_NAME, WINDOWS_PAYLOAD_NAME] + : [binName]; + const extractedNames = platform === 'windows' + ? [WINDOWS_LAUNCHER_NAME, WINDOWS_PAYLOAD_NAME] + : [binName]; + const archiveNames = platform === 'windows' + ? WINDOWS_ARCHIVE_NAMES + : UNIX_ARCHIVE_NAMES; + const cachePaths = cacheNames.map((name) => path.join(BIN_DIR, name)); - if (fs.existsSync(binPath)) { + if (platform === 'windows' && windowsPairReady(BIN_DIR)) return; + if (platform !== 'windows' && + cachePaths.every((candidate) => fs.existsSync(candidate))) { try { verifyCandidate(binPath); return; // already installed and runnable, nothing to do } catch (_) { - fs.rmSync(binPath, { force: true }); + // Fall through and atomically replace the invalid binary. } } @@ -244,50 +603,90 @@ async function main() { await download(url, tmpArchive); await verifyChecksum(tmpArchive, archive); - // Extract using execFileSync (array args — no shell injection). + // Validate the complete archive namespace, then extract only executables. if (ext === 'tar.gz') { - execFileSync('tar', ['-xzf', tmpArchive, '-C', tmpDir, '--no-same-owner']); + extractExactTarArchive( + tmpArchive, tmpDir, archiveNames, binName, + ); } else { - extractZipOnWindows(tmpArchive, tmpDir); + extractZipOnWindows(tmpArchive, tmpDir, archiveNames, extractedNames); } - // Validate extracted path doesn't escape tmpDir (tar-slip defense). - const extracted = path.join(tmpDir, binName); - const resolvedExtracted = path.resolve(extracted); + // Validate extracted paths don't escape tmpDir (tar-slip defense). const resolvedTmpDir = path.resolve(tmpDir); - if (!resolvedExtracted.startsWith(resolvedTmpDir + path.sep)) { - throw new Error(`Path traversal detected in archive: ${binName}`); - } - if (!fs.existsSync(extracted)) { - throw new Error(`Binary not found after extraction at ${extracted}`); + const extractedPaths = new Map(); + for (const name of extractedNames) { + const extracted = path.join(tmpDir, name); + const resolvedExtracted = path.resolve(extracted); + if (!resolvedExtracted.startsWith(resolvedTmpDir + path.sep)) { + throw new Error(`Path traversal detected in archive: ${name}`); + } + const status = fs.lstatSync(extracted, { throwIfNoEntry: false }); + if (!status || !status.isFile() || status.isSymbolicLink()) { + throw new Error(`Required binary not found as a regular file: ${extracted}`); + } + fs.chmodSync(extracted, 0o755); + extractedPaths.set(name, extracted); } - fs.chmodSync(extracted, 0o755); - verifyCandidate(extracted); + if (platform === 'windows') { + // The launcher resolves the adjacent portable payload in this directory. + verifyCandidate(extractedPaths.get(WINDOWS_LAUNCHER_NAME)); + } else { + verifyCandidate(extractedPaths.get(binName)); + } - const stagedSuffix = platform === 'windows' ? '.tmp.exe' : '.tmp'; - const staged = path.join( - BIN_DIR, - `.${binName}.${process.pid}.${crypto.randomBytes(8).toString('hex')}${stagedSuffix}`, - ); - try { - fs.copyFileSync(extracted, staged, fs.constants.COPYFILE_EXCL); - fs.chmodSync(staged, 0o755); - verifyCandidate(staged); - fs.renameSync(staged, binPath); - } finally { - try { fs.unlinkSync(staged); } catch (_) { /* renamed or never created */ } + if (platform === 'windows') { + installWindowsPairAtomically(tmpDir, BIN_DIR); + } else { + const staged = path.join( + BIN_DIR, + `.${binName}.${process.pid}.${crypto.randomBytes(8).toString('hex')}.tmp`, + ); + try { + fs.copyFileSync(extractedPaths.get(binName), staged, fs.constants.COPYFILE_EXCL); + fs.chmodSync(staged, 0o755); + verifyCandidate(staged); + try { + fs.renameSync(staged, binPath); + } catch (publishError) { + if (!filesEqualSha256(staged, binPath)) throw publishError; + } + verifyCandidate(binPath); + } finally { + try { fs.unlinkSync(staged); } catch (_) { /* renamed or already absent */ } + } } process.stdout.write('codebase-memory-mcp: ready.\n'); + if (platform === 'windows') { + process.stdout.write( + 'Windows package cache is portable. Run "codebase-memory-mcp install --yes" ' + + 'to create the managed launcher (use "npx codebase-memory-mcp install --yes" ' + + 'for a local npm install). Package updates/removal remain ' + + '"npm install codebase-memory-mcp@latest" and ' + + '"npm uninstall codebase-memory-mcp" (add -g for a global install).\n', + ); + } } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } } -main().catch((err) => { - process.stderr.write(`\ncodebase-memory-mcp: install failed — ${err.message}\n`); - process.stderr.write(`You can install manually: https://github.com/${REPO}#installation\n`); - // Non-fatal: don't block the rest of npm install - process.exit(0); -}); +if (require.main === module || module.parent == null) { + main().catch((err) => { + process.stderr.write(`\ncodebase-memory-mcp: install failed — ${err.message}\n`); + process.stderr.write(`You can install manually: https://github.com/${REPO}#installation\n`); + process.exit(1); + }); +} + +module.exports = { + UNIX_ARCHIVE_NAMES, + WINDOWS_LAUNCHER_NAME, + WINDOWS_PAYLOAD_NAME, + extractExactTarArchive, + installWindowsPairAtomically, + validateExactTarMemberListing, + windowsPairReady, +}; diff --git a/pkg/npm/package.json b/pkg/npm/package.json index 1be28a069..0b076b92f 100644 --- a/pkg/npm/package.json +++ b/pkg/npm/package.json @@ -26,6 +26,7 @@ "codebase-memory-mcp": "./bin.js" }, "scripts": { + "test": "node --test test/*.test.js", "postinstall": "node install.js" }, "engines": { diff --git a/pkg/npm/test/install-failure.test.js b/pkg/npm/test/install-failure.test.js new file mode 100644 index 000000000..3d539ec0f --- /dev/null +++ b/pkg/npm/test/install-failure.test.js @@ -0,0 +1,53 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +test('postinstall exits nonzero when installation fails', async () => { + const installPath = path.join(__dirname, '..', 'install.js'); + const source = fs.readFileSync(installPath, 'utf8'); + const output = { stdout: '', stderr: '' }; + const observed = { exitCode: undefined }; + const fakeProcess = { + // Force a deterministic failure before any filesystem or network access. + platform: 'unsupported-test-platform', + arch: 'x64', + env: {}, + pid: 12345, + stdout: { write: (text) => { output.stdout += String(text); } }, + stderr: { write: (text) => { output.stderr += String(text); } }, + exit: (code) => { observed.exitCode = code; }, + exitCode: undefined, + }; + + const sandbox = { + Buffer, + URL, + __dirname: path.dirname(installPath), + __filename: installPath, + clearTimeout, + console, + exports: {}, + module: { exports: {} }, + process: fakeProcess, + require: (specifier) => { + if (specifier === './package.json') return { version: '0.0.0-test' }; + return require(specifier); + }, + setTimeout, + }; + + vm.runInNewContext(source, sandbox, { filename: installPath }); + await new Promise((resolve) => setImmediate(resolve)); + + const exitCode = observed.exitCode ?? fakeProcess.exitCode ?? 0; + assert.match(output.stderr, /install failed/); + assert.notEqual( + exitCode, + 0, + 'npm postinstall reported success after a deterministic installation failure', + ); +}); diff --git a/pkg/npm/test/package-publication.test.js b/pkg/npm/test/package-publication.test.js new file mode 100644 index 000000000..2b7190ce5 --- /dev/null +++ b/pkg/npm/test/package-publication.test.js @@ -0,0 +1,137 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const test = require('node:test'); + +const { + UNIX_ARCHIVE_NAMES, + WINDOWS_LAUNCHER_NAME, + WINDOWS_PAYLOAD_NAME, + extractExactTarArchive, + installWindowsPairAtomically, + validateExactTarMemberListing, +} = require('../install.js'); + +function exactUnixListing(extra = []) { + return [...UNIX_ARCHIVE_NAMES, ...extra].join('\n') + '\n'; +} + +test('Unix archive validation rejects traversal and unexpected members', () => { + assert.throws( + () => validateExactTarMemberListing( + exactUnixListing(['../../.ssh/authorized_keys']), UNIX_ARCHIVE_NAMES, + ), + /unexpected or duplicate/, + ); + assert.throws( + () => validateExactTarMemberListing( + exactUnixListing(['unexpected-root-file']), UNIX_ARCHIVE_NAMES, + ), + /unexpected or duplicate/, + ); +}); + +test('Unix extraction requests only the validated root executable', () => { + const calls = []; + const runner = (command, args) => { + calls.push({ command, args: [...args] }); + return calls.length === 1 ? exactUnixListing() : Buffer.alloc(0); + }; + + extractExactTarArchive( + '/tmp/release.tar.gz', '/tmp/extract', UNIX_ARCHIVE_NAMES, + 'codebase-memory-mcp', runner, + ); + + assert.deepEqual(calls[0].args, ['-tzf', '/tmp/release.tar.gz']); + assert.deepEqual( + calls[1].args, + ['-xzf', '/tmp/release.tar.gz', '-C', '/tmp/extract', 'codebase-memory-mcp'], + ); +}); + +function writePair(directory, tag) { + fs.mkdirSync(directory, { recursive: true }); + fs.writeFileSync(path.join(directory, WINDOWS_LAUNCHER_NAME), `launcher:${tag}`); + fs.writeFileSync(path.join(directory, WINDOWS_PAYLOAD_NAME), `payload:${tag}`); +} + +function fakePairVerifier(launcherPath) { + const launcher = fs.readFileSync(launcherPath, 'utf8'); + const payload = fs.readFileSync( + path.join(path.dirname(launcherPath), WINDOWS_PAYLOAD_NAME), 'utf8', + ); + const match = /^launcher:(.+)$/.exec(launcher); + if (!match || payload !== `payload:${match[1]}`) { + throw new Error('launcher/payload test pair mismatch'); + } +} + +function withPairDirectories(callback) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cbm-npm-pair-test-')); + const source = path.join(root, 'source'); + const destination = path.join(root, 'destination'); + fs.mkdirSync(destination); + try { + callback({ source, destination }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +function assertPair(directory, tag) { + assert.equal( + fs.readFileSync(path.join(directory, WINDOWS_LAUNCHER_NAME), 'utf8'), + `launcher:${tag}`, + ); + assert.equal( + fs.readFileSync(path.join(directory, WINDOWS_PAYLOAD_NAME), 'utf8'), + `payload:${tag}`, + ); +} + +test('Windows publication repairs a corrupt launcher', () => { + withPairDirectories(({ source, destination }) => { + writePair(source, 'candidate'); + writePair(destination, 'old'); + fs.writeFileSync(path.join(destination, WINDOWS_LAUNCHER_NAME), 'corrupt'); + + installWindowsPairAtomically(source, destination, fakePairVerifier); + + assertPair(destination, 'candidate'); + }); +}); + +test('Windows publication repairs corrupt or missing payload state', () => { + for (const payload of ['corrupt', null]) { + withPairDirectories(({ source, destination }) => { + writePair(source, 'candidate'); + fs.writeFileSync( + path.join(destination, WINDOWS_LAUNCHER_NAME), 'launcher:partial', + ); + if (payload !== null) { + fs.writeFileSync( + path.join(destination, WINDOWS_PAYLOAD_NAME), payload, + ); + } + + installWindowsPairAtomically(source, destination, fakePairVerifier); + + assertPair(destination, 'candidate'); + }); + } +}); + +test('Windows publication preserves a valid concurrent winner', () => { + withPairDirectories(({ source, destination }) => { + writePair(source, 'loser'); + writePair(destination, 'winner'); + + installWindowsPairAtomically(source, destination, fakePairVerifier); + + assertPair(destination, 'winner'); + }); +}); diff --git a/pkg/npm/test/windows-launcher.test.js b/pkg/npm/test/windows-launcher.test.js new file mode 100644 index 000000000..b67aa9adf --- /dev/null +++ b/pkg/npm/test/windows-launcher.test.js @@ -0,0 +1,116 @@ +'use strict'; + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const vm = require('node:vm'); + +class ExitSignal extends Error { + constructor(code) { + super(`process.exit(${code})`); + this.code = code; + } +} + +function runShim(targetPlatform, arguments_, childStatus) { + const shimPath = path.join(__dirname, '..', 'bin.js'); + const source = fs.readFileSync(shimPath, 'utf8').replace(/^#![^\n]*\n/, ''); + const calls = []; + let stderr = ''; + + const fakeProcess = { + platform: targetPlatform, + argv: ['node.exe', shimPath, ...arguments_], + execPath: 'node.exe', + stderr: { write: (text) => { stderr += String(text); } }, + exit: (code) => { throw new ExitSignal(code); }, + }; + const fakeFs = { + existsSync: () => true, + }; + const fakeChildProcess = { + spawnSync: (executable, args, options) => { + calls.push({ executable, args, options }); + return { status: calls.length === 1 ? 0 : childStatus }; + }, + }; + const sandbox = { + __dirname: path.dirname(shimPath), + __filename: shimPath, + Buffer, + clearTimeout, + console, + exports: {}, + module: { exports: {} }, + process: fakeProcess, + require: (specifier) => { + if (specifier === 'fs') return fakeFs; + if (specifier === 'child_process') return fakeChildProcess; + return require(specifier); + }, + setTimeout, + }; + + let exitCode = null; + try { + vm.runInNewContext(source, sandbox, { filename: shimPath }); + } catch (error) { + if (!(error instanceof ExitSignal)) throw error; + exitCode = error.code; + } + return { calls, exitCode, stderr }; +} + +test('Windows npm shim probes and executes the cached launcher', () => { + const observed = runShim('win32', ['--version'], 0); + + assert.equal(observed.exitCode, 0); + assert.equal(observed.calls.length, 2); + for (const call of observed.calls) { + assert.equal(path.basename(call.executable), 'codebase-memory-mcp.exe'); + assert.notEqual( + path.basename(call.executable), + 'codebase-memory-mcp.payload.exe', + ); + } + assert.deepEqual(Array.from(observed.calls[1].args), ['--version']); + assert.equal(observed.calls[1].options.stdio, 'inherit'); +}); + +test('Windows npm shim keeps package-manager guidance after launcher refusal', () => { + const observed = runShim('win32', ['update'], 1); + + assert.equal(observed.exitCode, 1); + assert.equal(path.basename(observed.calls[1].executable), 'codebase-memory-mcp.exe'); + assert.match(observed.stderr, /npm install codebase-memory-mcp@latest/); + assert.match(observed.stderr, /install --yes/); +}); + +test('non-Windows npm shim keeps its native payload execution path', () => { + const observed = runShim('darwin', ['--version'], 0); + + assert.equal(observed.exitCode, 0); + assert.equal(observed.calls.length, 1); + assert.equal(path.basename(observed.calls[0].executable), 'codebase-memory-mcp'); +}); + +test('PowerShell install mutation runs through the downloaded launcher', () => { + const installer = fs.readFileSync( + path.join(__dirname, '..', '..', '..', 'install.ps1'), + 'utf8', + ); + + assert.match( + installer, + /^\s*\$candidateVersion\s*=\s*&\s*\$DownloadedLauncher\s+--version\b/m, + ); + assert.match( + installer, + /^\s*&\s*\$DownloadedLauncher\s+@InstallArgs\b/m, + ); + assert.doesNotMatch( + installer, + /^\s*&\s*\$DownloadedPayload\s+@InstallArgs\b/m, + ); +}); diff --git a/pkg/pypi/src/codebase_memory_mcp/_cli.py b/pkg/pypi/src/codebase_memory_mcp/_cli.py index 49ae3fc9c..6495a0829 100644 --- a/pkg/pypi/src/codebase_memory_mcp/_cli.py +++ b/pkg/pypi/src/codebase_memory_mcp/_cli.py @@ -1,4 +1,4 @@ -"""Downloads the codebase-memory-mcp binary on first run, then exec's it.""" +"""Downloads codebase-memory-mcp on first run, then runs its native entry point.""" import hashlib import os @@ -15,6 +15,15 @@ from pathlib import Path REPO = "DeusData/codebase-memory-mcp" +_WINDOWS_LAUNCHER_NAME = "codebase-memory-mcp.exe" +_WINDOWS_PAYLOAD_NAME = "codebase-memory-mcp.payload.exe" +_WINDOWS_ARCHIVE_NAMES = ( + _WINDOWS_LAUNCHER_NAME, + _WINDOWS_PAYLOAD_NAME, + "LICENSE", + "install.ps1", + "THIRD_PARTY_NOTICES.md", +) # Security: only permit https fetches. urllib's default handlers accept # file://, ftp://, and custom schemes — a redirect or tainted URL source @@ -115,6 +124,26 @@ def _verify_candidate(path: Path) -> None: raise RuntimeError(f"downloaded binary failed to run: {exc}") from exc +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(65536), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _files_equal_sha256(left: Path, right: Path) -> bool: + try: + return ( + left.is_file() + and right.is_file() + and left.stat().st_size == right.stat().st_size + and _file_sha256(left) == _file_sha256(right) + ) + except OSError: + return False + + def _safe_extract_tar(tf, dest: str) -> None: """Extract a tarfile to dest, rejecting path-traversal entries. @@ -142,17 +171,66 @@ def _safe_extract_tar(tf, dest: str) -> None: tf.extractall(dest) -def _safe_extract_zip(zf, dest: str) -> None: - """Extract a zipfile to dest, rejecting path-traversal entries.""" +def _safe_extract_zip(zf, dest: str, archive_names=(), extract_names=()) -> None: + """Extract a zipfile after validating its Windows-style namespace. + + Windows treats paths case-insensitively. Rejecting duplicate/case-conflicting + members before extraction prevents archive order from selecting the binary + that a portable package-manager shim eventually executes. + """ dest_abs = os.path.abspath(dest) - for name in zf.namelist(): - member_abs = os.path.abspath(os.path.join(dest_abs, name)) + seen = set() + required = tuple(archive_names) + required_set = set(required) + found = set() + for info in zf.infolist(): + raw_name = info.filename + name = raw_name.replace("\\", "/") + segments_name = name[:-1] if name.endswith("/") else name + segments = segments_name.split("/") + if ( + not segments_name + or name.startswith("/") + or ":" in name + or any(segment in ("", ".", "..") for segment in segments) + or any(segment.endswith((".", " ")) for segment in segments) + ): + sys.exit( + f"codebase-memory-mcp: refusing unsafe zip entry " + f"(invalid path: {raw_name!r})" + ) + folded = segments_name.casefold() + if folded in seen: + sys.exit( + f"codebase-memory-mcp: refusing unsafe zip entry " + f"(duplicate or case conflict: {raw_name!r})" + ) + seen.add(folded) + if name not in required_set or info.is_dir(): + sys.exit( + f"codebase-memory-mcp: archive must contain only the exact " + f"root files: {', '.join(required)}" + ) + found.add(name) + member_abs = os.path.abspath(os.path.join(dest_abs, *segments)) if not (member_abs == dest_abs or member_abs.startswith(dest_abs + os.sep)): sys.exit( f"codebase-memory-mcp: refusing unsafe zip entry " - f"(escapes dest: {name!r})" + f"(escapes dest: {raw_name!r})" ) - zf.extractall(dest) + missing = required_set - found + if missing or len(seen) != len(required): + sys.exit( + f"codebase-memory-mcp: archive must contain exactly one of each " + f"required root file: {', '.join(required)}" + ) + + # Extract only the two validated executable files. This avoids relying on + # platform-specific zip path rewriting and always creates regular files. + for name in extract_names: + target = os.path.join(dest_abs, name) + with zf.open(name) as source, open(target, "xb") as output: + shutil.copyfileobj(source, output) def _verify_checksum(archive_path: str, archive_name: str, version: str) -> None: @@ -254,10 +332,42 @@ def _cache_dir() -> Path: def _bin_path(version: str) -> Path: - name = "codebase-memory-mcp.exe" if sys.platform == "win32" else "codebase-memory-mcp" + # The payload remains the immutable Windows cache pair's readiness signal. + # _execution_path selects the adjacent launcher for process execution. + name = ( + _WINDOWS_PAYLOAD_NAME + if sys.platform == "win32" + else "codebase-memory-mcp" + ) return _cache_dir() / version / name +def _windows_pair_paths(version: str): + version_dir = _cache_dir() / version + return ( + version_dir / _WINDOWS_LAUNCHER_NAME, + version_dir / _WINDOWS_PAYLOAD_NAME, + ) + + +def _execution_path(payload: Path, target_platform: str) -> Path: + """Select the permanent Windows launcher without changing other platforms.""" + if target_platform == "win32": + return payload.with_name(_WINDOWS_LAUNCHER_NAME) + return payload + + +def _windows_pair_ready(version: str) -> bool: + launcher, payload = _windows_pair_paths(version) + if not launcher.is_file() or not payload.is_file(): + return False + try: + _verify_candidate(launcher) + except RuntimeError: + return False + return True + + def _download(version: str) -> Path: os_name = _os_name() arch = _arch() @@ -294,6 +404,21 @@ def _download(version: str) -> Path: _verify_checksum(tmp_archive, archive, version) + if os_name == "windows": + bin_name = _WINDOWS_PAYLOAD_NAME + extraction_names = ( + _WINDOWS_LAUNCHER_NAME, + _WINDOWS_PAYLOAD_NAME, + ) + else: + bin_name = "codebase-memory-mcp" + extraction_names = (bin_name,) + cache_names = extraction_names + publish_names = ( + (_WINDOWS_LAUNCHER_NAME, _WINDOWS_PAYLOAD_NAME) + if os_name == "windows" + else cache_names + ) if ext == "tar.gz": import tarfile with tarfile.open(tmp_archive) as tf: @@ -301,43 +426,75 @@ def _download(version: str) -> Path: else: import zipfile with zipfile.ZipFile(tmp_archive) as zf: - _safe_extract_zip(zf, tmp) - - bin_name = "codebase-memory-mcp.exe" if os_name == "windows" else "codebase-memory-mcp" - extracted = os.path.join(tmp, bin_name) - if not os.path.exists(extracted): - sys.exit("codebase-memory-mcp: binary not found after extraction") - - extracted_path = Path(extracted) - current = extracted_path.stat().st_mode - extracted_path.chmod(current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + _safe_extract_zip( + zf, + tmp, + _WINDOWS_ARCHIVE_NAMES, + extraction_names, + ) + + extracted_paths = {} + for name in extraction_names: + extracted_path = Path(tmp) / name + if not extracted_path.is_file(): + sys.exit( + f"codebase-memory-mcp: required binary not found after " + f"extraction: {name}" + ) + current = extracted_path.stat().st_mode + extracted_path.chmod( + current | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) + extracted_paths[name] = extracted_path try: - _verify_candidate(extracted_path) + if os_name == "windows": + # The portable launcher must resolve this adjacent payload. + _verify_candidate(extracted_paths[_WINDOWS_LAUNCHER_NAME]) + else: + _verify_candidate(extracted_paths[bin_name]) except RuntimeError as exc: sys.exit(f"codebase-memory-mcp: {exc}") - staged_path = None + staged_paths = {} try: - staged_suffix = ".tmp.exe" if sys.platform == "win32" else ".tmp" - with tempfile.NamedTemporaryFile( - dir=str(dest.parent), - prefix=f".{dest.name}.", - suffix=staged_suffix, - delete=False, - ) as staged: - staged_path = Path(staged.name) - shutil.copy2(extracted_path, staged_path) - staged_mode = staged_path.stat().st_mode - staged_path.chmod( - staged_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH - ) - _verify_candidate(staged_path) - os.replace(staged_path, dest) - staged_path = None + for name in cache_names: + staged_suffix = ".tmp.exe" if sys.platform == "win32" else ".tmp" + with tempfile.NamedTemporaryFile( + dir=str(dest.parent), + prefix=f".{name}.", + suffix=staged_suffix, + delete=False, + ) as staged: + staged_path = Path(staged.name) + shutil.copy2(extracted_paths[name], staged_path) + staged_mode = staged_path.stat().st_mode + staged_path.chmod( + staged_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + ) + staged_paths[name] = staged_path + + if os_name != "windows": + _verify_candidate(staged_paths[bin_name]) + for name in publish_names: + target = dest.parent / name + try: + os.replace(staged_paths[name], target) + staged_paths.pop(name) + except OSError as publish_error: + # Never unlink a destination here: it may belong to a + # concurrent contender. A collision remains eligible only + # when its bytes match this authenticated stage; pair + # execution is checked after payload is published last. + if not _files_equal_sha256(staged_paths[name], target): + raise publish_error + if os_name == "windows": + _verify_candidate(dest.parent / _WINDOWS_LAUNCHER_NAME) + else: + _verify_candidate(dest) except RuntimeError as exc: sys.exit(f"codebase-memory-mcp: {exc}") finally: - if staged_path is not None: + for staged_path in staged_paths.values(): try: staged_path.unlink() except OSError: @@ -350,17 +507,55 @@ def main() -> None: version = _version() bin_path = _bin_path(version) - if not bin_path.exists(): + cache_ready = ( + _windows_pair_ready(version) + if sys.platform == "win32" + else bin_path.is_file() + ) + if not cache_ready: bin_path = _download(version) + execution_path = _execution_path(bin_path, sys.platform) + # args is a list (not a shell string), so exec/subprocess treat each # element as a discrete argv entry — no shell interpretation, no # injection vector. sys.argv forwarding is the whole point of this # shim, so tainted-input suppression is intentional. - args = [str(bin_path)] + sys.argv[1:] + args = [str(execution_path)] + sys.argv[1:] if sys.platform != "win32": - os.execv(str(bin_path), args) # noqa: S606 — list form, no shell + os.execv(str(execution_path), args) # noqa: S606 — list form, no shell else: result = subprocess.run(args) # noqa: S603 — list form, no shell=True + mutation = _portable_mutation_action(sys.argv[1:]) + if result.returncode != 0 and mutation is not None: + package_command = ( + "python -m pip install --upgrade codebase-memory-mcp" + if mutation == "update" + else "python -m pip uninstall codebase-memory-mcp" + ) + print( + f'This PyPI Windows copy is portable. Use "{package_command}" ' + f'for package maintenance, or run "codebase-memory-mcp ' + f'install --yes" once to create a managed launcher with ' + f'coordinated self-update/uninstall.', + file=sys.stderr, + ) sys.exit(result.returncode) + + +def _portable_mutation_action(args): + for argument in args: + if argument in ( + "cli", + "hook-augment", + "config", + "install", + "--help", + "-h", + "--version", + ): + return None + if argument in ("update", "uninstall"): + return argument + return None diff --git a/pkg/pypi/tests/test_cli.py b/pkg/pypi/tests/test_cli.py new file mode 100644 index 000000000..a5d8eaf03 --- /dev/null +++ b/pkg/pypi/tests/test_cli.py @@ -0,0 +1,37 @@ +import sys +import unittest +from pathlib import Path + + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PACKAGE_ROOT / "src")) + +from codebase_memory_mcp import _cli # noqa: E402 + + +class WindowsLauncherSelectionTests(unittest.TestCase): + def test_windows_uses_adjacent_launcher(self): + payload = Path("cache") / "0.8.1" / _cli._WINDOWS_PAYLOAD_NAME + + self.assertEqual( + _cli._execution_path(payload, "win32"), + payload.with_name(_cli._WINDOWS_LAUNCHER_NAME), + ) + + def test_non_windows_keeps_payload(self): + payload = Path("cache") / "0.8.1" / "codebase-memory-mcp" + + self.assertEqual(_cli._execution_path(payload, "linux"), payload) + + def test_portable_mutation_guidance_classification_is_preserved(self): + self.assertEqual(_cli._portable_mutation_action(["update"]), "update") + self.assertEqual( + _cli._portable_mutation_action(["uninstall", "--yes"]), + "uninstall", + ) + self.assertIsNone(_cli._portable_mutation_action(["install", "--yes"])) + self.assertIsNone(_cli._portable_mutation_action(["cli", "update"])) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/security-fuzz.sh b/scripts/security-fuzz.sh index fffd1aefe..9d74a175e 100755 --- a/scripts/security-fuzz.sh +++ b/scripts/security-fuzz.sh @@ -24,6 +24,9 @@ TOTAL=0 # Temp directory for input files (avoids pipe/stdin issues with timeout) FUZZ_TMPDIR=$(mktemp -d) trap 'rm -rf "$FUZZ_TMPDIR"' EXIT +FUZZ_HOME="$FUZZ_TMPDIR/home" +FUZZ_CACHE="$FUZZ_TMPDIR/cache" +mkdir -p "$FUZZ_HOME" "$FUZZ_CACHE" # Helper: send a payload to the MCP server and check it doesn't crash. # Uses temp file + perl alarm for portable timeout (works on macOS + Linux). @@ -34,17 +37,24 @@ test_payload() { # Write session input to a temp file (avoids pipe/stdin issues) local tmpinput="$FUZZ_TMPDIR/input_${TOTAL}.jsonl" - printf '%s\n%s\n%s\n' \ + local tmpoutput="$FUZZ_TMPDIR/output_${TOTAL}.jsonl" + local acknowledgement_id=$((900000 + TOTAL)) + printf '%s\n%s\n%s\n%s\n' \ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"fuzz","version":"1.0"}}}' \ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \ - "$payload" > "$tmpinput" + "$payload" \ + "{\"jsonrpc\":\"2.0\",\"id\":$acknowledgement_id,\"method\":\"ping\",\"params\":{}}" \ + > "$tmpinput" # Run with 10s timeout: GNU timeout → perl alarm fallback local ec=0 if command -v timeout &>/dev/null; then - timeout 10 "$BINARY" < "$tmpinput" > /dev/null 2>&1 || ec=$? + HOME="$FUZZ_HOME" CBM_CACHE_DIR="$FUZZ_CACHE" \ + timeout 10 "$BINARY" < "$tmpinput" > "$tmpoutput" 2>&1 || ec=$? else - perl -e 'alarm(10); exec @ARGV' -- "$BINARY" < "$tmpinput" > /dev/null 2>&1 || ec=$? + HOME="$FUZZ_HOME" CBM_CACHE_DIR="$FUZZ_CACHE" \ + perl -e 'alarm(10); exec @ARGV' -- "$BINARY" \ + < "$tmpinput" > "$tmpoutput" 2>&1 || ec=$? fi # Acceptable exits: @@ -53,7 +63,13 @@ test_payload() { # 142 = SIGALRM (perl timeout — hung process, same as GNU timeout 124) # 124 = GNU timeout if [[ $ec -eq 0 || $ec -eq 141 ]]; then - PASS=$((PASS + 1)) + if grep -Eq "\"id\"[[:space:]]*:[[:space:]]*$acknowledgement_id([^0-9]|$).*\"result\"[[:space:]]*:" \ + "$tmpoutput"; then + PASS=$((PASS + 1)) + else + echo "FAIL: $name — target exited before acknowledging payload processing" + FAIL=$((FAIL + 1)) + fi elif [[ $ec -eq 124 || $ec -eq 142 ]]; then echo "FAIL: $name — timed out (hung for 10s)" FAIL=$((FAIL + 1)) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index ec4e4d581..9aa15e814 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -18,6 +18,27 @@ if [ -n "$SMOKE_MODE" ] && [ "$SMOKE_MODE" != "--agent-config-only" ]; then exit 2 fi REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)" + +# Windows release archives contain a small permanent launcher plus a portable +# payload. Whenever a smoke fixture copies the launcher, keep the payload next +# to it so the copied fixture remains a complete portable bundle. +WINDOWS_PAYLOAD="" +if [[ "$BINARY" == *.exe ]]; then + WINDOWS_PAYLOAD="$(cd "$(dirname "$BINARY")" && pwd)/codebase-memory-mcp.payload.exe" + if [ ! -f "$WINDOWS_PAYLOAD" ]; then + echo "FAIL: Windows launcher has no adjacent codebase-memory-mcp.payload.exe" + exit 1 + fi +fi + +copy_smoke_binary() { + local destination="$1" + cp "$BINARY" "$destination" + if [ -n "$WINDOWS_PAYLOAD" ]; then + cp "$WINDOWS_PAYLOAD" "$(dirname "$destination")/codebase-memory-mcp.payload.exe" + fi +} + TMPDIR=$(mktemp -d) DRYRUN_HOME="" # On MSYS2/Windows, convert POSIX path to native Windows path for the binary @@ -660,29 +681,28 @@ rm -f "$MCP_INPUT" "$MCP_OUTPUT" # 5e: MCP tool call via JSON-RPC (index + search round-trip) echo "" echo "--- Phase 5e: MCP tool call round-trip ---" -MCP_TOOL_INPUT=$(mktemp) MCP_TOOL_OUTPUT=$(mktemp) -cat > "$MCP_TOOL_INPUT" << TOOLEOF -{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke-test","version":"1.0"}}} -{"jsonrpc":"2.0","method":"notifications/initialized"} -{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"index_repository","arguments":{"repo_path":"$TMPDIR","mode":"fast"}}} -{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_graph","arguments":{"name_pattern":"compute"}}} -TOOLEOF - -mcp_run "$MCP_TOOL_INPUT" "$MCP_TOOL_OUTPUT" 30 +if ! python3 "$REPO_ROOT/scripts/test_mcp_interactive.py" \ + "$BINARY" --scenario roundtrip --repo-path "$TMPDIR" \ + > "$MCP_TOOL_OUTPUT"; then + echo "FAIL: interactive MCP index + search session failed" + cat "$MCP_TOOL_OUTPUT" + rm -f "$MCP_TOOL_OUTPUT" + exit 1 +fi if ! grep -q '"id":2' "$MCP_TOOL_OUTPUT"; then echo "FAIL: no index_repository response (id:2)" cat "$MCP_TOOL_OUTPUT" - rm -f "$MCP_TOOL_INPUT" "$MCP_TOOL_OUTPUT" + rm -f "$MCP_TOOL_OUTPUT" exit 1 fi if ! grep -q '"id":3' "$MCP_TOOL_OUTPUT"; then echo "FAIL: no search_graph response (id:3)" cat "$MCP_TOOL_OUTPUT" - rm -f "$MCP_TOOL_INPUT" "$MCP_TOOL_OUTPUT" + rm -f "$MCP_TOOL_OUTPUT" exit 1 fi echo "OK: MCP tool call round-trip (index + search) succeeded" @@ -711,7 +731,7 @@ if ! grep -q '"id":1' "$MCP_CL_OUTPUT" || ! grep -q '"id":2' "$MCP_CL_OUTPUT"; t fi echo "OK: Content-Length framing works (OpenCode compatible)" -rm -f "$MCP_CL_INPUT" "$MCP_CL_OUTPUT" "$MCP_TOOL_INPUT" "$MCP_TOOL_OUTPUT" +rm -f "$MCP_CL_INPUT" "$MCP_CL_OUTPUT" "$MCP_TOOL_OUTPUT" echo "" echo "=== Phase 6: CLI subcommands ===" @@ -750,25 +770,49 @@ echo "OK: install --dry-run completed" # 6b: uninstall --dry-run -y echo "--- Phase 6b: uninstall --dry-run ---" -UNINSTALL_OUT=$(run_dryrun_env "$BINARY" uninstall --dry-run -y 2>&1) -if ! echo "$UNINSTALL_OUT" | grep -qi 'uninstall\|remov'; then - echo "FAIL: uninstall --dry-run produced unexpected output" - echo "$UNINSTALL_OUT" - exit 1 +if [[ "$BINARY" == *.exe ]]; then + if UNINSTALL_OUT=$(run_dryrun_env "$BINARY" uninstall --dry-run -y 2>&1); then + echo "FAIL: portable Windows bundle accepted uninstall" + exit 1 + fi + if ! echo "$UNINSTALL_OUT" | grep -qi 'managed\|install\|package'; then + echo "FAIL: portable Windows uninstall refusal had no install guidance" + echo "$UNINSTALL_OUT" + exit 1 + fi +else + UNINSTALL_OUT=$(run_dryrun_env "$BINARY" uninstall --dry-run -y 2>&1) + if ! echo "$UNINSTALL_OUT" | grep -qi 'uninstall\|remov'; then + echo "FAIL: uninstall --dry-run produced unexpected output" + echo "$UNINSTALL_OUT" + exit 1 + fi fi echo "OK: uninstall --dry-run completed" # 6c: update --dry-run --standard -y echo "--- Phase 6c: update --dry-run ---" -UPDATE_OUT=$(run_dryrun_env "$BINARY" update --dry-run --standard -y 2>&1) -if ! echo "$UPDATE_OUT" | grep -qi 'dry-run'; then - echo "FAIL: update --dry-run did not indicate dry-run mode" - echo "$UPDATE_OUT" - exit 1 -fi -if ! echo "$UPDATE_OUT" | grep -qi 'standard'; then - echo "FAIL: update --dry-run did not respect --standard flag" - exit 1 +if [[ "$BINARY" == *.exe ]]; then + if UPDATE_OUT=$(run_dryrun_env "$BINARY" update --dry-run --standard -y 2>&1); then + echo "FAIL: portable Windows bundle accepted update" + exit 1 + fi + if ! echo "$UPDATE_OUT" | grep -qi 'managed\|install\|package'; then + echo "FAIL: portable Windows update refusal had no install guidance" + echo "$UPDATE_OUT" + exit 1 + fi +else + UPDATE_OUT=$(run_dryrun_env "$BINARY" update --dry-run --standard -y 2>&1) + if ! echo "$UPDATE_OUT" | grep -qi 'dry-run'; then + echo "FAIL: update --dry-run did not indicate dry-run mode" + echo "$UPDATE_OUT" + exit 1 + fi + if ! echo "$UPDATE_OUT" | grep -qi 'standard'; then + echo "FAIL: update --dry-run did not respect --standard flag" + exit 1 + fi fi # On Linux the binary must self-update from the static "-portable" asset: the # standard linux asset dynamically links glibc 2.38+ and breaks on older distros @@ -803,7 +847,7 @@ INSTALL_DIR="$REPLACE_DIR/install" mkdir -p "$INSTALL_DIR" # 1. Copy binary to "install dir" as the "currently installed" version -cp "$BINARY" "$INSTALL_DIR/codebase-memory-mcp" +copy_smoke_binary "$INSTALL_DIR/codebase-memory-mcp" chmod 755 "$INSTALL_DIR/codebase-memory-mcp" # Verify installed binary works @@ -815,7 +859,7 @@ if ! echo "$INSTALLED_VER" | grep -qE 'v?[0-9]+\.[0-9]+|dev'; then fi # 2. Copy binary as the "downloaded" new version -cp "$BINARY" "$REPLACE_DIR/smoke-codebase-memory-mcp" +copy_smoke_binary "$REPLACE_DIR/smoke-codebase-memory-mcp" # 3. Simulate cbm_replace_binary: unlink old, copy new rm -f "$INSTALL_DIR/codebase-memory-mcp" @@ -852,17 +896,15 @@ echo "=== Phase 7: MCP advanced tool calls ===" # 7a: search_code via MCP (graph-augmented v2) echo "--- Phase 7a: search_code via MCP ---" -MCP_SC_INPUT=$(mktemp) MCP_SC_OUTPUT=$(mktemp) -cat > "$MCP_SC_INPUT" << SCEOF -{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke","version":"1.0"}}} -{"jsonrpc":"2.0","method":"notifications/initialized"} -{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"index_repository","arguments":{"repo_path":"$TMPDIR","mode":"fast"}}} -{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"search_code","arguments":{"pattern":"compute","mode":"compact","limit":3}}} -{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"get_code_snippet","arguments":{"qualified_name":"compute"}}} -SCEOF - -mcp_run "$MCP_SC_INPUT" "$MCP_SC_OUTPUT" 30 +if ! python3 "$REPO_ROOT/scripts/test_mcp_interactive.py" \ + "$BINARY" --scenario advanced --repo-path "$TMPDIR" \ + > "$MCP_SC_OUTPUT"; then + echo "FAIL: interactive MCP advanced-tool session failed" + cat "$MCP_SC_OUTPUT" + rm -f "$MCP_SC_OUTPUT" + exit 1 +fi if ! grep -q '"id":3' "$MCP_SC_OUTPUT"; then echo "FAIL: search_code response (id:3) missing" @@ -877,7 +919,7 @@ if ! grep -q '"id":4' "$MCP_SC_OUTPUT"; then fi echo "OK: get_code_snippet via MCP" -rm -f "$MCP_SC_INPUT" "$MCP_SC_OUTPUT" +rm -f "$MCP_SC_OUTPUT" fi @@ -955,7 +997,7 @@ mkdir -p "$GITLAB_DIR" "$(dirname "$GITLAB_HOOKS")" "$DEVIN_DIR" mkdir -p "$FAKE_HOME/.local/bin" # Copy binary with correct name for platform if [[ "$BINARY" == *.exe ]]; then - cp "$BINARY" "$FAKE_HOME/.local/bin/codebase-memory-mcp.exe" + copy_smoke_binary "$FAKE_HOME/.local/bin/codebase-memory-mcp.exe" SELF_PATH="$FAKE_HOME/.local/bin/codebase-memory-mcp.exe" else cp "$BINARY" "$FAKE_HOME/.local/bin/codebase-memory-mcp" @@ -2088,6 +2130,10 @@ echo "" echo "=== Phase 9: agent config uninstall E2E ===" # Run uninstall (same FAKE_HOME with all configs present) +UNINSTALL_BINARY="$BINARY" +if [[ "$BINARY" == *.exe ]]; then + UNINSTALL_BINARY="$SELF_PATH" +fi HOME="$FAKE_HOME" \ XDG_CONFIG_HOME="$FAKE_HOME/.config" \ APPDATA="$FAKE_HOME/AppData/Roaming" \ @@ -2095,7 +2141,7 @@ HOME="$FAKE_HOME" \ KIMI_CODE_HOME="$CUSTOM_KIMI_HOME" \ CBM_ROO_CONFIG_PATH="$ROO_CFG" \ PATH="$FAKE_HOME/.local/bin:$PATH" \ - "$BINARY" uninstall -y -n 2>&1 || true + "$UNINSTALL_BINARY" uninstall -y -n 2>&1 || true # 9a-b: Claude Code MCP removed but existing keys preserved if cat "$FAKE_HOME/.claude.json" 2>/dev/null | python3 -c " @@ -2456,9 +2502,13 @@ rm -rf "$EMPTY_HOME" # 9b-2: Install twice (idempotent) IDEM_HOME=$(mktemp -d) mkdir -p "$IDEM_HOME/.claude" "$IDEM_HOME/.local/bin" -cp "$BINARY" "$IDEM_HOME/.local/bin/codebase-memory-mcp" -HOME="$IDEM_HOME" "$BINARY" install -y 2>&1 > /dev/null || true +copy_smoke_binary "$IDEM_HOME/.local/bin/codebase-memory-mcp" HOME="$IDEM_HOME" "$BINARY" install -y 2>&1 > /dev/null || true +IDEM_INSTALLER="$BINARY" +if [[ "$BINARY" == *.exe ]]; then + IDEM_INSTALLER="$IDEM_HOME/.local/bin/codebase-memory-mcp.exe" +fi +HOME="$IDEM_HOME" "$IDEM_INSTALLER" install -y 2>&1 > /dev/null || true # Count MCP entries — should be exactly 1 COUNT=$(cat "$IDEM_HOME/.claude.json" 2>/dev/null | python3 -c " import json, sys @@ -2482,7 +2532,7 @@ rm -rf "$CLEAN_HOME" # 9b-4: Install over corrupt JSON CORRUPT_HOME=$(mktemp -d) mkdir -p "$CORRUPT_HOME/.claude" "$CORRUPT_HOME/.local/bin" -cp "$BINARY" "$CORRUPT_HOME/.local/bin/codebase-memory-mcp" +copy_smoke_binary "$CORRUPT_HOME/.local/bin/codebase-memory-mcp" echo '{invalid json here' > "$CORRUPT_HOME/.claude.json" HOME="$CORRUPT_HOME" "$BINARY" install -y 2>&1 > /dev/null || true # Should either fix it or handle gracefully — not crash @@ -2492,9 +2542,13 @@ rm -rf "$CORRUPT_HOME" # 9b-8: Double uninstall DBL_HOME=$(mktemp -d) mkdir -p "$DBL_HOME/.claude" "$DBL_HOME/.local/bin" -cp "$BINARY" "$DBL_HOME/.local/bin/codebase-memory-mcp" +copy_smoke_binary "$DBL_HOME/.local/bin/codebase-memory-mcp" HOME="$DBL_HOME" "$BINARY" install -y 2>&1 > /dev/null || true -HOME="$DBL_HOME" "$BINARY" uninstall -y -n 2>&1 > /dev/null || true +DBL_UNINSTALLER="$BINARY" +if [[ "$BINARY" == *.exe ]]; then + DBL_UNINSTALLER="$DBL_HOME/.local/bin/codebase-memory-mcp.exe" +fi +HOME="$DBL_HOME" "$DBL_UNINSTALLER" uninstall -y -n 2>&1 > /dev/null || true HOME="$DBL_HOME" "$BINARY" uninstall -y -n 2>&1 > /dev/null || true echo "OK 9b-8: double uninstall doesn't crash" rm -rf "$DBL_HOME" @@ -2523,7 +2577,7 @@ echo "=== Phase 10: binary security E2E ===" SECURITY_DIR=$(mktemp -d) SECURITY_BIN="$SECURITY_DIR/codebase-memory-mcp" -cp "$BINARY" "$SECURITY_BIN" +copy_smoke_binary "$SECURITY_BIN" chmod 755 "$SECURITY_BIN" if [ "$(uname -s)" = "Darwin" ]; then @@ -2642,11 +2696,27 @@ echo "" echo "=== Phase 14: update + uninstall E2E ===" if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then - # ── 14a-f: Real update command against local HTTP server ── + # ── 14a-f: Real update command against the local release fixture ── + # Curl/installer phases below keep using the loopback HTTP artifact server. + # Native update intentionally accepts only HTTPS, plus an explicit file:// + # CBM_DOWNLOAD_URL test override, so point it directly at the same fixture. + UPDATE_DOWNLOAD_URL="$SMOKE_DOWNLOAD_URL" + if [ -n "${SMOKE_UPDATE_FIXTURE_DIR:-}" ]; then + UPDATE_FIXTURE_DIR="$SMOKE_UPDATE_FIXTURE_DIR" + if command -v cygpath &>/dev/null; then + UPDATE_FIXTURE_DIR=$(cygpath -m "$UPDATE_FIXTURE_DIR") + UPDATE_DOWNLOAD_URL="file:///$UPDATE_FIXTURE_DIR" + elif [[ "$UPDATE_FIXTURE_DIR" == /* ]]; then + UPDATE_DOWNLOAD_URL="file://$UPDATE_FIXTURE_DIR" + else + echo "FAIL 14a: SMOKE_UPDATE_FIXTURE_DIR must be absolute" + exit 1 + fi + fi UPDATE_HOME=$(mktemp -d) mkdir -p "$UPDATE_HOME/.claude" "$UPDATE_HOME/.local/bin" if [[ "$BINARY" == *.exe ]]; then - cp "$BINARY" "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe" + copy_smoke_binary "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe" chmod 755 "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe" else cp "$BINARY" "$UPDATE_HOME/.local/bin/codebase-memory-mcp" @@ -2656,6 +2726,20 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then fi fi + # A portable Windows payload may install a managed launcher, but it must not + # perform update/uninstall directly. Establish the managed layout first and + # exercise those mutations through its canonical launcher. + UPDATE_DRIVER="$BINARY" + if [[ "$BINARY" == *.exe ]]; then + HOME="$UPDATE_HOME" "$WINDOWS_PAYLOAD" install -y --force --skip-config \ + "--dir=$UPDATE_HOME/.local/bin" + UPDATE_DRIVER="$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe" + if [ ! -f "$UPDATE_DRIVER" ]; then + echo "FAIL 14a: managed Windows launcher missing after install" + exit 1 + fi + fi + # Pre-install agent config with a WRONG binary path (simulates stale config) echo '{"mcpServers":{"codebase-memory-mcp":{"command":"/old/stale/path"}}}' > "$UPDATE_HOME/.claude.json" @@ -2664,15 +2748,19 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then if curl -sf "$SMOKE_DOWNLOAD_URL/" 2>/dev/null | grep -q "ui-"; then UPDATE_VARIANT="--ui" fi - HOME="$UPDATE_HOME" CBM_DOWNLOAD_URL="$SMOKE_DOWNLOAD_URL" \ - "$BINARY" update $UPDATE_VARIANT -y 2>&1 || true + HOME="$UPDATE_HOME" CBM_DOWNLOAD_URL="$UPDATE_DOWNLOAD_URL" \ + "$UPDATE_DRIVER" update $UPDATE_VARIANT -y 2>&1 # 14b: Verify new binary exists and runs - if [ ! -f "$UPDATE_HOME/.local/bin/codebase-memory-mcp" ]; then + if [[ "$BINARY" == *.exe ]]; then + UPD_BIN="$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe" + else + UPD_BIN="$UPDATE_HOME/.local/bin/codebase-memory-mcp" + fi + if [ ! -f "$UPD_BIN" ]; then echo "FAIL 14b: binary missing after update" exit 1 fi - UPD_BIN="$UPDATE_HOME/.local/bin/codebase-memory-mcp" if [ "$(uname -s)" = "Darwin" ]; then codesign --sign - --force "$UPD_BIN" 2>/dev/null || true fi @@ -2696,13 +2784,13 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then # ── 14d-f: Real uninstall with binary removal ── # First verify binary + configs exist - if [ ! -f "$UPDATE_HOME/.local/bin/codebase-memory-mcp" ]; then + if [ ! -f "$UPD_BIN" ]; then echo "FAIL 14d: binary should exist before uninstall" exit 1 fi # Run actual uninstall - HOME="$UPDATE_HOME" "$BINARY" uninstall -y 2>&1 || true + HOME="$UPDATE_HOME" "$UPD_BIN" uninstall -y 2>&1 # 14e: Verify binary removed if [ -f "$UPDATE_HOME/.local/bin/codebase-memory-mcp" ] || [ -f "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe" ]; then @@ -2730,9 +2818,9 @@ else # Local mode: basic binary replacement test (no download) UPDATE_DIR=$(mktemp -d) mkdir -p "$UPDATE_DIR/install" - cp "$BINARY" "$UPDATE_DIR/install/codebase-memory-mcp" + copy_smoke_binary "$UPDATE_DIR/install/codebase-memory-mcp" chmod 755 "$UPDATE_DIR/install/codebase-memory-mcp" - cp "$BINARY" "$UPDATE_DIR/smoke-downloaded" + copy_smoke_binary "$UPDATE_DIR/smoke-downloaded" rm -f "$UPDATE_DIR/install/codebase-memory-mcp" cp "$UPDATE_DIR/smoke-downloaded" "$UPDATE_DIR/install/codebase-memory-mcp" chmod 755 "$UPDATE_DIR/install/codebase-memory-mcp" @@ -2847,7 +2935,16 @@ echo "OK 12c: checksum verified" # 12d: extract binary echo "--- Phase 12d: extraction ---" (cd "$DL_DIR" && if [ "$DL_EXT" = "zip" ]; then unzip -q "$DL_ARCHIVE"; else tar -xzf "$DL_ARCHIVE"; fi) -DL_BIN="$DL_DIR/codebase-memory-mcp" +if [ "$DL_OS" = "windows" ]; then + DL_BIN="$DL_DIR/codebase-memory-mcp.exe" + DL_PAYLOAD="$DL_DIR/codebase-memory-mcp.payload.exe" + if [ ! -f "$DL_PAYLOAD" ]; then + echo "FAIL 12d: Windows payload not found after extraction" + exit 1 + fi +else + DL_BIN="$DL_DIR/codebase-memory-mcp" +fi if [ ! -f "$DL_BIN" ]; then echo "FAIL 12d: binary not found after extraction" exit 1 @@ -2855,6 +2952,11 @@ fi chmod +x "$DL_BIN" echo "OK 12d: binary extracted" +if [ "$DL_OS" = "windows" ] && ! "$DL_PAYLOAD" --version > /dev/null 2>&1; then + echo "FAIL 12d: extracted Windows payload doesn't run" + exit 1 +fi + # 12e: extracted binary runs if ! "$DL_BIN" --version > /dev/null 2>&1; then # On macOS arm64, may need signing diff --git a/scripts/test-windows.ps1 b/scripts/test-windows.ps1 index b1e12ff5d..72b287eda 100644 --- a/scripts/test-windows.ps1 +++ b/scripts/test-windows.ps1 @@ -18,6 +18,8 @@ * test_hook_augment.py guards #618 (fixed by #619) * test_ui_drive_listing.py guards #548 (roots field) * test_cli_non_ascii_arg.py guards #423/#20 (wide-argv main()) + * test_windows_launcher.py guards the permanent launcher, + managed layout, portable refusal, and crash containment KNOWN REDS - genuine, still-open Windows bugs reproduced at the product surface. They are EXPECTED to be RED (exit 1) and are opt-in @@ -37,8 +39,13 @@ AddressSanitizer/UBSan (Linux containers, WSL), prefer scripts/test.sh. .PARAMETER Binary - Path to an existing codebase-memory-mcp.exe. If omitted, the script builds it - (target selected by -Target) into build/c/. + Path to an existing portable payload executable. If omitted, the script + builds it (target selected by -Target) into build/c/. + +.PARAMETER Launcher + Path to the permanent launcher executable. If omitted, the script uses + build/c/codebase-memory-mcp-launcher.exe, building target cbm-launcher when + needed. .PARAMETER Target Makefile.cbm target used when building: 'cbm-with-ui' (default; needed for the @@ -60,6 +67,7 @@ [CmdletBinding()] param( [string]$Binary, + [string]$Launcher, [ValidateSet("cbm-with-ui", "cbm")] [string]$Target = "cbm-with-ui", [switch]$GuardsOnly, @@ -93,8 +101,34 @@ function Resolve-Binary { return $built } +function Resolve-Launcher { + param([string]$Explicit) + if ($Explicit) { return (Resolve-Path $Explicit).Path } + $built = Join-Path $repoRoot "build\c\codebase-memory-mcp-launcher.exe" + if (Test-Path $built) { return $built } + Write-Host "Building permanent launcher via Makefile.cbm ..." -ForegroundColor Cyan + & $Make "-j" "-f" "Makefile.cbm" "cbm-launcher" "SANITIZE=" "TMP=$tmp" "TEMP=$tmp" "TMPDIR=$tmp" + if ($LASTEXITCODE -ne 0) { throw "launcher build failed (exit $LASTEXITCODE)" } + if (-not (Test-Path $built)) { throw "launcher not produced at $built" } + return $built +} + +function Resolve-AbiMismatchLauncher { + $built = Join-Path $repoRoot "build\c\codebase-memory-mcp-launcher-abi2.exe" + if (Test-Path $built) { return $built } + Write-Host "Building launcher ABI mismatch fixture via Makefile.cbm ..." -ForegroundColor Cyan + & $Make "-j" "-f" "Makefile.cbm" "build/c/codebase-memory-mcp-launcher-abi2.exe" "SANITIZE=" "TMP=$tmp" "TEMP=$tmp" "TMPDIR=$tmp" + if ($LASTEXITCODE -ne 0) { throw "launcher ABI fixture build failed (exit $LASTEXITCODE)" } + if (-not (Test-Path $built)) { throw "launcher ABI fixture not produced at $built" } + return $built +} + $bin = Resolve-Binary -Explicit $Binary -Write-Host "Binary: $bin" -ForegroundColor Green +$launcherBin = Resolve-Launcher -Explicit $Launcher +$abiMismatchLauncher = Resolve-AbiMismatchLauncher +Write-Host "Payload: $bin" -ForegroundColor Green +Write-Host "Launcher: $launcherBin" -ForegroundColor Green +Write-Host "ABI mismatch fixture: $abiMismatchLauncher" -ForegroundColor Green $env:PYTHONUTF8 = "1" # encode argv/stdio as UTF-8 $env:CBM_INDEX_SUPERVISOR = "0" # in-process indexing (see .DESCRIPTION) @@ -108,7 +142,8 @@ $guards = @( "tests\windows\test_non_ascii_cache_dump.py", "tests\windows\test_hook_augment.py", "tests\windows\test_ui_drive_listing.py", - "tests\windows\test_cli_non_ascii_arg.py" + "tests\windows\test_cli_non_ascii_arg.py", + "tests\windows\test_windows_launcher.py" ) # Opt-in known-red repros - EXPECTED red (exit 1); never gate CI. Currently empty: @@ -122,7 +157,11 @@ $fixedKeepers = @() Write-Host "`n--- Green guards ---" -ForegroundColor Cyan foreach ($t in $guards) { Write-Host "`n=== $t ===" -ForegroundColor Cyan - & $py $t $bin + if ($t -eq "tests\windows\test_windows_launcher.py") { + & $py $t $launcherBin $bin $abiMismatchLauncher + } else { + & $py $t $bin + } $code = $LASTEXITCODE if ($code -eq 0) { Write-Host "GREEN ($t)" -ForegroundColor Green diff --git a/scripts/test.sh b/scripts/test.sh index 1254419a6..c3bb32c8c 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -51,6 +51,18 @@ done print_env "test.sh" +# Step 0: fast build/security harness regressions run before the compiler-heavy +# suite. The Windows package surface is static here; native launcher behavior is +# exercised by scripts/test-windows.ps1. +echo "=== Step 0a: Windows launcher bundle contract ===" +bash "$ROOT/tests/test_windows_bundle_contract.sh" + +echo "=== Step 0b: tree-sitter runtime Makefile dependencies ===" +bash "$ROOT/tests/test_makefile_ts_runtime_dependencies.sh" + +echo "=== Step 0c: security fuzz harness self-test ===" +bash "$ROOT/tests/test_security_fuzz_harness.sh" + # Verify compiler supports target arch verify_compiler "$CC" diff --git a/scripts/test_mcp_interactive.py b/scripts/test_mcp_interactive.py new file mode 100644 index 000000000..f0c81ef55 --- /dev/null +++ b/scripts/test_mcp_interactive.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Bounded interactive MCP smoke scenarios. + +Unlike a regular-file fixture, this keeps stdin open while each potentially +long-running request completes. That models a standing coding-agent session +and prevents EOF/session cancellation from racing the response assertions. +""" + +from __future__ import annotations + +import argparse +import json +import queue +import subprocess +import sys +import threading +import time +from typing import Any, BinaryIO + + +INITIALIZE_PARAMS = { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "interactive-smoke", "version": "1.0"}, +} + + +class SmokeFailure(RuntimeError): + pass + + +def read_json_lines( + stream: BinaryIO, + responses: "queue.Queue[dict[str, Any]]", + transcript: list[dict[str, Any]], +) -> None: + for raw_line in iter(stream.readline, b""): + try: + message = json.loads(raw_line.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + continue + if isinstance(message, dict): + transcript.append(message) + responses.put(message) + + +def drain(stream: BinaryIO, chunks: list[bytes]) -> None: + for chunk in iter(lambda: stream.read(8192), b""): + chunks.append(chunk) + + +def send(process: subprocess.Popen[bytes], message: dict[str, Any]) -> None: + if process.stdin is None: + raise SmokeFailure("MCP stdin is unavailable") + encoded = json.dumps(message, separators=(",", ":")).encode("utf-8") + b"\n" + try: + process.stdin.write(encoded) + process.stdin.flush() + except (BrokenPipeError, OSError) as error: + raise SmokeFailure("MCP server closed stdin early") from error + + +def wait_response( + process: subprocess.Popen[bytes], + responses: "queue.Queue[dict[str, Any]]", + request_id: int, + timeout: float, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout + while True: + if process.poll() is not None and responses.empty(): + raise SmokeFailure( + f"MCP server exited {process.returncode} before response id={request_id}" + ) + try: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise queue.Empty + message = responses.get(timeout=remaining) + except queue.Empty as error: + raise SmokeFailure(f"timed out waiting for MCP response id={request_id}") from error + if message.get("id") != request_id: + continue + if "error" in message: + raise SmokeFailure( + f"MCP response id={request_id} returned JSON-RPC error: {message['error']!r}" + ) + result = message.get("result") + if isinstance(result, dict) and result.get("isError") is True: + raise SmokeFailure( + f"MCP tool response id={request_id} reported isError=true" + ) + return message + + +def request( + process: subprocess.Popen[bytes], + responses: "queue.Queue[dict[str, Any]]", + request_id: int, + method: str, + params: dict[str, Any], + timeout: float, +) -> dict[str, Any]: + send( + process, + { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + }, + ) + return wait_response(process, responses, request_id, timeout) + + +def run_scenario( + process: subprocess.Popen[bytes], + responses: "queue.Queue[dict[str, Any]]", + scenario: str, + repo_path: str, + timeout: float, +) -> None: + request(process, responses, 1, "initialize", INITIALIZE_PARAMS, timeout) + send( + process, + {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, + ) + request( + process, + responses, + 2, + "tools/call", + { + "name": "index_repository", + "arguments": {"repo_path": repo_path, "mode": "fast"}, + }, + timeout, + ) + if scenario == "roundtrip": + request( + process, + responses, + 3, + "tools/call", + {"name": "search_graph", "arguments": {"name_pattern": "compute"}}, + timeout, + ) + return + request( + process, + responses, + 3, + "tools/call", + { + "name": "search_code", + "arguments": {"pattern": "compute", "mode": "compact", "limit": 3}, + }, + timeout, + ) + request( + process, + responses, + 4, + "tools/call", + { + "name": "get_code_snippet", + "arguments": {"qualified_name": "compute"}, + }, + timeout, + ) + + +def stop_process(process: subprocess.Popen[bytes]) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.wait(timeout=2) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=2) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("binary") + parser.add_argument("--scenario", choices=("roundtrip", "advanced"), required=True) + parser.add_argument("--repo-path", required=True) + parser.add_argument("--response-timeout", type=float, default=30.0) + parser.add_argument("--exit-timeout", type=float, default=15.0) + args = parser.parse_args() + + process = subprocess.Popen( + [args.binary], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + assert process.stdout is not None + assert process.stderr is not None + responses: "queue.Queue[dict[str, Any]]" = queue.Queue() + transcript: list[dict[str, Any]] = [] + stderr_chunks: list[bytes] = [] + stdout_thread = threading.Thread( + target=read_json_lines, + args=(process.stdout, responses, transcript), + daemon=True, + ) + stderr_thread = threading.Thread( + target=drain, args=(process.stderr, stderr_chunks), daemon=True + ) + stdout_thread.start() + stderr_thread.start() + + try: + run_scenario( + process, + responses, + args.scenario, + args.repo_path, + args.response_timeout, + ) + assert process.stdin is not None + process.stdin.close() + try: + return_code = process.wait(timeout=args.exit_timeout) + except subprocess.TimeoutExpired as error: + raise SmokeFailure("MCP server did not exit after interactive stdin EOF") from error + if return_code != 0: + raise SmokeFailure(f"MCP server exited nonzero after completed session: {return_code}") + stdout_thread.join(timeout=2) + stderr_thread.join(timeout=2) + for message in transcript: + print(json.dumps(message, separators=(",", ":"), ensure_ascii=False)) + return 0 + except SmokeFailure as error: + stop_process(process) + stderr = b"".join(stderr_chunks).decode("utf-8", errors="replace") + print(f"FAIL: {error}", file=sys.stderr) + if stderr: + print("--- MCP stderr ---", file=sys.stderr) + print(stderr, file=sys.stderr, end="" if stderr.endswith("\n") else "\n") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/cli/activation_transaction.c b/src/cli/activation_transaction.c index 109f80eaa..09f09583e 100644 --- a/src/cli/activation_transaction.c +++ b/src/cli/activation_transaction.c @@ -91,8 +91,7 @@ static cbm_activation_transaction_before_absent_publish_for_test_fn static void *activation_before_absent_publish_context_for_test; void cbm_activation_transaction_set_before_absent_publish_for_test( - cbm_activation_transaction_before_absent_publish_for_test_fn hook, - void *context) { + cbm_activation_transaction_before_absent_publish_for_test_fn hook, void *context) { activation_before_absent_publish_context_for_test = context; activation_before_absent_publish_for_test = hook; } @@ -118,8 +117,8 @@ static char *activation_string_span(const char *value, size_t length) { return copy; } -static bool activation_target_parts(const char *target_path, - char **directory_out, char **name_out) { +static bool activation_target_parts(const char *target_path, char **directory_out, + char **name_out) { *directory_out = NULL; *name_out = NULL; if (!target_path || !target_path[0]) { @@ -149,8 +148,7 @@ static bool activation_target_parts(const char *target_path, *directory_out = activation_string_span(target_path, 3U); #endif } else { - *directory_out = activation_string_span( - target_path, (size_t)(separator - target_path)); + *directory_out = activation_string_span(target_path, (size_t)(separator - target_path)); } if (*directory_out) { *name_out = activation_string_copy(base); @@ -177,10 +175,8 @@ static char *activation_path_name_copy(const char *path) { } static char *activation_unique_path(const char *directory, const char *tag) { - uint64_t sequence = atomic_fetch_add_explicit( - &activation_unique_sequence, 1U, - memory_order_relaxed) + - 1U; + uint64_t sequence = + atomic_fetch_add_explicit(&activation_unique_sequence, 1U, memory_order_relaxed) + 1U; #ifdef _WIN32 unsigned long process_id = (unsigned long)GetCurrentProcessId(); #else @@ -194,10 +190,8 @@ static char *activation_unique_path(const char *directory, const char *tag) { } int written = snprintf( path, needed, "%s%s.cbm-%s-%lu-%" PRIu64, directory, - directory[directory_length - 1U] == '/' || - directory[directory_length - 1U] == '\\' - ? "" - : "/", + directory[directory_length - 1U] == '/' || directory[directory_length - 1U] == '\\' ? "" + : "/", tag, process_id, sequence); if (written <= 0 || (size_t)written >= needed) { free(path); @@ -209,8 +203,7 @@ static char *activation_unique_path(const char *directory, const char *tag) { static bool activation_identity_equal(const activation_file_identity_t *left, const activation_file_identity_t *right) { #ifdef _WIN32 - return left->volume_serial == right->volume_serial && - left->index_high == right->index_high && + return left->volume_serial == right->volume_serial && left->index_high == right->index_high && left->index_low == right->index_low; #else return left->device == right->device && left->inode == right->inode; @@ -231,14 +224,12 @@ static wchar_t *activation_utf8_to_wide(const char *value) { if (!value) { return NULL; } - int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, - NULL, 0); + int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, NULL, 0); if (needed <= 0) { return NULL; } wchar_t *wide = malloc((size_t)needed * sizeof(*wide)); - if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, - wide, needed) <= 0) { + if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, wide, needed) <= 0) { free(wide); return NULL; } @@ -259,9 +250,8 @@ static bool activation_windows_user(void **information_out, PSID *sid_out) { return false; } void *information = calloc(1, needed); - bool ok = information && - GetTokenInformation(token, TokenUser, information, needed, - &needed) != 0; + bool ok = + information && GetTokenInformation(token, TokenUser, information, needed, &needed) != 0; (void)CloseHandle(token); if (!ok) { free(information); @@ -277,11 +267,9 @@ static bool activation_windows_user(void **information_out, PSID *sid_out) { return true; } -static bool activation_windows_security_init( - activation_windows_security_t *security) { +static bool activation_windows_security_init(activation_windows_security_t *security) { memset(security, 0, sizeof(*security)); - if (!activation_windows_user(&security->token_information, - &security->user_sid)) { + if (!activation_windows_user(&security->token_information, &security->user_sid)) { return false; } EXPLICIT_ACCESSW access; @@ -292,14 +280,11 @@ static bool activation_windows_security_init( access.Trustee.TrusteeForm = TRUSTEE_IS_SID; access.Trustee.TrusteeType = TRUSTEE_IS_USER; access.Trustee.ptstrName = (LPWSTR)security->user_sid; - bool ok = SetEntriesInAclW(1, &access, NULL, &security->acl) == ERROR_SUCCESS && - InitializeSecurityDescriptor(&security->descriptor, - SECURITY_DESCRIPTOR_REVISION) && - SetSecurityDescriptorDacl(&security->descriptor, TRUE, - security->acl, FALSE) && - SetSecurityDescriptorControl(&security->descriptor, - SE_DACL_PROTECTED, - SE_DACL_PROTECTED); + bool ok = + SetEntriesInAclW(1, &access, NULL, &security->acl) == ERROR_SUCCESS && + InitializeSecurityDescriptor(&security->descriptor, SECURITY_DESCRIPTOR_REVISION) && + SetSecurityDescriptorDacl(&security->descriptor, TRUE, security->acl, FALSE) && + SetSecurityDescriptorControl(&security->descriptor, SE_DACL_PROTECTED, SE_DACL_PROTECTED); if (!ok) { if (security->acl) { (void)LocalFree(security->acl); @@ -314,8 +299,7 @@ static bool activation_windows_security_init( return true; } -static void activation_windows_security_destroy( - activation_windows_security_t *security) { +static void activation_windows_security_destroy(activation_windows_security_t *security) { if (security->acl) { (void)LocalFree(security->acl); } @@ -331,11 +315,9 @@ static bool activation_windows_owner_is_current(HANDLE handle) { } PSID owner = NULL; PSECURITY_DESCRIPTOR descriptor = NULL; - DWORD result = GetSecurityInfo(handle, SE_FILE_OBJECT, - OWNER_SECURITY_INFORMATION, &owner, NULL, + DWORD result = GetSecurityInfo(handle, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, NULL, NULL, NULL, &descriptor); - bool same = result == ERROR_SUCCESS && owner && IsValidSid(owner) && - EqualSid(owner, user_sid); + bool same = result == ERROR_SUCCESS && owner && IsValidSid(owner) && EqualSid(owner, user_sid); if (descriptor) { (void)LocalFree(descriptor); } @@ -351,21 +333,17 @@ static bool activation_windows_acl_secure(HANDLE handle) { } PACL dacl = NULL; PSECURITY_DESCRIPTOR descriptor = NULL; - DWORD result = GetSecurityInfo(handle, SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION, NULL, NULL, + DWORD result = GetSecurityInfo(handle, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, NULL, NULL, &dacl, NULL, &descriptor); ACL_SIZE_INFORMATION acl_information; memset(&acl_information, 0, sizeof(acl_information)); - bool secure = result == ERROR_SUCCESS && descriptor && dacl && - IsValidAcl(dacl) != 0 && - GetAclInformation(dacl, &acl_information, - sizeof(acl_information), - AclSizeInformation) != 0; - const DWORD mutation_rights = - GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | - FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | - FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | - WRITE_OWNER; + bool secure = + result == ERROR_SUCCESS && descriptor && dacl && IsValidAcl(dacl) != 0 && + GetAclInformation(dacl, &acl_information, sizeof(acl_information), AclSizeInformation) != 0; + const DWORD mutation_rights = GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | + FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | + FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | + WRITE_OWNER; enum { ACTIVATION_WINDOWS_ACE_ALLOW = 0x00, ACTIVATION_WINDOWS_ACE_DENY = 0x01, @@ -383,8 +361,7 @@ static bool activation_windows_acl_secure(HANDLE handle) { if (header->AceType == ACTIVATION_WINDOWS_ACE_DENY || header->AceType == ACTIVATION_WINDOWS_ACE_DENY_OBJECT || header->AceType == ACTIVATION_WINDOWS_ACE_DENY_CALLBACK || - header->AceType == - ACTIVATION_WINDOWS_ACE_DENY_CALLBACK_OBJECT) { + header->AceType == ACTIVATION_WINDOWS_ACE_DENY_CALLBACK_OBJECT) { continue; } if (header->AceType != ACTIVATION_WINDOWS_ACE_ALLOW) { @@ -406,12 +383,10 @@ static bool activation_windows_acl_secure(HANDLE handle) { } PSID sid = (PSID)&ace->SidStart; size_t sid_capacity = (size_t)header->AceSize - sid_offset; - DWORD sid_length = GetSidLengthRequired( - ((SID *)sid)->SubAuthorityCount); + DWORD sid_length = GetSidLengthRequired(((SID *)sid)->SubAuthorityCount); bool trusted = sid_length <= sid_capacity && IsValidSid(sid) && GetLengthSid(sid) == sid_length && - (EqualSid(sid, user_sid) || - IsWellKnownSid(sid, WinLocalSystemSid) || + (EqualSid(sid, user_sid) || IsWellKnownSid(sid, WinLocalSystemSid) || IsWellKnownSid(sid, WinBuiltinAdministratorsSid)); if (!trusted) { secure = false; @@ -424,18 +399,16 @@ static bool activation_windows_acl_secure(HANDLE handle) { return secure; } -static bool activation_windows_identity( - HANDLE handle, activation_file_identity_t *identity_out, - bool require_regular) { +static bool activation_windows_identity(HANDLE handle, activation_file_identity_t *identity_out, + bool require_regular) { BY_HANDLE_FILE_INFORMATION information; if (handle == INVALID_HANDLE_VALUE || GetFileType(handle) != FILE_TYPE_DISK || !GetFileInformationByHandle(handle, &information)) { return false; } - if (require_regular && - ((information.dwFileAttributes & - (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || - information.nNumberOfLinks != 1)) { + if (require_regular && ((information.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0 || + information.nNumberOfLinks != 1)) { return false; } identity_out->volume_serial = information.dwVolumeSerialNumber; @@ -444,15 +417,13 @@ static bool activation_windows_identity( return true; } -static HANDLE activation_windows_directory_open_no_reparse( - const char *directory) { +static HANDLE activation_windows_directory_open_no_reparse(const char *directory) { wchar_t *input = activation_utf8_to_wide(directory); if (!input) { return INVALID_HANDLE_VALUE; } DWORD needed = GetFullPathNameW(input, 0, NULL, NULL); - wchar_t *path = needed > 0 ? calloc((size_t)needed + 1U, sizeof(*path)) - : NULL; + wchar_t *path = needed > 0 ? calloc((size_t)needed + 1U, sizeof(*path)) : NULL; DWORD length = path ? GetFullPathNameW(input, needed + 1U, path, NULL) : 0; free(input); if (!path || length < 3 || length > needed || path[1] != L':' || @@ -470,22 +441,19 @@ static HANDLE activation_windows_directory_open_no_reparse( if (boundary < length && path[boundary] != L'\\') { continue; } - if (boundary < length && boundary > 0 && - path[boundary - 1] == L'\\') { + if (boundary < length && boundary > 0 && path[boundary - 1] == L'\\') { free(path); return INVALID_HANDLE_VALUE; } wchar_t saved = path[boundary]; path[boundary] = L'\0'; - HANDLE handle = CreateFileW( - path, FILE_READ_ATTRIBUTES | READ_CONTROL, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + HANDLE handle = + CreateFileW(path, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); path[boundary] = saved; BY_HANDLE_FILE_INFORMATION information; - bool valid = handle != INVALID_HANDLE_VALUE && - GetFileType(handle) == FILE_TYPE_DISK && + bool valid = handle != INVALID_HANDLE_VALUE && GetFileType(handle) == FILE_TYPE_DISK && GetFileInformationByHandle(handle, &information) && (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; @@ -506,19 +474,16 @@ static HANDLE activation_windows_directory_open_no_reparse( return final_handle; } -static bool activation_directory_secure( - const char *directory, int *unused, - activation_file_identity_t *identity_out) { +static bool activation_directory_secure(const char *directory, int *unused, + activation_file_identity_t *identity_out) { (void)unused; HANDLE handle = activation_windows_directory_open_no_reparse(directory); BY_HANDLE_FILE_INFORMATION information; - bool ok = handle != INVALID_HANDLE_VALUE && - GetFileInformationByHandle(handle, &information) && + bool ok = handle != INVALID_HANDLE_VALUE && GetFileInformationByHandle(handle, &information) && (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && activation_windows_identity(handle, identity_out, false) && - activation_windows_owner_is_current(handle) && - activation_windows_acl_secure(handle); + activation_windows_owner_is_current(handle) && activation_windows_acl_secure(handle); if (handle != INVALID_HANDLE_VALUE) { (void)CloseHandle(handle); } @@ -534,25 +499,22 @@ static bool activation_posix_acl_empty(int descriptor) { static char *activation_posix_walk_path(const char *directory) { #ifdef __APPLE__ static const char *const aliases[] = {"/tmp", "/var"}; - for (size_t index = 0; index < sizeof(aliases) / sizeof(aliases[0]); - index++) { + for (size_t index = 0; index < sizeof(aliases) / sizeof(aliases[0]); index++) { const char *alias = aliases[index]; size_t alias_length = strlen(alias); if (strncmp(directory, alias, alias_length) != 0 || - (directory[alias_length] != '\0' && - directory[alias_length] != '/')) { + (directory[alias_length] != '\0' && directory[alias_length] != '/')) { continue; } struct stat alias_status; char resolved[4096]; - if (lstat(alias, &alias_status) != 0 || - !S_ISLNK(alias_status.st_mode) || alias_status.st_uid != 0 || - !realpath(alias, resolved)) { + if (lstat(alias, &alias_status) != 0 || !S_ISLNK(alias_status.st_mode) || + alias_status.st_uid != 0 || !realpath(alias, resolved)) { return NULL; } struct stat resolved_status; - if (lstat(resolved, &resolved_status) != 0 || - !S_ISDIR(resolved_status.st_mode) || resolved_status.st_uid != 0) { + if (lstat(resolved, &resolved_status) != 0 || !S_ISDIR(resolved_status.st_mode) || + resolved_status.st_uid != 0) { return NULL; } size_t needed = strlen(resolved) + strlen(directory + alias_length) + 1U; @@ -560,8 +522,7 @@ static char *activation_posix_walk_path(const char *directory) { if (!mapped) { return NULL; } - int written = snprintf(mapped, needed, "%s%s", resolved, - directory + alias_length); + int written = snprintf(mapped, needed, "%s%s", resolved, directory + alias_length); if (written <= 0 || (size_t)written >= needed) { free(mapped); return NULL; @@ -575,15 +536,12 @@ static char *activation_posix_walk_path(const char *directory) { static bool activation_posix_intermediate_secure(const struct stat *status) { bool trusted_owner = status->st_uid == 0 || status->st_uid == geteuid(); bool private_permissions = (status->st_mode & 0022) == 0; - bool root_sticky = status->st_uid == 0 && - (status->st_mode & S_ISVTX) != 0; - return S_ISDIR(status->st_mode) && trusted_owner && - (private_permissions || root_sticky); + bool root_sticky = status->st_uid == 0 && (status->st_mode & S_ISVTX) != 0; + return S_ISDIR(status->st_mode) && trusted_owner && (private_permissions || root_sticky); } -static bool activation_directory_secure( - const char *directory, int *directory_fd_out, - activation_file_identity_t *identity_out) { +static bool activation_directory_secure(const char *directory, int *directory_fd_out, + activation_file_identity_t *identity_out) { int flags = O_RDONLY | O_DIRECTORY | O_CLOEXEC; #ifdef O_NOFOLLOW flags |= O_NOFOLLOW; @@ -623,8 +581,7 @@ static bool activation_directory_secure( while (*remaining == '/') { remaining++; } - if (next_ok && *remaining && - !activation_posix_intermediate_secure(&next_status)) { + if (next_ok && *remaining && !activation_posix_intermediate_secure(&next_status)) { next_ok = false; } if (next_ok) { @@ -643,9 +600,8 @@ static bool activation_directory_secure( } } struct stat status; - ok = ok && fstat(descriptor, &status) == 0 && - S_ISDIR(status.st_mode) && status.st_uid == geteuid() && - (status.st_mode & 0022) == 0 && + ok = ok && fstat(descriptor, &status) == 0 && S_ISDIR(status.st_mode) && + status.st_uid == geteuid() && (status.st_mode & 0022) == 0 && activation_posix_acl_empty(descriptor); free(walk_path); if (!ok) { @@ -662,21 +618,16 @@ static bool activation_directory_secure( #endif -static bool activation_directory_still_valid( - const cbm_activation_transaction_t *transaction) { +static bool activation_directory_still_valid(const cbm_activation_transaction_t *transaction) { #ifdef _WIN32 int ignored = 0; activation_file_identity_t current; - return activation_directory_secure(transaction->directory_path, &ignored, - ¤t) && - activation_identity_equal(¤t, - &transaction->directory_identity); + return activation_directory_secure(transaction->directory_path, &ignored, ¤t) && + activation_identity_equal(¤t, &transaction->directory_identity); #else struct stat status; - if (transaction->directory_fd < 0 || - fstat(transaction->directory_fd, &status) != 0 || - !S_ISDIR(status.st_mode) || status.st_uid != geteuid() || - (status.st_mode & 0022) != 0 || + if (transaction->directory_fd < 0 || fstat(transaction->directory_fd, &status) != 0 || + !S_ISDIR(status.st_mode) || status.st_uid != geteuid() || (status.st_mode & 0022) != 0 || !activation_posix_acl_empty(transaction->directory_fd)) { return false; } @@ -684,16 +635,14 @@ static bool activation_directory_still_valid( .device = status.st_dev, .inode = status.st_ino, }; - if (!activation_identity_equal(¤t, - &transaction->directory_identity)) { + if (!activation_identity_equal(¤t, &transaction->directory_identity)) { return false; } int path_fd = -1; activation_file_identity_t path_identity; - bool path_same = activation_directory_secure( - transaction->directory_path, &path_fd, - &path_identity) && - activation_identity_equal(&path_identity, ¤t); + bool path_same = + activation_directory_secure(transaction->directory_path, &path_fd, &path_identity) && + activation_identity_equal(&path_identity, ¤t); if (path_fd >= 0) { (void)close(path_fd); } @@ -721,8 +670,8 @@ static bool activation_native_sync(activation_native_file_t file) { #endif } -static bool activation_native_write_all(activation_native_file_t file, - const void *data, size_t length) { +static bool activation_native_write_all(activation_native_file_t file, const void *data, + size_t length) { const unsigned char *cursor = data; while (length > 0) { #ifdef _WIN32 @@ -755,9 +704,8 @@ typedef enum { } activation_create_status_t; static activation_create_status_t activation_private_file_create( - const cbm_activation_transaction_t *transaction, const char *path, - const char *name, activation_native_file_t *file_out, - activation_file_identity_t *identity_out) { + const cbm_activation_transaction_t *transaction, const char *path, const char *name, + activation_native_file_t *file_out, activation_file_identity_t *identity_out) { *file_out = ACTIVATION_INVALID_FILE; if (!activation_directory_still_valid(transaction)) { return ACTIVATION_CREATE_ERROR; @@ -770,17 +718,15 @@ static activation_create_status_t activation_private_file_create( return ACTIVATION_CREATE_ERROR; } SetLastError(ERROR_SUCCESS); - HANDLE file = CreateFileW( - wide, GENERIC_READ | GENERIC_WRITE | READ_CONTROL | DELETE, - FILE_SHARE_READ, &security.attributes, CREATE_NEW, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + HANDLE file = CreateFileW(wide, GENERIC_READ | GENERIC_WRITE | READ_CONTROL | DELETE, + FILE_SHARE_READ, &security.attributes, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); DWORD error = file == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS; free(wide); bool valid = file != INVALID_HANDLE_VALUE && SetHandleInformation(file, HANDLE_FLAG_INHERIT, 0) != 0 && activation_windows_identity(file, identity_out, true) && - activation_windows_owner_is_current(file) && - activation_windows_acl_secure(file); + activation_windows_owner_is_current(file) && activation_windows_acl_secure(file); activation_windows_security_destroy(&security); if (!valid) { if (file != INVALID_HANDLE_VALUE) { @@ -800,11 +746,9 @@ static activation_create_status_t activation_private_file_create( int file = openat(transaction->directory_fd, name, flags, 0700); int open_error = errno; struct stat status; - bool valid = file >= 0 && fchmod(file, 0700) == 0 && - fstat(file, &status) == 0 && S_ISREG(status.st_mode) && - status.st_uid == geteuid() && status.st_nlink == 1 && - (status.st_mode & 0777) == 0700 && - activation_posix_acl_empty(file); + bool valid = file >= 0 && fchmod(file, 0700) == 0 && fstat(file, &status) == 0 && + S_ISREG(status.st_mode) && status.st_uid == geteuid() && status.st_nlink == 1 && + (status.st_mode & 0777) == 0700 && activation_posix_acl_empty(file); if (!valid) { if (file >= 0) { (void)close(file); @@ -820,9 +764,8 @@ static activation_create_status_t activation_private_file_create( } static cbm_activation_transaction_status_t activation_create_unique( - const cbm_activation_transaction_t *transaction, const char *tag, - char **path_out, char **name_out, activation_native_file_t *file_out, - activation_file_identity_t *identity_out) { + const cbm_activation_transaction_t *transaction, const char *tag, char **path_out, + char **name_out, activation_native_file_t *file_out, activation_file_identity_t *identity_out) { *path_out = NULL; *name_out = NULL; *file_out = ACTIVATION_INVALID_FILE; @@ -836,8 +779,8 @@ static cbm_activation_transaction_status_t activation_create_unique( free(candidate); return CBM_ACTIVATION_TRANSACTION_NO_MEMORY; } - activation_create_status_t created = activation_private_file_create( - transaction, candidate, name, file_out, identity_out); + activation_create_status_t created = + activation_private_file_create(transaction, candidate, name, file_out, identity_out); if (created == ACTIVATION_CREATE_OK) { *path_out = candidate; *name_out = name; @@ -853,9 +796,8 @@ static cbm_activation_transaction_status_t activation_create_unique( } #ifdef _WIN32 -static bool activation_external_snapshot( - const char *path, bool *exists_out, - activation_file_identity_t *identity_out) { +static bool activation_external_snapshot(const char *path, bool *exists_out, + activation_file_identity_t *identity_out) { *exists_out = false; wchar_t *wide = activation_utf8_to_wide(path); if (!wide) { @@ -867,20 +809,17 @@ static bool activation_external_snapshot( free(wide); return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND; } - if ((attributes & - (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + if ((attributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { free(wide); return false; } - HANDLE handle = CreateFileW( - wide, FILE_READ_ATTRIBUTES | READ_CONTROL, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, - OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL); + HANDLE handle = CreateFileW(wide, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL); free(wide); - bool valid = handle != INVALID_HANDLE_VALUE && - activation_windows_identity(handle, identity_out, true) && - activation_windows_owner_is_current(handle) && - activation_windows_acl_secure(handle); + bool valid = + handle != INVALID_HANDLE_VALUE && activation_windows_identity(handle, identity_out, true) && + activation_windows_owner_is_current(handle) && activation_windows_acl_secure(handle); if (handle != INVALID_HANDLE_VALUE) { (void)CloseHandle(handle); } @@ -894,16 +833,14 @@ static bool activation_external_snapshot( #ifndef _WIN32 static bool activation_posix_entry_snapshot_with_links( - const cbm_activation_transaction_t *transaction, const char *name, - nlink_t required_links, bool *exists_out, - activation_file_identity_t *identity_out) { + const cbm_activation_transaction_t *transaction, const char *name, nlink_t required_links, + bool *exists_out, activation_file_identity_t *identity_out) { *exists_out = false; if (!activation_directory_still_valid(transaction)) { return false; } struct stat before; - if (fstatat(transaction->directory_fd, name, &before, - AT_SYMLINK_NOFOLLOW) != 0) { + if (fstatat(transaction->directory_fd, name, &before, AT_SYMLINK_NOFOLLOW) != 0) { return errno == ENOENT; } if (!S_ISREG(before.st_mode) || before.st_uid != geteuid() || @@ -917,20 +854,14 @@ static bool activation_posix_entry_snapshot_with_links( int file = openat(transaction->directory_fd, name, flags); struct stat opened; struct stat after; - bool valid = file >= 0 && fstat(file, &opened) == 0 && - S_ISREG(opened.st_mode) && opened.st_uid == geteuid() && - opened.st_nlink == required_links && - (opened.st_mode & 0022) == 0 && - opened.st_dev == before.st_dev && - opened.st_ino == before.st_ino && - activation_posix_acl_empty(file) && - fstatat(transaction->directory_fd, name, &after, - AT_SYMLINK_NOFOLLOW) == 0 && + bool valid = file >= 0 && fstat(file, &opened) == 0 && S_ISREG(opened.st_mode) && + opened.st_uid == geteuid() && opened.st_nlink == required_links && + (opened.st_mode & 0022) == 0 && opened.st_dev == before.st_dev && + opened.st_ino == before.st_ino && activation_posix_acl_empty(file) && + fstatat(transaction->directory_fd, name, &after, AT_SYMLINK_NOFOLLOW) == 0 && S_ISREG(after.st_mode) && after.st_uid == geteuid() && - after.st_nlink == required_links && - (after.st_mode & 0022) == 0 && - after.st_dev == opened.st_dev && - after.st_ino == opened.st_ino; + after.st_nlink == required_links && (after.st_mode & 0022) == 0 && + after.st_dev == opened.st_dev && after.st_ino == opened.st_ino; bool closed = file >= 0 && close(file) == 0; if (!valid || !closed) { return false; @@ -942,24 +873,22 @@ static bool activation_posix_entry_snapshot_with_links( } #endif -static bool activation_entry_snapshot( - const cbm_activation_transaction_t *transaction, const char *path, - const char *name, bool *exists_out, - activation_file_identity_t *identity_out) { +static bool activation_entry_snapshot(const cbm_activation_transaction_t *transaction, + const char *path, const char *name, bool *exists_out, + activation_file_identity_t *identity_out) { #ifdef _WIN32 return activation_directory_still_valid(transaction) && activation_external_snapshot(path, exists_out, identity_out); #else (void)path; - return activation_posix_entry_snapshot_with_links( - transaction, name, (nlink_t)1, exists_out, identity_out); + return activation_posix_entry_snapshot_with_links(transaction, name, (nlink_t)1, exists_out, + identity_out); #endif } -static bool activation_path_matches( - const cbm_activation_transaction_t *transaction, const char *path, - const char *name, const activation_file_identity_t *expected, - bool *exists_out) { +static bool activation_path_matches(const cbm_activation_transaction_t *transaction, + const char *path, const char *name, + const activation_file_identity_t *expected, bool *exists_out) { activation_file_identity_t actual; bool exists = false; if (!activation_entry_snapshot(transaction, path, name, &exists, &actual)) { @@ -969,8 +898,7 @@ static bool activation_path_matches( return !exists || activation_identity_equal(&actual, expected); } -static bool activation_sync_directory( - const cbm_activation_transaction_t *transaction) { +static bool activation_sync_directory(const cbm_activation_transaction_t *transaction) { #ifdef _WIN32 /* MoveFileExW(MOVEFILE_WRITE_THROUGH) is the strongest portable * directory-entry durability primitive available here. */ @@ -985,18 +913,16 @@ static bool activation_sync_directory( #endif } -static bool activation_rename( - const cbm_activation_transaction_t *transaction, const char *source, - const char *source_name, const char *destination, - const char *destination_name, bool replace_destination) { +static bool activation_rename(const cbm_activation_transaction_t *transaction, const char *source, + const char *source_name, const char *destination, + const char *destination_name, bool replace_destination) { if (!activation_directory_still_valid(transaction)) { return false; } #ifdef _WIN32 wchar_t *wide_source = activation_utf8_to_wide(source); wchar_t *wide_destination = activation_utf8_to_wide(destination); - DWORD flags = MOVEFILE_WRITE_THROUGH | - (replace_destination ? MOVEFILE_REPLACE_EXISTING : 0); + DWORD flags = MOVEFILE_WRITE_THROUGH | (replace_destination ? MOVEFILE_REPLACE_EXISTING : 0); bool ok = wide_source && wide_destination && MoveFileExW(wide_source, wide_destination, flags) != 0 && activation_directory_still_valid(transaction); @@ -1009,8 +935,8 @@ static bool activation_rename( (void)replace_destination; int result; do { - result = renameat(transaction->directory_fd, source_name, - transaction->directory_fd, destination_name); + result = renameat(transaction->directory_fd, source_name, transaction->directory_fd, + destination_name); } while (result != 0 && errno == EINTR); return result == 0; #endif @@ -1023,9 +949,8 @@ typedef enum { } activation_unlink_status_t; static activation_unlink_status_t activation_unlink_expected( - const cbm_activation_transaction_t *transaction, const char *path, - const char *name, const activation_file_identity_t *expected, - bool allow_windows_deferred) { + const cbm_activation_transaction_t *transaction, const char *path, const char *name, + const activation_file_identity_t *expected, bool allow_windows_deferred) { bool exists = false; if (!activation_path_matches(transaction, path, name, expected, &exists)) { return ACTIVATION_UNLINK_ERROR; @@ -1044,8 +969,7 @@ static activation_unlink_status_t activation_unlink_expected( } DWORD error = GetLastError(); bool deferred = allow_windows_deferred && - (error == ERROR_ACCESS_DENIED || - error == ERROR_SHARING_VIOLATION || + (error == ERROR_ACCESS_DENIED || error == ERROR_SHARING_VIOLATION || error == ERROR_LOCK_VIOLATION) && MoveFileExW(wide, NULL, MOVEFILE_DELAY_UNTIL_REBOOT) != 0; free(wide); @@ -1056,13 +980,11 @@ static activation_unlink_status_t activation_unlink_expected( do { result = unlinkat(transaction->directory_fd, name, 0); } while (result != 0 && errno == EINTR); - return result == 0 || errno == ENOENT ? ACTIVATION_UNLINK_OK - : ACTIVATION_UNLINK_ERROR; + return result == 0 || errno == ENOENT ? ACTIVATION_UNLINK_OK : ACTIVATION_UNLINK_ERROR; #endif } -static void activation_transaction_destroy( - cbm_activation_transaction_t *transaction) { +static void activation_transaction_destroy(cbm_activation_transaction_t *transaction) { if (!transaction) { return; } @@ -1103,8 +1025,7 @@ static cbm_activation_transaction_status_t activation_discard_staged_assets( } } ok = activation_sync_directory(transaction) && ok; - return ok ? CBM_ACTIVATION_TRANSACTION_OK - : CBM_ACTIVATION_TRANSACTION_IO; + return ok ? CBM_ACTIVATION_TRANSACTION_OK : CBM_ACTIVATION_TRANSACTION_IO; } static cbm_activation_transaction_status_t activation_transaction_prepare( @@ -1135,25 +1056,22 @@ static cbm_activation_transaction_status_t activation_transaction_prepare( if (!activation_directory_secure(transaction->directory_path, &ignored, &transaction->directory_identity)) { #else - if (!activation_directory_secure(transaction->directory_path, - &transaction->directory_fd, + if (!activation_directory_secure(transaction->directory_path, &transaction->directory_fd, &transaction->directory_identity)) { #endif activation_transaction_destroy(transaction); return CBM_ACTIVATION_TRANSACTION_IO; } - if (!activation_entry_snapshot( - transaction, transaction->target_path, transaction->target_name, - &transaction->target_existed, &transaction->target_identity)) { + if (!activation_entry_snapshot(transaction, transaction->target_path, transaction->target_name, + &transaction->target_existed, &transaction->target_identity)) { activation_transaction_destroy(transaction); return CBM_ACTIVATION_TRANSACTION_IO; } if (transaction->target_existed) { activation_native_file_t reservation = ACTIVATION_INVALID_FILE; cbm_activation_transaction_status_t status = activation_create_unique( - transaction, "backup", &transaction->backup_path, - &transaction->backup_name, &reservation, - &transaction->backup_identity); + transaction, "backup", &transaction->backup_path, &transaction->backup_name, + &reservation, &transaction->backup_identity); if (status != CBM_ACTIVATION_TRANSACTION_OK) { activation_transaction_destroy(transaction); return status; @@ -1171,8 +1089,7 @@ static cbm_activation_transaction_status_t activation_transaction_prepare( return CBM_ACTIVATION_TRANSACTION_OK; } -static void activation_failed_stage_cleanup( - cbm_activation_transaction_t *transaction) { +static void activation_failed_stage_cleanup(cbm_activation_transaction_t *transaction) { if (transaction) { (void)activation_discard_staged_assets(transaction); activation_transaction_destroy(transaction); @@ -1189,15 +1106,15 @@ cbm_activation_transaction_status_t cbm_activation_transaction_stage_bytes( return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT; } cbm_activation_transaction_t *transaction = NULL; - cbm_activation_transaction_status_t status = activation_transaction_prepare( - target_path, ACTIVATION_REPLACE, &transaction); + cbm_activation_transaction_status_t status = + activation_transaction_prepare(target_path, ACTIVATION_REPLACE, &transaction); if (status != CBM_ACTIVATION_TRANSACTION_OK) { return status; } activation_native_file_t staged = ACTIVATION_INVALID_FILE; - status = activation_create_unique( - transaction, "stage", &transaction->staged_path, - &transaction->staged_name, &staged, &transaction->staged_identity); + status = + activation_create_unique(transaction, "stage", &transaction->staged_path, + &transaction->staged_name, &staged, &transaction->staged_identity); if (status != CBM_ACTIVATION_TRANSACTION_OK) { activation_failed_stage_cleanup(transaction); return status; @@ -1206,8 +1123,7 @@ cbm_activation_transaction_status_t cbm_activation_transaction_stage_bytes( bool written = activation_native_write_all(staged, candidate, candidate_size); bool durable = written && activation_native_sync(staged); bool closed = activation_native_close(staged); - if (!written || !durable || !closed || - !activation_sync_directory(transaction)) { + if (!written || !durable || !closed || !activation_sync_directory(transaction)) { activation_failed_stage_cleanup(transaction); return CBM_ACTIVATION_TRANSACTION_IO; } @@ -1215,8 +1131,7 @@ cbm_activation_transaction_status_t cbm_activation_transaction_stage_bytes( return CBM_ACTIVATION_TRANSACTION_OK; } -static bool activation_source_open(const char *path, - activation_native_file_t *file_out) { +static bool activation_source_open(const char *path, activation_native_file_t *file_out) { *file_out = ACTIVATION_INVALID_FILE; char *directory = NULL; char *name = NULL; @@ -1240,23 +1155,18 @@ static bool activation_source_open(const char *path, free(name); return false; } - HANDLE file = CreateFileW( - wide, GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, - OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, - NULL); + HANDLE file = + CreateFileW(wide, GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, NULL); free(wide); activation_file_identity_t actual; activation_file_identity_t directory_now; - bool valid = file != INVALID_HANDLE_VALUE && - activation_windows_identity(file, &actual, true) && - activation_windows_owner_is_current(file) && - activation_windows_acl_secure(file) && + bool valid = file != INVALID_HANDLE_VALUE && activation_windows_identity(file, &actual, true) && + activation_windows_owner_is_current(file) && activation_windows_acl_secure(file) && activation_identity_equal(&actual, &expected) && - activation_directory_secure(directory, &ignored, - &directory_now) && - activation_identity_equal(&directory_now, - &directory_identity); + activation_directory_secure(directory, &ignored, &directory_now) && + activation_identity_equal(&directory_now, &directory_identity); if (!valid) { if (file != INVALID_HANDLE_VALUE) { (void)CloseHandle(file); @@ -1268,31 +1178,25 @@ static bool activation_source_open(const char *path, #else int directory_fd = -1; activation_file_identity_t directory_identity; - if (!activation_directory_secure(directory, &directory_fd, - &directory_identity)) { + if (!activation_directory_secure(directory, &directory_fd, &directory_identity)) { free(directory); free(name); return false; } struct stat before; - bool before_valid = - fstatat(directory_fd, name, &before, AT_SYMLINK_NOFOLLOW) == 0 && - S_ISREG(before.st_mode) && before.st_uid == geteuid() && - before.st_nlink == 1 && (before.st_mode & 0022) == 0; + bool before_valid = fstatat(directory_fd, name, &before, AT_SYMLINK_NOFOLLOW) == 0 && + S_ISREG(before.st_mode) && before.st_uid == geteuid() && + before.st_nlink == 1 && (before.st_mode & 0022) == 0; int flags = O_RDONLY | O_CLOEXEC; #ifdef O_NOFOLLOW flags |= O_NOFOLLOW; #endif int file = before_valid ? openat(directory_fd, name, flags) : -1; struct stat information; - bool valid = file >= 0 && fstat(file, &information) == 0 && - S_ISREG(information.st_mode) && - information.st_uid == geteuid() && - information.st_nlink == 1 && - (information.st_mode & 0022) == 0 && - activation_posix_acl_empty(file) && - information.st_dev == before.st_dev && - information.st_ino == before.st_ino; + bool valid = file >= 0 && fstat(file, &information) == 0 && S_ISREG(information.st_mode) && + information.st_uid == geteuid() && information.st_nlink == 1 && + (information.st_mode & 0022) == 0 && activation_posix_acl_empty(file) && + information.st_dev == before.st_dev && information.st_ino == before.st_ino; (void)close(directory_fd); if (!valid) { if (file >= 0) { @@ -1309,8 +1213,8 @@ static bool activation_source_open(const char *path, return true; } -static bool activation_native_read(activation_native_file_t file, void *buffer, - size_t capacity, size_t *read_out) { +static bool activation_native_read(activation_native_file_t file, void *buffer, size_t capacity, + size_t *read_out) { #ifdef _WIN32 DWORD amount = 0; DWORD request = capacity > (size_t)UINT32_MAX ? UINT32_MAX : (DWORD)capacity; @@ -1338,13 +1242,12 @@ cbm_activation_transaction_status_t cbm_activation_transaction_stage_file( if (transaction_out) { *transaction_out = NULL; } - if (!target_path || !candidate_path || !candidate_path[0] || - !transaction_out) { + if (!target_path || !candidate_path || !candidate_path[0] || !transaction_out) { return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT; } cbm_activation_transaction_t *transaction = NULL; - cbm_activation_transaction_status_t status = activation_transaction_prepare( - target_path, ACTIVATION_REPLACE, &transaction); + cbm_activation_transaction_status_t status = + activation_transaction_prepare(target_path, ACTIVATION_REPLACE, &transaction); if (status != CBM_ACTIVATION_TRANSACTION_OK) { return status; } @@ -1354,9 +1257,9 @@ cbm_activation_transaction_status_t cbm_activation_transaction_stage_file( return CBM_ACTIVATION_TRANSACTION_IO; } activation_native_file_t staged = ACTIVATION_INVALID_FILE; - status = activation_create_unique( - transaction, "stage", &transaction->staged_path, - &transaction->staged_name, &staged, &transaction->staged_identity); + status = + activation_create_unique(transaction, "stage", &transaction->staged_path, + &transaction->staged_name, &staged, &transaction->staged_identity); if (status != CBM_ACTIVATION_TRANSACTION_OK) { (void)activation_native_close(source); activation_failed_stage_cleanup(transaction); @@ -1375,8 +1278,7 @@ cbm_activation_transaction_status_t cbm_activation_transaction_stage_file( if (amount == 0) { break; } - if (SIZE_MAX - total < amount || - !activation_native_write_all(staged, buffer, amount)) { + if (SIZE_MAX - total < amount || !activation_native_write_all(staged, buffer, amount)) { copied = false; break; } @@ -1385,8 +1287,8 @@ cbm_activation_transaction_status_t cbm_activation_transaction_stage_file( bool durable = copied && total > 0 && activation_native_sync(staged); bool source_closed = activation_native_close(source); bool staged_closed = activation_native_close(staged); - if (!copied || total == 0 || !durable || !source_closed || - !staged_closed || !activation_sync_directory(transaction)) { + if (!copied || total == 0 || !durable || !source_closed || !staged_closed || + !activation_sync_directory(transaction)) { activation_failed_stage_cleanup(transaction); return CBM_ACTIVATION_TRANSACTION_IO; } @@ -1395,21 +1297,18 @@ cbm_activation_transaction_status_t cbm_activation_transaction_stage_file( } cbm_activation_transaction_status_t cbm_activation_transaction_stage_removal( - const char *target_path, - cbm_activation_transaction_t **transaction_out) { + const char *target_path, cbm_activation_transaction_t **transaction_out) { if (transaction_out) { *transaction_out = NULL; } if (!target_path || !transaction_out) { return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT; } - return activation_transaction_prepare(target_path, ACTIVATION_REMOVE, - transaction_out); + return activation_transaction_prepare(target_path, ACTIVATION_REMOVE, transaction_out); } #ifdef _WIN32 -static bool activation_windows_copy_target_to_backup( - cbm_activation_transaction_t *transaction) { +static bool activation_windows_copy_target_to_backup(cbm_activation_transaction_t *transaction) { if (!activation_directory_still_valid(transaction)) { return false; } @@ -1420,33 +1319,26 @@ static bool activation_windows_copy_target_to_backup( free(backup_path); return false; } - HANDLE target = CreateFileW( - target_path, GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, - OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, - NULL); - HANDLE backup = CreateFileW( - backup_path, - GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, - FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, - NULL); + HANDLE target = + CreateFileW(target_path, GENERIC_READ | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, NULL); + HANDLE backup = + CreateFileW(backup_path, GENERIC_READ | GENERIC_WRITE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL); free(target_path); free(backup_path); activation_file_identity_t target_identity; activation_file_identity_t backup_identity; LARGE_INTEGER beginning = {.QuadPart = 0}; - bool valid = target != INVALID_HANDLE_VALUE && - backup != INVALID_HANDLE_VALUE && + bool valid = target != INVALID_HANDLE_VALUE && backup != INVALID_HANDLE_VALUE && activation_windows_identity(target, &target_identity, true) && activation_windows_identity(backup, &backup_identity, true) && activation_windows_owner_is_current(target) && activation_windows_owner_is_current(backup) && - activation_windows_acl_secure(target) && - activation_windows_acl_secure(backup) && - activation_identity_equal(&target_identity, - &transaction->target_identity) && - activation_identity_equal(&backup_identity, - &transaction->backup_identity) && + activation_windows_acl_secure(target) && activation_windows_acl_secure(backup) && + activation_identity_equal(&target_identity, &transaction->target_identity) && + activation_identity_equal(&backup_identity, &transaction->backup_identity) && SetFilePointerEx(backup, beginning, NULL, FILE_BEGIN) != 0 && SetEndOfFile(backup) != 0; unsigned char buffer[64U * 1024U]; @@ -1461,8 +1353,7 @@ static bool activation_windows_copy_target_to_backup( valid = valid && activation_native_sync(backup); bool target_closed = target != INVALID_HANDLE_VALUE && CloseHandle(target) != 0; bool backup_closed = backup != INVALID_HANDLE_VALUE && CloseHandle(backup) != 0; - return valid && target_closed && backup_closed && - activation_directory_still_valid(transaction); + return valid && target_closed && backup_closed && activation_directory_still_valid(transaction); } #endif @@ -1499,8 +1390,7 @@ static activation_publish_status_t activation_publish_absent_link_fallback( int linked; do { linked = linkat(transaction->directory_fd, transaction->staged_name, - transaction->directory_fd, transaction->target_name, - 0); + transaction->directory_fd, transaction->target_name, 0); } while (linked != 0 && errno == EINTR); if (linked != 0) { return ACTIVATION_PUBLISH_UNCHANGED_ERROR; @@ -1511,26 +1401,20 @@ static activation_publish_status_t activation_publish_absent_link_fallback( bool staged_exists = false; bool target_exists = false; bool linked_pair = - activation_posix_entry_snapshot_with_links( - transaction, transaction->staged_name, (nlink_t)2, - &staged_exists, &staged_identity) && + activation_posix_entry_snapshot_with_links(transaction, transaction->staged_name, + (nlink_t)2, &staged_exists, &staged_identity) && staged_exists && - activation_identity_equal(&staged_identity, - &transaction->staged_identity) && - activation_posix_entry_snapshot_with_links( - transaction, transaction->target_name, (nlink_t)2, - &target_exists, &target_identity) && - target_exists && - activation_identity_equal(&target_identity, - &transaction->staged_identity); + activation_identity_equal(&staged_identity, &transaction->staged_identity) && + activation_posix_entry_snapshot_with_links(transaction, transaction->target_name, + (nlink_t)2, &target_exists, &target_identity) && + target_exists && activation_identity_equal(&target_identity, &transaction->staged_identity); if (!linked_pair) { return ACTIVATION_PUBLISH_CHANGED_ERROR; } int removed; do { - removed = unlinkat(transaction->directory_fd, - transaction->staged_name, 0); + removed = unlinkat(transaction->directory_fd, transaction->staged_name, 0); } while (removed != 0 && errno == EINTR); if (removed != 0 && errno != ENOENT) { return ACTIVATION_PUBLISH_CHANGED_ERROR; @@ -1539,12 +1423,11 @@ static activation_publish_status_t activation_publish_absent_link_fallback( activation_file_identity_t published_identity; bool published_exists = false; - if (!activation_posix_entry_snapshot_with_links( - transaction, transaction->target_name, (nlink_t)1, - &published_exists, &published_identity) || + if (!activation_posix_entry_snapshot_with_links(transaction, transaction->target_name, + (nlink_t)1, &published_exists, + &published_identity) || !published_exists || - !activation_identity_equal(&published_identity, - &transaction->staged_identity)) { + !activation_identity_equal(&published_identity, &transaction->staged_identity)) { return ACTIVATION_PUBLISH_CHANGED_ERROR; } return ACTIVATION_PUBLISH_OK; @@ -1555,10 +1438,8 @@ static activation_publish_status_t activation_finish_absent_publish( cbm_activation_transaction_t *transaction) { transaction->staged_exists = false; bool target_exists = false; - return activation_path_matches( - transaction, transaction->target_path, - transaction->target_name, &transaction->staged_identity, - &target_exists) && + return activation_path_matches(transaction, transaction->target_path, transaction->target_name, + &transaction->staged_identity, &target_exists) && target_exists ? ACTIVATION_PUBLISH_OK : ACTIVATION_PUBLISH_CHANGED_ERROR; @@ -1570,19 +1451,16 @@ static activation_publish_status_t activation_publish_absent_replacement( return ACTIVATION_PUBLISH_UNCHANGED_ERROR; } bool staged_exists = false; - if (!activation_path_matches( - transaction, transaction->staged_path, - transaction->staged_name, &transaction->staged_identity, - &staged_exists) || + if (!activation_path_matches(transaction, transaction->staged_path, transaction->staged_name, + &transaction->staged_identity, &staged_exists) || !staged_exists) { return ACTIVATION_PUBLISH_UNCHANGED_ERROR; } #ifdef _WIN32 wchar_t *source = activation_utf8_to_wide(transaction->staged_path); wchar_t *destination = activation_utf8_to_wide(transaction->target_path); - bool published = source && destination && - MoveFileExW(source, destination, - MOVEFILE_WRITE_THROUGH) != 0; + bool published = + source && destination && MoveFileExW(source, destination, MOVEFILE_WRITE_THROUGH) != 0; free(source); free(destination); if (!published) { @@ -1592,10 +1470,8 @@ static activation_publish_status_t activation_publish_absent_replacement( #elif defined(__APPLE__) int renamed; do { - renamed = renameatx_np(transaction->directory_fd, - transaction->staged_name, - transaction->directory_fd, - transaction->target_name, RENAME_EXCL); + renamed = renameatx_np(transaction->directory_fd, transaction->staged_name, + transaction->directory_fd, transaction->target_name, RENAME_EXCL); } while (renamed != 0 && errno == EINTR); if (renamed == 0) { return activation_finish_absent_publish(transaction); @@ -1608,10 +1484,8 @@ static activation_publish_status_t activation_publish_absent_replacement( const unsigned int rename_noreplace = 1U; long renamed; do { - renamed = syscall(SYS_renameat2, transaction->directory_fd, - transaction->staged_name, - transaction->directory_fd, - transaction->target_name, rename_noreplace); + renamed = syscall(SYS_renameat2, transaction->directory_fd, transaction->staged_name, + transaction->directory_fd, transaction->target_name, rename_noreplace); } while (renamed != 0 && errno == EINTR); if (renamed == 0) { return activation_finish_absent_publish(transaction); @@ -1626,35 +1500,30 @@ static activation_publish_status_t activation_publish_absent_replacement( } #ifndef _WIN32 -static bool activation_linked_backup_pair_valid( - const cbm_activation_transaction_t *transaction) { +static bool activation_linked_backup_pair_valid(const cbm_activation_transaction_t *transaction) { activation_file_identity_t target_identity; activation_file_identity_t backup_identity; bool target_exists = false; bool backup_exists = false; - return activation_posix_entry_snapshot_with_links( - transaction, transaction->target_name, (nlink_t)2, - &target_exists, &target_identity) && + return activation_posix_entry_snapshot_with_links(transaction, transaction->target_name, + (nlink_t)2, &target_exists, + &target_identity) && target_exists && - activation_identity_equal(&target_identity, - &transaction->target_identity) && - activation_posix_entry_snapshot_with_links( - transaction, transaction->backup_name, (nlink_t)2, - &backup_exists, &backup_identity) && + activation_identity_equal(&target_identity, &transaction->target_identity) && + activation_posix_entry_snapshot_with_links(transaction, transaction->backup_name, + (nlink_t)2, &backup_exists, + &backup_identity) && backup_exists && - activation_identity_equal(&backup_identity, - &transaction->target_identity); + activation_identity_equal(&backup_identity, &transaction->target_identity); } -static bool activation_remove_linked_backup( - cbm_activation_transaction_t *transaction) { +static bool activation_remove_linked_backup(cbm_activation_transaction_t *transaction) { if (!activation_linked_backup_pair_valid(transaction)) { return false; } int result; do { - result = unlinkat(transaction->directory_fd, - transaction->backup_name, 0); + result = unlinkat(transaction->directory_fd, transaction->backup_name, 0); } while (result != 0 && errno == EINTR); if (result == 0) { transaction->backup_exists = false; @@ -1675,31 +1544,29 @@ static activation_publish_status_t activation_publish_existing_replacement( if (!activation_windows_copy_target_to_backup(transaction)) { return ACTIVATION_PUBLISH_UNCHANGED_ERROR; } - if (!activation_rename( - transaction, transaction->staged_path, transaction->staged_name, - transaction->target_path, transaction->target_name, true)) { + if (!activation_rename(transaction, transaction->staged_path, transaction->staged_name, + transaction->target_path, transaction->target_name, true)) { return ACTIVATION_PUBLISH_UNCHANGED_ERROR; } transaction->staged_exists = false; transaction->backup_contains_target = true; bool target_exists = false; - if (!activation_path_matches( - transaction, transaction->target_path, transaction->target_name, - &transaction->staged_identity, &target_exists) || + if (!activation_path_matches(transaction, transaction->target_path, transaction->target_name, + &transaction->staged_identity, &target_exists) || !target_exists) { return ACTIVATION_PUBLISH_CHANGED_ERROR; } #else - activation_unlink_status_t reservation_removed = activation_unlink_expected( - transaction, transaction->backup_path, transaction->backup_name, - &transaction->backup_identity, false); + activation_unlink_status_t reservation_removed = + activation_unlink_expected(transaction, transaction->backup_path, transaction->backup_name, + &transaction->backup_identity, false); if (reservation_removed != ACTIVATION_UNLINK_OK) { return ACTIVATION_PUBLISH_UNCHANGED_ERROR; } transaction->backup_exists = false; if (!activation_directory_still_valid(transaction) || - linkat(transaction->directory_fd, transaction->target_name, - transaction->directory_fd, transaction->backup_name, 0) != 0) { + linkat(transaction->directory_fd, transaction->target_name, transaction->directory_fd, + transaction->backup_name, 0) != 0) { return ACTIVATION_PUBLISH_UNCHANGED_ERROR; } transaction->backup_exists = true; @@ -1707,54 +1574,43 @@ static activation_publish_status_t activation_publish_existing_replacement( transaction->backup_identity = transaction->target_identity; bool staged_exists = false; if (!activation_linked_backup_pair_valid(transaction) || - !activation_path_matches( - transaction, transaction->staged_path, - transaction->staged_name, &transaction->staged_identity, - &staged_exists) || + !activation_path_matches(transaction, transaction->staged_path, transaction->staged_name, + &transaction->staged_identity, &staged_exists) || !staged_exists) { - return activation_remove_linked_backup(transaction) - ? ACTIVATION_PUBLISH_UNCHANGED_ERROR - : ACTIVATION_PUBLISH_CHANGED_ERROR; + return activation_remove_linked_backup(transaction) ? ACTIVATION_PUBLISH_UNCHANGED_ERROR + : ACTIVATION_PUBLISH_CHANGED_ERROR; } if (!activation_sync_directory(transaction)) { - return activation_remove_linked_backup(transaction) - ? ACTIVATION_PUBLISH_UNCHANGED_ERROR - : ACTIVATION_PUBLISH_CHANGED_ERROR; + return activation_remove_linked_backup(transaction) ? ACTIVATION_PUBLISH_UNCHANGED_ERROR + : ACTIVATION_PUBLISH_CHANGED_ERROR; } - if (!activation_rename( - transaction, transaction->staged_path, transaction->staged_name, - transaction->target_path, transaction->target_name, true)) { - return activation_remove_linked_backup(transaction) - ? ACTIVATION_PUBLISH_UNCHANGED_ERROR - : ACTIVATION_PUBLISH_CHANGED_ERROR; + if (!activation_rename(transaction, transaction->staged_path, transaction->staged_name, + transaction->target_path, transaction->target_name, true)) { + return activation_remove_linked_backup(transaction) ? ACTIVATION_PUBLISH_UNCHANGED_ERROR + : ACTIVATION_PUBLISH_CHANGED_ERROR; } transaction->staged_exists = false; #endif return ACTIVATION_PUBLISH_OK; } -static bool activation_target_still_original( - const cbm_activation_transaction_t *transaction) { +static bool activation_target_still_original(const cbm_activation_transaction_t *transaction) { activation_file_identity_t current; bool exists = false; - if (!activation_entry_snapshot( - transaction, transaction->target_path, transaction->target_name, - &exists, ¤t) || + if (!activation_entry_snapshot(transaction, transaction->target_path, transaction->target_name, + &exists, ¤t) || exists != transaction->target_existed) { return false; } - return !exists || activation_identity_equal(¤t, - &transaction->target_identity); + return !exists || activation_identity_equal(¤t, &transaction->target_identity); } -static bool activation_absent_target_snapshot( - const cbm_activation_transaction_t *transaction, bool *exists_out, - bool *linked_pair_out) { +static bool activation_absent_target_snapshot(const cbm_activation_transaction_t *transaction, + bool *exists_out, bool *linked_pair_out) { *exists_out = false; *linked_pair_out = false; - if (activation_path_matches( - transaction, transaction->target_path, transaction->target_name, - &transaction->staged_identity, exists_out)) { + if (activation_path_matches(transaction, transaction->target_path, transaction->target_name, + &transaction->staged_identity, exists_out)) { return true; } #ifndef _WIN32 @@ -1765,18 +1621,14 @@ static bool activation_absent_target_snapshot( activation_file_identity_t target_identity; bool staged_exists = false; bool target_exists = false; - if (!activation_posix_entry_snapshot_with_links( - transaction, transaction->target_name, (nlink_t)2, - &target_exists, &target_identity) || + if (!activation_posix_entry_snapshot_with_links(transaction, transaction->target_name, + (nlink_t)2, &target_exists, &target_identity) || !target_exists || - !activation_identity_equal(&target_identity, - &transaction->staged_identity) || - !activation_posix_entry_snapshot_with_links( - transaction, transaction->staged_name, (nlink_t)2, - &staged_exists, &staged_identity) || + !activation_identity_equal(&target_identity, &transaction->staged_identity) || + !activation_posix_entry_snapshot_with_links(transaction, transaction->staged_name, + (nlink_t)2, &staged_exists, &staged_identity) || !staged_exists || - !activation_identity_equal(&staged_identity, - &transaction->staged_identity)) { + !activation_identity_equal(&staged_identity, &transaction->staged_identity)) { return false; } *exists_out = true; @@ -1787,26 +1639,23 @@ static bool activation_absent_target_snapshot( #endif } -static bool activation_remove_absent_published_target( - cbm_activation_transaction_t *transaction, bool linked_pair) { +static bool activation_remove_absent_published_target(cbm_activation_transaction_t *transaction, + bool linked_pair) { if (!linked_pair) { - return activation_unlink_expected( - transaction, transaction->target_path, - transaction->target_name, &transaction->staged_identity, - false) == ACTIVATION_UNLINK_OK; + return activation_unlink_expected(transaction, transaction->target_path, + transaction->target_name, &transaction->staged_identity, + false) == ACTIVATION_UNLINK_OK; } #ifndef _WIN32 bool target_exists = false; bool still_linked = false; - if (!activation_absent_target_snapshot(transaction, &target_exists, - &still_linked) || + if (!activation_absent_target_snapshot(transaction, &target_exists, &still_linked) || !target_exists || !still_linked) { return false; } int removed; do { - removed = unlinkat(transaction->directory_fd, - transaction->target_name, 0); + removed = unlinkat(transaction->directory_fd, transaction->target_name, 0); } while (removed != 0 && errno == EINTR); if (removed != 0) { return false; @@ -1815,15 +1664,14 @@ static bool activation_remove_absent_published_target( bool staged_exists = false; activation_file_identity_t absent_identity; bool target_remains = false; - return activation_posix_entry_snapshot_with_links( - transaction, transaction->staged_name, (nlink_t)1, - &staged_exists, &staged_identity) && + return activation_posix_entry_snapshot_with_links(transaction, transaction->staged_name, + (nlink_t)1, &staged_exists, + &staged_identity) && staged_exists && - activation_identity_equal(&staged_identity, - &transaction->staged_identity) && - activation_posix_entry_snapshot_with_links( - transaction, transaction->target_name, (nlink_t)1, - &target_remains, &absent_identity) && + activation_identity_equal(&staged_identity, &transaction->staged_identity) && + activation_posix_entry_snapshot_with_links(transaction, transaction->target_name, + (nlink_t)1, &target_remains, + &absent_identity) && !target_remains; #else return false; @@ -1835,33 +1683,28 @@ static cbm_activation_transaction_status_t activation_rollback_internal( if (transaction->target_existed) { bool backup_exists = false; if (!transaction->backup_contains_target || - !activation_path_matches( - transaction, transaction->backup_path, - transaction->backup_name, &transaction->backup_identity, - &backup_exists) || + !activation_path_matches(transaction, transaction->backup_path, + transaction->backup_name, &transaction->backup_identity, + &backup_exists) || !backup_exists) { transaction->state = ACTIVATION_RECOVERY_NEEDED; return CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; } bool target_exists = false; activation_file_identity_t current_target; - if (!activation_entry_snapshot( - transaction, transaction->target_path, - transaction->target_name, &target_exists, ¤t_target)) { + if (!activation_entry_snapshot(transaction, transaction->target_path, + transaction->target_name, &target_exists, ¤t_target)) { transaction->state = ACTIVATION_RECOVERY_NEEDED; return CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; } if (target_exists && (transaction->action != ACTIVATION_REPLACE || - !activation_identity_equal(¤t_target, - &transaction->staged_identity))) { + !activation_identity_equal(¤t_target, &transaction->staged_identity))) { transaction->state = ACTIVATION_RECOVERY_NEEDED; return CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; } - if (!activation_rename( - transaction, transaction->backup_path, - transaction->backup_name, transaction->target_path, - transaction->target_name, target_exists)) { + if (!activation_rename(transaction, transaction->backup_path, transaction->backup_name, + transaction->target_path, transaction->target_name, target_exists)) { transaction->state = ACTIVATION_RECOVERY_NEEDED; return CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; } @@ -1870,26 +1713,22 @@ static cbm_activation_transaction_status_t activation_rollback_internal( } else if (transaction->action == ACTIVATION_REPLACE) { bool target_exists = false; bool linked_pair = false; - if (!activation_absent_target_snapshot(transaction, &target_exists, - &linked_pair)) { + if (!activation_absent_target_snapshot(transaction, &target_exists, &linked_pair)) { transaction->state = ACTIVATION_RECOVERY_NEEDED; return CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; } - if (target_exists && !activation_remove_absent_published_target( - transaction, linked_pair)) { + if (target_exists && !activation_remove_absent_published_target(transaction, linked_pair)) { transaction->state = ACTIVATION_RECOVERY_NEEDED; return CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; } } transaction->state = ACTIVATION_ROLLED_BACK; - return activation_sync_directory(transaction) - ? CBM_ACTIVATION_TRANSACTION_OK - : CBM_ACTIVATION_TRANSACTION_IO; + return activation_sync_directory(transaction) ? CBM_ACTIVATION_TRANSACTION_OK + : CBM_ACTIVATION_TRANSACTION_IO; } cbm_activation_transaction_status_t cbm_activation_transaction_commit( - cbm_activation_transaction_t *transaction, - cbm_activation_transaction_validator_fn validator, + cbm_activation_transaction_t *transaction, cbm_activation_transaction_validator_fn validator, void *validator_context) { if (!transaction) { return CBM_ACTIVATION_TRANSACTION_INVALID_ARGUMENT; @@ -1903,10 +1742,9 @@ cbm_activation_transaction_status_t cbm_activation_transaction_commit( if (transaction->action == ACTIVATION_REPLACE) { bool staged_exists = false; if (!transaction->staged_exists || - !activation_path_matches( - transaction, transaction->staged_path, - transaction->staged_name, &transaction->staged_identity, - &staged_exists) || + !activation_path_matches(transaction, transaction->staged_path, + transaction->staged_name, &transaction->staged_identity, + &staged_exists) || !staged_exists) { return CBM_ACTIVATION_TRANSACTION_IO; } @@ -1929,30 +1767,24 @@ cbm_activation_transaction_status_t cbm_activation_transaction_commit( } else { bool reservation_exists = false; if (!transaction->backup_exists || - !activation_path_matches( - transaction, transaction->backup_path, - transaction->backup_name, &transaction->backup_identity, - &reservation_exists) || + !activation_path_matches(transaction, transaction->backup_path, + transaction->backup_name, &transaction->backup_identity, + &reservation_exists) || !reservation_exists || - !activation_rename( - transaction, transaction->target_path, - transaction->target_name, transaction->backup_path, - transaction->backup_name, true)) { + !activation_rename(transaction, transaction->target_path, transaction->target_name, + transaction->backup_path, transaction->backup_name, true)) { return CBM_ACTIVATION_TRANSACTION_IO; } transaction->backup_identity = transaction->target_identity; transaction->backup_contains_target = true; } } - if (transaction->action == ACTIVATION_REPLACE && - !transaction->target_existed) { + if (transaction->action == ACTIVATION_REPLACE && !transaction->target_existed) { if (activation_before_absent_publish_for_test) { activation_before_absent_publish_for_test( - transaction->target_path, - activation_before_absent_publish_context_for_test); + transaction->target_path, activation_before_absent_publish_context_for_test); } - activation_publish_status_t published = - activation_publish_absent_replacement(transaction); + activation_publish_status_t published = activation_publish_absent_replacement(transaction); if (published == ACTIVATION_PUBLISH_UNCHANGED_ERROR) { return CBM_ACTIVATION_TRANSACTION_IO; } @@ -1966,17 +1798,14 @@ cbm_activation_transaction_status_t cbm_activation_transaction_commit( } } transaction->state = ACTIVATION_COMMITTED; - if (!activation_directory_still_valid(transaction) || - !activation_sync_directory(transaction)) { - cbm_activation_transaction_status_t restored = - activation_rollback_internal(transaction); + if (!activation_directory_still_valid(transaction) || !activation_sync_directory(transaction)) { + cbm_activation_transaction_status_t restored = activation_rollback_internal(transaction); return restored == CBM_ACTIVATION_TRANSACTION_OK ? CBM_ACTIVATION_TRANSACTION_IO : CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; } if (validator && !validator(transaction->target_path, validator_context)) { - cbm_activation_transaction_status_t restored = - activation_rollback_internal(transaction); + cbm_activation_transaction_status_t restored = activation_rollback_internal(transaction); return restored == CBM_ACTIVATION_TRANSACTION_OK ? CBM_ACTIVATION_TRANSACTION_VALIDATION_FAILED : CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED; @@ -2037,16 +1866,13 @@ cbm_activation_transaction_status_t cbm_activation_transaction_close( cbm_activation_transaction_t *transaction = *transaction_io; if (transaction->state == ACTIVATION_COMMITTED || transaction->state == ACTIVATION_RECOVERY_NEEDED) { - cbm_activation_transaction_status_t status = - activation_rollback_internal(transaction); + cbm_activation_transaction_status_t status = activation_rollback_internal(transaction); if (status != CBM_ACTIVATION_TRANSACTION_OK) { return status; } } - if (transaction->state == ACTIVATION_STAGED || - transaction->state == ACTIVATION_ROLLED_BACK) { - cbm_activation_transaction_status_t status = - activation_discard_staged_assets(transaction); + if (transaction->state == ACTIVATION_STAGED || transaction->state == ACTIVATION_ROLLED_BACK) { + cbm_activation_transaction_status_t status = activation_discard_staged_assets(transaction); if (status != CBM_ACTIVATION_TRANSACTION_OK) { return status; } @@ -2073,13 +1899,10 @@ const char *cbm_activation_transaction_backup_path( const char *cbm_activation_transaction_deferred_path( const cbm_activation_transaction_t *transaction) { - return transaction && transaction->deferred_cleanup - ? transaction->backup_path - : NULL; + return transaction && transaction->deferred_cleanup ? transaction->backup_path : NULL; } -const char *cbm_activation_transaction_status_message( - cbm_activation_transaction_status_t status) { +const char *cbm_activation_transaction_status_message(cbm_activation_transaction_status_t status) { switch (status) { case CBM_ACTIVATION_TRANSACTION_OK: return "activation transaction completed"; diff --git a/src/cli/activation_transaction.h b/src/cli/activation_transaction.h index 9d5c264f1..028b80a66 100644 --- a/src/cli/activation_transaction.h +++ b/src/cli/activation_transaction.h @@ -29,8 +29,7 @@ typedef enum { CBM_ACTIVATION_TRANSACTION_ROLLBACK_FAILED = -6, } cbm_activation_transaction_status_t; -typedef bool (*cbm_activation_transaction_validator_fn)( - const char *target_path, void *context); +typedef bool (*cbm_activation_transaction_validator_fn)(const char *target_path, void *context); /* Test-only seam: invoked after an absent target has been revalidated and * immediately before its staged candidate is published. Production callers @@ -38,8 +37,7 @@ typedef bool (*cbm_activation_transaction_validator_fn)( typedef void (*cbm_activation_transaction_before_absent_publish_for_test_fn)( const char *target_path, void *context); void cbm_activation_transaction_set_before_absent_publish_for_test( - cbm_activation_transaction_before_absent_publish_for_test_fn hook, - void *context); + cbm_activation_transaction_before_absent_publish_for_test_fn hook, void *context); /* Stage a candidate beside target_path (therefore on the same filesystem). * The staged file is private to the current account and executable. */ @@ -54,15 +52,14 @@ cbm_activation_transaction_status_t cbm_activation_transaction_stage_file( /* Prepare an atomic removal. A missing target is a valid no-op transaction. */ cbm_activation_transaction_status_t cbm_activation_transaction_stage_removal( - const char *target_path, - cbm_activation_transaction_t **transaction_out); + const char *target_path, cbm_activation_transaction_t **transaction_out); /* Atomically publish the candidate (or remove the target), retaining any old * target at backup_path. If validator rejects the post-commit state, this * function rolls back before returning VALIDATION_FAILED. */ cbm_activation_transaction_status_t cbm_activation_transaction_commit( - cbm_activation_transaction_t *transaction, - cbm_activation_transaction_validator_fn validator, void *validator_context); + cbm_activation_transaction_t *transaction, cbm_activation_transaction_validator_fn validator, + void *validator_context); /* Restore the retained target after a successful commit. */ cbm_activation_transaction_status_t cbm_activation_transaction_rollback( @@ -80,16 +77,12 @@ cbm_activation_transaction_status_t cbm_activation_transaction_finalize( cbm_activation_transaction_status_t cbm_activation_transaction_close( cbm_activation_transaction_t **transaction_io); -const char *cbm_activation_transaction_target_path( - const cbm_activation_transaction_t *transaction); -const char *cbm_activation_transaction_staged_path( - const cbm_activation_transaction_t *transaction); -const char *cbm_activation_transaction_backup_path( - const cbm_activation_transaction_t *transaction); +const char *cbm_activation_transaction_target_path(const cbm_activation_transaction_t *transaction); +const char *cbm_activation_transaction_staged_path(const cbm_activation_transaction_t *transaction); +const char *cbm_activation_transaction_backup_path(const cbm_activation_transaction_t *transaction); const char *cbm_activation_transaction_deferred_path( const cbm_activation_transaction_t *transaction); -const char *cbm_activation_transaction_status_message( - cbm_activation_transaction_status_t status); +const char *cbm_activation_transaction_status_message(cbm_activation_transaction_status_t status); #endif /* CBM_ACTIVATION_TRANSACTION_H */ diff --git a/src/cli/cli.c b/src/cli/cli.c index 4cb673fc4..9d351beee 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -12,6 +12,7 @@ #include "cli/config_text_edit.h" #include "cli/config_toml_edit.h" #include "cli/config_yaml_edit.h" +#include "cli/windows_launcher_state.h" #include "daemon/bootstrap.h" #include "daemon/ipc.h" #include "daemon/runtime.h" @@ -22,6 +23,7 @@ #include "foundation/log.h" #include "foundation/sha256.h" #include "mcp/mcp.h" // cbm_mcp_tool_input_schema — CLI flag parser + per-tool --help +#include "mcp/index_supervisor.h" /* CLI buffer size constants. */ enum { @@ -108,9 +110,10 @@ static int cbm_powershell_quote_word(const char *value, char *out, size_t out_si #include // mode_t, S_IXUSR #include #include -#include // MAX_WBITS +#include // MAX_WBITS #ifdef _WIN32 #include +#include "foundation/win_utf8.h" #endif /* yyjson for JSON read-modify-write */ @@ -141,14 +144,16 @@ bool cbm_cli_mcp_result_is_error(const char *result) { } yyjson_doc *document = yyjson_read(result, strlen(result), 0); yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; - yyjson_val *error = yyjson_is_obj(root) - ? yyjson_obj_get(root, "isError") - : NULL; + yyjson_val *error = yyjson_is_obj(root) ? yyjson_obj_get(root, "isError") : NULL; bool is_error = yyjson_is_bool(error) && yyjson_get_bool(error); yyjson_doc_free(document); return is_error; } +int cbm_cli_exit_status_after_maintenance(int exit_status, bool maintenance_cancelled) { + return maintenance_cancelled && exit_status == EXIT_SUCCESS ? EXIT_FAILURE : exit_status; +} + static const char CLI_ACTIVATION_REFUSED_MESSAGE[] = "error: active CBM sessions and operations could not be stopped safely; " "no activation was committed."; @@ -169,6 +174,9 @@ typedef struct { cbm_daemon_ipc_startup_lock_t *startup_lock; cbm_daemon_build_identity_t identity; char source_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + char canonical_cache[CLI_BUF_4K]; + char cache_fingerprint[CBM_SHA256_HEX_LEN + 1]; + char *original_cache_environment; cbm_daemon_runtime_activation_action_t action; cbm_daemon_runtime_activation_result_t daemon_result; uint64_t deadline_ms; @@ -179,14 +187,170 @@ typedef struct { bool shutdown_requested; bool mutation_authorized; bool cleanup_ok; + bool original_cache_environment_present; + bool cache_environment_overridden; } cli_activation_production_context_t; 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 void cli_activation_diagnostic(const cbm_cli_activation_ops_t *ops, - const char *message) { +#ifdef _WIN32 +static cbm_windows_launcher_context_t g_windows_launcher_context; +static bool cli_windows_current_path(const wchar_t *canonical_launcher, + wchar_t out[CBM_WINDOWS_LAUNCHER_PATH_CAP]); + +static bool cli_windows_current_process_size(uint64_t *size_out) { + if (!size_out) { + return false; + } + wchar_t path[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + DWORD length = GetModuleFileNameW(NULL, path, (DWORD)(sizeof(path) / sizeof(path[0]))); + if (length == 0 || length >= (DWORD)(sizeof(path) / sizeof(path[0]))) { + return false; + } + WIN32_FILE_ATTRIBUTE_DATA attributes; + if (!GetFileAttributesExW(path, GetFileExInfoStandard, &attributes)) { + return false; + } + *size_out = ((uint64_t)attributes.nFileSizeHigh << 32U) | (uint64_t)attributes.nFileSizeLow; + return *size_out > 0; +} + +int cbm_cli_windows_payload_descriptor_role(int argc, char *const argv[]) { + static const char role[] = "__cbm_windows_payload_descriptor_v1"; + if (argc != 3 || !argv || !argv[1] || strcmp(argv[1], role) != 0) { + return -1; + } + if (!argv[2] || !argv[2][0]) + return CLI_TRUE; + uint64_t launcher_abi = 0U; + for (const char *cursor = argv[2]; *cursor; cursor++) { + if (*cursor < '0' || *cursor > '9' || + launcher_abi > (UINT32_MAX - (uint64_t)(*cursor - '0')) / 10U) { + return CLI_TRUE; + } + launcher_abi = launcher_abi * 10U + (uint64_t)(*cursor - '0'); + } + uint64_t payload_size = 0U; + bool fingerprint_ready = launcher_abi > 0U && cbm_index_supervisor_capture_build_fingerprint(); + const char *fingerprint = fingerprint_ready ? cbm_index_supervisor_build_fingerprint() : NULL; + cbm_windows_release_descriptor_v1_t descriptor = { + .launcher_abi = (uint32_t)launcher_abi, + .payload_launcher_abi_min = CBM_WINDOWS_PAYLOAD_LAUNCHER_ABI_MIN, + .payload_launcher_abi_max = CBM_WINDOWS_PAYLOAD_LAUNCHER_ABI_MAX, + }; + descriptor.payload_size = cli_windows_current_process_size(&payload_size) ? payload_size : 0U; + if (fingerprint) { + (void)snprintf(descriptor.payload_sha256, sizeof(descriptor.payload_sha256), "%s", + fingerprint); + } + uint8_t record[CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE]; + /* stdout is a pipe for this private binary protocol, but the Windows CRT + * still defaults it to text mode. A 0x0a byte in payload_size must remain + * one byte rather than being expanded to CRLF. */ + bool binary_stdout = _setmode(_fileno(stdout), _O_BINARY) != -1; + bool descriptor_written = + binary_stdout && cbm_windows_release_descriptor_v1_encode(&descriptor, record) && + fwrite(record, 1U, sizeof(record), stdout) == sizeof(record) && fflush(stdout) == 0; + return descriptor_written ? CLI_OK : CLI_TRUE; +} + +int cbm_cli_windows_launcher_startup_authenticate(int argc, char *const argv[]) { + cbm_windows_launcher_context_t context; + char error[CLI_BUF_512] = {0}; + if (!cbm_windows_launcher_context_consume(&context, error, sizeof(error))) { + (void)fprintf(stderr, "codebase-memory-mcp: untrusted Windows launcher context: %s\n", + error[0] ? error : "authentication failed"); + return CLI_TRUE; + } + + cbm_windows_launcher_action_t action = + cbm_windows_launcher_classify_action(argc, (const char *const *)argv); + if (context.present && context.action != action) { + (void)cbm_windows_launcher_context_complete(&context, false, error, sizeof(error)); + (void)fprintf(stderr, "codebase-memory-mcp: Windows launcher action did not match the " + "payload command\n"); + return CLI_TRUE; + } + if (!context.present) { + if (!cbm_windows_launcher_context_complete(&context, true, error, sizeof(error))) { + (void)fprintf(stderr, + "codebase-memory-mcp: Windows launcher completion failed: " + "%s\n", + error[0] ? error : "authority acknowledgement failed"); + return CLI_TRUE; + } + memset(&g_windows_launcher_context, 0, sizeof(g_windows_launcher_context)); + return CLI_OK; + } + + bool mutation = action == CBM_WINDOWS_LAUNCHER_ACTION_UPDATE || + action == CBM_WINDOWS_LAUNCHER_ACTION_UNINSTALL; + if (!cbm_windows_launcher_action_allowed(action, context.managed) || + context.private_activation != mutation) { + (void)cbm_windows_launcher_context_complete(&context, false, error, sizeof(error)); + (void)fprintf(stderr, "codebase-memory-mcp: Windows launcher refused an invalid " + "managed activation context\n"); + return CLI_TRUE; + } + + if (context.managed) { + uint64_t process_size = 0; + bool fingerprint_ok = cbm_index_supervisor_capture_build_fingerprint(); + const char *fingerprint = fingerprint_ok ? cbm_index_supervisor_build_fingerprint() : NULL; + if (!fingerprint || context.payload_size == 0 || + !cli_windows_current_process_size(&process_size) || + process_size != context.payload_size || + strcmp(fingerprint, context.expected_payload_sha256) != 0 || + context.canonical_launcher_path[0] == L'\0') { + (void)cbm_windows_launcher_context_complete(&context, false, error, sizeof(error)); + (void)fprintf(stderr, "codebase-memory-mcp: managed Windows payload identity did " + "not match current-v1; startup refused\n"); + return CLI_TRUE; + } + } + + if (!cbm_windows_launcher_context_complete(&context, true, error, sizeof(error))) { + (void)fprintf(stderr, "codebase-memory-mcp: Windows launcher completion failed: %s\n", + error[0] ? error : "authority acknowledgement failed"); + return CLI_TRUE; + } + g_windows_launcher_context = context; + return CLI_OK; +} + +static bool cli_windows_require_managed_mutation(cbm_windows_launcher_action_t action) { + if (g_windows_launcher_context.present && g_windows_launcher_context.managed && + g_windows_launcher_context.private_activation && + g_windows_launcher_context.action == action) { + return true; + } + const char *operation = + action == CBM_WINDOWS_LAUNCHER_ACTION_UNINSTALL ? "uninstall" : "update"; + (void)fprintf(stderr, + "error: portable Windows payloads cannot self-%s. Use your package " + "manager to replace/remove this portable copy, or run " + "\"codebase-memory-mcp.payload.exe install\" once to create a " + "managed launcher installation.\n", + operation); + return false; +} +#else +int cbm_cli_windows_payload_descriptor_role(int argc, char *const argv[]) { + (void)argc; + (void)argv; + return -1; +} + +int cbm_cli_windows_launcher_startup_authenticate(int argc, char *const argv[]) { + (void)argc; + (void)argv; + return CLI_OK; +} +#endif + +static void cli_activation_diagnostic(const cbm_cli_activation_ops_t *ops, const char *message) { const char *diagnostic = message ? message : CLI_ACTIVATION_REFUSED_MESSAGE; if (ops && ops->visible_diagnostic) { ops->visible_diagnostic(ops->context, diagnostic); @@ -198,15 +362,13 @@ static void cli_activation_diagnostic(const cbm_cli_activation_ops_t *ops, int cbm_cli_activation_guard_with_ops(const cbm_cli_activation_ops_t *ops, cbm_cli_activation_mutation_fn mutation, void *mutation_context) { - if (!ops || !ops->reserve_for_mutation || - !ops->mutation_lease_release) { + if (!ops || !ops->reserve_for_mutation || !ops->mutation_lease_release) { cli_activation_diagnostic(ops, CLI_ACTIVATION_REFUSED_MESSAGE); return CLI_TRUE; } cbm_cli_activation_lock_t mutation_lease = NULL; - int reserve_status = - ops->reserve_for_mutation(ops->context, &mutation_lease); + int reserve_status = ops->reserve_for_mutation(ops->context, &mutation_lease); if (reserve_status != 1 || !mutation_lease) { /* A failed reservation must not normally return authority, but the * boundary is injectable and production can surface cleanup-only @@ -221,10 +383,9 @@ int cbm_cli_activation_guard_with_ops(const cbm_cli_activation_ops_t *ops, int rc = mutation ? mutation(mutation_context) : CLI_OK; ops->mutation_lease_release(ops->context, mutation_lease); if (rc != CLI_OK) { - cli_activation_diagnostic( - ops, rc == CLI_ACTIVATION_PARTIAL - ? CLI_ACTIVATION_PARTIAL_MESSAGE - : CLI_ACTIVATION_MUTATION_FAILED_MESSAGE); + cli_activation_diagnostic(ops, rc == CLI_ACTIVATION_PARTIAL + ? CLI_ACTIVATION_PARTIAL_MESSAGE + : CLI_ACTIVATION_MUTATION_FAILED_MESSAGE); } return rc; } @@ -239,13 +400,11 @@ void cbm_cli_set_activation_ops_for_test(const cbm_cli_activation_ops_t *ops) { g_cli_activation_test_ops_set = true; } -void cbm_cli_set_activation_runtime_parent_for_test( - const char *runtime_parent) { +void cbm_cli_set_activation_runtime_parent_for_test(const char *runtime_parent) { g_cli_activation_runtime_parent_for_test = runtime_parent; } -static const char *cli_activation_action_text( - cbm_daemon_runtime_activation_action_t action) { +static const char *cli_activation_action_text(cbm_daemon_runtime_activation_action_t action) { switch (action) { case CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL: return "install"; @@ -274,9 +433,8 @@ static uint64_t cli_activation_process_id(void) { #endif } -static bool cli_activation_log_event( - cli_activation_production_context_t *context, const char *phase, - const char *detail) { +static bool cli_activation_log_event(cli_activation_production_context_t *context, + const char *phase, const char *detail) { if (!context || !context->activation_log || !phase) { return false; } @@ -290,45 +448,35 @@ static bool cli_activation_log_event( bool encoded = yyjson_mut_obj_add_str(document, root, "event", "cbm.activation") && yyjson_mut_obj_add_strcpy(document, root, "phase", phase) && - yyjson_mut_obj_add_strcpy( - document, root, "action", - cli_activation_action_text(context->action)) && + yyjson_mut_obj_add_strcpy(document, root, "action", + cli_activation_action_text(context->action)) && yyjson_mut_obj_add_int(document, root, "requester_pid", (int64_t)cli_activation_process_id()) && - yyjson_mut_obj_add_int(document, root, "timestamp_unix_s", - (int64_t)time(NULL)) && - yyjson_mut_obj_add_str(document, root, "source_version", - CBM_VERSION) && + yyjson_mut_obj_add_int(document, root, "timestamp_unix_s", (int64_t)time(NULL)) && + yyjson_mut_obj_add_str(document, root, "source_version", CBM_VERSION) && yyjson_mut_obj_add_strcpy( document, root, "source_build", - context->identity.build_fingerprint - ? context->identity.build_fingerprint - : "") && - yyjson_mut_obj_add_int( - document, root, "daemon_active_clients", - (int64_t)context->daemon_result.active_clients) && - yyjson_mut_obj_add_int( - document, root, "daemon_active_connections", - (int64_t)context->daemon_result.active_connections) && + context->identity.build_fingerprint ? context->identity.build_fingerprint : "") && + yyjson_mut_obj_add_int(document, root, "daemon_active_clients", + (int64_t)context->daemon_result.active_clients) && + yyjson_mut_obj_add_int(document, root, "daemon_active_connections", + (int64_t)context->daemon_result.active_connections) && yyjson_mut_obj_add_bool(document, root, "restart_required", true); if (encoded && context->target_version && context->target_version[0]) { - encoded = yyjson_mut_obj_add_strcpy( - document, root, "target_version", context->target_version); + encoded = + yyjson_mut_obj_add_strcpy(document, root, "target_version", context->target_version); } if (encoded && context->target_build && context->target_build[0]) { - encoded = yyjson_mut_obj_add_strcpy( - document, root, "target_build", context->target_build); + encoded = yyjson_mut_obj_add_strcpy(document, root, "target_build", context->target_build); } if (encoded && detail && detail[0]) { encoded = yyjson_mut_obj_add_strcpy(document, root, "detail", detail); } size_t json_size = 0; char *json = encoded ? yyjson_mut_write(document, 0, &json_size) : NULL; - bool written = json && json_size > 0 && - fwrite(json, 1, json_size, context->activation_log) == - json_size && - fputc('\n', context->activation_log) != EOF && - fflush(context->activation_log) == 0; + bool written = + json && json_size > 0 && fwrite(json, 1, json_size, context->activation_log) == json_size && + fputc('\n', context->activation_log) != EOF && fflush(context->activation_log) == 0; if (written) { int descriptor = cbm_fileno(context->activation_log); #ifdef _WIN32 @@ -342,8 +490,7 @@ static bool cli_activation_log_event( return written; } -static int cli_activation_startup_lock_acquire( - cli_activation_production_context_t *context) { +static int cli_activation_startup_lock_acquire(cli_activation_production_context_t *context) { if (!context || !context->endpoint) { return CLI_ERR; } @@ -352,8 +499,7 @@ static int cli_activation_startup_lock_acquire( } do { cbm_daemon_ipc_startup_lock_t *lock = NULL; - int status = cbm_daemon_ipc_startup_lock_try_acquire( - context->endpoint, &lock); + int status = cbm_daemon_ipc_startup_lock_try_acquire(context->endpoint, &lock); if (status == 1 && lock) { context->startup_lock = lock; return 1; @@ -366,15 +512,14 @@ static int cli_activation_startup_lock_acquire( return 0; } -static _Noreturn void cli_activation_cleanup_fail_stop( - cli_activation_production_context_t *context, const char *component) { +static _Noreturn void cli_activation_cleanup_fail_stop(cli_activation_production_context_t *context, + const char *component) { if (context) { (void)cli_activation_log_event( context, "failed", "coordination cleanup timed out; process exit releases retained claims"); } - cbm_log_error("coordination.cleanup_timeout", "component", component, - "action", "process_exit"); + cbm_log_error("coordination.cleanup_timeout", "component", component, "action", "process_exit"); (void)fflush(stdout); (void)fflush(stderr); _Exit(EXIT_FAILURE); @@ -382,17 +527,14 @@ static _Noreturn void cli_activation_cleanup_fail_stop( static void cli_activation_startup_lock_release_complete( cli_activation_production_context_t *context) { - uint64_t deadline = - cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + uint64_t deadline = cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); while (context && context->startup_lock) { - (void)cbm_daemon_ipc_startup_lock_release( - &context->startup_lock); + (void)cbm_daemon_ipc_startup_lock_release(&context->startup_lock); if (!context->startup_lock) { return; } if (cbm_now_ms() >= deadline) { - cli_activation_cleanup_fail_stop(context, - "startup_lock_cleanup"); + cli_activation_cleanup_fail_stop(context, "startup_lock_cleanup"); } cbm_usleep(CLI_ACTIVATION_RETRY_US); } @@ -405,8 +547,7 @@ static uint32_t cli_activation_remaining_timeout( return 1U; } uint64_t remaining = context->control_deadline_ms - now; - return remaining >= UINT32_MAX ? UINT32_MAX - 1U - : (uint32_t)remaining; + return remaining >= UINT32_MAX ? UINT32_MAX - 1U : (uint32_t)remaining; } static uint64_t cli_activation_count_add(uint64_t left, uint64_t right) { @@ -420,16 +561,14 @@ static void cli_activation_merge_daemon_result( return; } context->daemon_result.accepted = true; - context->daemon_result.active_clients = cli_activation_count_add( - context->daemon_result.active_clients, result->active_clients); + context->daemon_result.active_clients = + cli_activation_count_add(context->daemon_result.active_clients, result->active_clients); context->daemon_result.active_connections = cli_activation_count_add( - context->daemon_result.active_connections, - result->active_connections); + context->daemon_result.active_connections, result->active_connections); context->shutdown_requested = true; } -static cbm_version_cohort_quiesce_result_t -cli_activation_request_quiescence(void *opaque) { +static cbm_version_cohort_quiesce_result_t cli_activation_request_quiescence(void *opaque) { cli_activation_production_context_t *context = opaque; if (!context || !context->endpoint) { return CBM_VERSION_COHORT_QUIESCE_ERROR; @@ -441,10 +580,8 @@ cli_activation_request_quiescence(void *opaque) { * order and time out both sides. OP8 is safe to attempt directly. An * absent endpoint or lost ACK is not mutation authority: the finite * lifetime-EX wait below remains the authoritative drain proof. */ - uint64_t control = - cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); - context->control_deadline_ms = - control < context->deadline_ms ? control : context->deadline_ms; + uint64_t control = cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + context->control_deadline_ms = control < context->deadline_ms ? control : context->deadline_ms; cbm_daemon_runtime_activation_result_t result = {0}; if (cbm_daemon_runtime_request_activation_shutdown( context->endpoint, &context->identity, context->action, @@ -455,14 +592,11 @@ cli_activation_request_quiescence(void *opaque) { return CBM_VERSION_COHORT_QUIESCE_REQUESTED; } -static void cli_activation_release_cleanup_lease( - cli_activation_production_context_t *context, - cbm_version_cohort_lease_t **lease_io) { - uint64_t cleanup_deadline = - cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); +static void cli_activation_release_cleanup_lease(cli_activation_production_context_t *context, + cbm_version_cohort_lease_t **lease_io) { + uint64_t cleanup_deadline = cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); while (lease_io && *lease_io && cbm_now_ms() < cleanup_deadline) { - if (cbm_version_cohort_lease_release(lease_io) == - CBM_PRIVATE_FILE_LOCK_OK) { + if (cbm_version_cohort_lease_release(lease_io) == CBM_PRIVATE_FILE_LOCK_OK) { return; } cbm_usleep(CLI_ACTIVATION_RETRY_US); @@ -472,8 +606,7 @@ static void cli_activation_release_cleanup_lease( } } -static int cli_activation_production_reserve( - void *opaque, cbm_cli_activation_lock_t *lease_out) { +static int cli_activation_production_reserve(void *opaque, cbm_cli_activation_lock_t *lease_out) { cli_activation_production_context_t *context = opaque; if (lease_out) { *lease_out = NULL; @@ -481,11 +614,9 @@ static int cli_activation_production_reserve( if (!context || !context->cohort_manager || !lease_out) { return CLI_ERR; } - cbm_version_cohort_quiesce_result_t quiesce = - CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + cbm_version_cohort_quiesce_result_t quiesce = CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; cbm_version_cohort_lease_t *lease = NULL; - context->control_deadline_ms = - cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + context->control_deadline_ms = cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); /* Ask the current daemon to snapshot and stop its sessions before the * maintenance marker wakes thin frontends. Otherwise cooperative clients @@ -501,10 +632,9 @@ static int cli_activation_production_reserve( cli_activation_merge_daemon_result(context, &eager_result); } - cbm_version_cohort_status_t status = - cbm_version_cohort_reserve_for_mutation( - context->cohort_manager, context->deadline_ms, - cli_activation_request_quiescence, context, &quiesce, &lease); + cbm_version_cohort_status_t status = cbm_version_cohort_reserve_for_mutation( + context->cohort_manager, context->deadline_ms, cli_activation_request_quiescence, context, + &quiesce, &lease); if (status != CBM_VERSION_COHORT_OK || !lease) { cli_activation_release_cleanup_lease(context, &lease); context->cohort_lease = lease; @@ -514,16 +644,14 @@ static int cli_activation_production_reserve( /* Quiescence never touches startup. Only after * maintenance+admission+lifetime are all held EX may activation acquire * startup in the final global order and retain it through mutation. */ - context->control_deadline_ms = - cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); - if (!context->startup_lock && - cli_activation_startup_lock_acquire(context) != 1) { + context->control_deadline_ms = cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + if (!context->startup_lock && cli_activation_startup_lock_acquire(context) != 1) { cli_activation_release_cleanup_lease(context, &lease); context->cohort_lease = lease; return CLI_ERR; } - int generation = cbm_daemon_ipc_generation_probe_under_startup_lock( - context->endpoint, context->startup_lock); + int generation = cbm_daemon_ipc_generation_probe_under_startup_lock(context->endpoint, + context->startup_lock); if (generation != 0) { cli_activation_startup_lock_release_complete(context); cli_activation_release_cleanup_lease(context, &lease); @@ -532,10 +660,9 @@ static int cli_activation_production_reserve( } context->cohort_lease = lease; - if (!cli_activation_log_event( - context, "daemon_stopped", - context->shutdown_requested ? "cohort drained" - : "no active cohort")) { + if (!cli_activation_log_event(context, "daemon_stopped", + context->shutdown_requested ? "cohort drained" + : "no active cohort")) { cli_activation_startup_lock_release_complete(context); cli_activation_release_cleanup_lease(context, &lease); context->cohort_lease = lease; @@ -546,8 +673,7 @@ static int cli_activation_production_reserve( return 1; } -static void cli_activation_production_release( - void *opaque, cbm_cli_activation_lock_t lease) { +static void cli_activation_production_release(void *opaque, cbm_cli_activation_lock_t lease) { cli_activation_production_context_t *context = opaque; if (!context) { return; @@ -557,8 +683,7 @@ static void cli_activation_production_release( if (context->startup_lock) { cli_activation_startup_lock_release_complete(context); } - cbm_version_cohort_lease_t *cohort_lease = - (cbm_version_cohort_lease_t *)lease; + cbm_version_cohort_lease_t *cohort_lease = (cbm_version_cohort_lease_t *)lease; if (cohort_lease != context->cohort_lease) { context->cleanup_ok = false; return; @@ -567,66 +692,89 @@ static void cli_activation_production_release( context->cohort_lease = cohort_lease; } -static void cli_activation_production_diagnostic(void *opaque, - const char *message) { +static void cli_activation_production_diagnostic(void *opaque, const char *message) { cli_activation_production_context_t *context = opaque; if (context && context->mutation_authorized) { - (void)fprintf(stderr, "%s\n", - message ? message - : CLI_ACTIVATION_MUTATION_FAILED_MESSAGE); + (void)fprintf(stderr, "%s\n", message ? message : CLI_ACTIVATION_MUTATION_FAILED_MESSAGE); return; } - (void)fprintf(stderr, "%s\n", - message ? message : CLI_ACTIVATION_REFUSED_MESSAGE); + (void)fprintf(stderr, "%s\n", message ? message : CLI_ACTIVATION_REFUSED_MESSAGE); } -static bool cli_activation_production_context_init( - cli_activation_production_context_t *context, - cbm_daemon_runtime_activation_action_t action, - const char *target_version, const char *target_build) { +static bool cli_activation_production_context_init(cli_activation_production_context_t *context, + cbm_daemon_runtime_activation_action_t action, + const char *target_version, + const char *target_build) { memset(context, 0, sizeof(*context)); context->action = action; context->target_version = target_version; context->target_build = target_build; context->cleanup_ok = true; - context->deadline_ms = - cli_activation_deadline_after(CLI_ACTIVATION_DRAIN_TIMEOUT_MS); - context->control_deadline_ms = - cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); - context->endpoint = cbm_daemon_bootstrap_endpoint_new( - g_cli_activation_runtime_parent_for_test); - context->cohort_manager = context->endpoint - ? cbm_version_cohort_manager_new( - context->endpoint) - : NULL; - if (!context->endpoint || !context->cohort_manager || - !cbm_daemon_runtime_process_build_fingerprint( - cli_activation_process_id(), context->source_build)) { + context->deadline_ms = cli_activation_deadline_after(CLI_ACTIVATION_DRAIN_TIMEOUT_MS); + context->control_deadline_ms = cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + const char *original_cache_environment = getenv("CBM_CACHE_DIR"); + context->original_cache_environment_present = original_cache_environment != NULL; + if (context->original_cache_environment_present) { + context->original_cache_environment = strdup(original_cache_environment); + if (!context->original_cache_environment) { + return false; + } + } + const char *requested_cache = cbm_resolve_cache_dir(); + bool cache_ready = requested_cache && requested_cache[0] && + cbm_canonical_path(requested_cache, context->canonical_cache, + sizeof(context->canonical_cache)); + if (!cache_ready && requested_cache && requested_cache[0] && + cbm_mkdir_p(requested_cache, 0700)) { + cache_ready = cbm_canonical_path(requested_cache, context->canonical_cache, + sizeof(context->canonical_cache)); + } + if (!cache_ready || !cbm_is_dir(context->canonical_cache) || + !cbm_daemon_ipc_private_directory_secure(context->canonical_cache)) { + return false; + } + cbm_normalize_path_sep(context->canonical_cache); + if (cbm_setenv("CBM_CACHE_DIR", context->canonical_cache, 1) != 0) { + return false; + } + context->cache_environment_overridden = true; + cbm_sha256_hex(context->canonical_cache, strlen(context->canonical_cache), + context->cache_fingerprint); + context->endpoint = cbm_daemon_bootstrap_endpoint_new(g_cli_activation_runtime_parent_for_test); + context->cohort_manager = + context->endpoint ? cbm_version_cohort_manager_new(context->endpoint) : NULL; + const char *captured_build = cbm_index_supervisor_build_fingerprint(); + bool build_ready = captured_build && captured_build[0] + ? snprintf(context->source_build, sizeof(context->source_build), "%s", + captured_build) > 0 + : cbm_daemon_runtime_process_build_fingerprint( + cli_activation_process_id(), context->source_build); + /* Managed update/uninstall payload copies are POSIX-unlinked immediately + * after the authenticated startup handshake. Their process image can no + * longer be reopened by pathname here, so reuse the exact fingerprint + * captured and checked before that unlink. */ + if (!context->endpoint || !context->cohort_manager || !build_ready) { return false; } context->identity = (cbm_daemon_build_identity_t){ .semantic_version = CBM_VERSION, .build_fingerprint = context->source_build, + .cache_fingerprint = context->cache_fingerprint, .protocol_abi = CBM_DAEMON_RUNTIME_WIRE_ABI, .store_abi = 1, .feature_abi = 1, }; - const char *cache_dir = cbm_resolve_cache_dir(); - char log_dir[CLI_BUF_1K]; - int written = cache_dir - ? snprintf(log_dir, sizeof(log_dir), "%s/logs", cache_dir) - : CLI_ERR; + char log_dir[CLI_BUF_4K + CLI_BUF_16]; + int written = snprintf(log_dir, sizeof(log_dir), "%s/logs", context->canonical_cache); if (written <= 0 || (size_t)written >= sizeof(log_dir)) { return false; } - context->activation_log = cbm_daemon_ipc_private_log_open( - log_dir, "activation-events.ndjson", - CLI_ACTIVATION_LOG_CAP_BYTES); + context->activation_log = cbm_daemon_ipc_private_log_open(log_dir, "activation-events.ndjson", + CLI_ACTIVATION_LOG_CAP_BYTES); return context->activation_log != NULL; } -static void cli_activation_production_context_close( - cli_activation_production_context_t *context) { +static void cli_activation_production_context_close(cli_activation_production_context_t *context) { if (!context) { return; } @@ -634,11 +782,9 @@ static void cli_activation_production_context_close( cli_activation_startup_lock_release_complete(context); } cli_activation_release_cleanup_lease(context, &context->cohort_lease); - uint64_t manager_deadline = - cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); + uint64_t manager_deadline = cli_activation_deadline_after(CLI_ACTIVATION_CONTROL_TIMEOUT_MS); while (context->cohort_manager && cbm_now_ms() < manager_deadline) { - if (cbm_version_cohort_manager_free(&context->cohort_manager) == - CBM_PRIVATE_FILE_LOCK_OK) { + if (cbm_version_cohort_manager_free(&context->cohort_manager) == CBM_PRIVATE_FILE_LOCK_OK) { break; } cbm_usleep(CLI_ACTIVATION_RETRY_US); @@ -654,23 +800,32 @@ static void cli_activation_production_context_close( } cbm_daemon_ipc_endpoint_free(context->endpoint); context->endpoint = NULL; + if (context->cache_environment_overridden) { + int restore_result = + context->original_cache_environment_present + ? cbm_setenv("CBM_CACHE_DIR", context->original_cache_environment, 1) + : cbm_unsetenv("CBM_CACHE_DIR"); + if (restore_result != 0) { + context->cleanup_ok = false; + } + context->cache_environment_overridden = false; + } + free(context->original_cache_environment); + context->original_cache_environment = NULL; } -static int cli_activation_guard( - cbm_daemon_runtime_activation_action_t action, - const char *target_version, const char *target_build, - cbm_cli_activation_mutation_fn mutation, void *mutation_context) { +static int cli_activation_guard(cbm_daemon_runtime_activation_action_t action, + const char *target_version, const char *target_build, + cbm_cli_activation_mutation_fn mutation, void *mutation_context) { if (g_cli_activation_test_ops_set) { - return cbm_cli_activation_guard_with_ops( - &g_cli_activation_test_ops, mutation, mutation_context); + return cbm_cli_activation_guard_with_ops(&g_cli_activation_test_ops, mutation, + mutation_context); } cli_activation_production_context_t context; - if (!cli_activation_production_context_init( - &context, action, target_version, target_build)) { + if (!cli_activation_production_context_init(&context, action, target_version, target_build)) { cli_activation_production_context_close(&context); - cli_activation_production_diagnostic(NULL, - CLI_ACTIVATION_REFUSED_MESSAGE); + cli_activation_production_diagnostic(NULL, CLI_ACTIVATION_REFUSED_MESSAGE); return CLI_TRUE; } printf("Stopping active CBM sessions and operations for %s...\n", @@ -678,9 +833,8 @@ static int cli_activation_guard( (void)fflush(stdout); if (!cli_activation_log_event(&context, "requested", NULL)) { cli_activation_production_context_close(&context); - (void)fprintf(stderr, - "error: activation request could not be recorded safely; " - "no activation was committed.\n"); + (void)fprintf(stderr, "error: activation request could not be recorded safely; " + "no activation was committed.\n"); return CLI_TRUE; } @@ -690,16 +844,13 @@ static int cli_activation_guard( .mutation_lease_release = cli_activation_production_release, .visible_diagnostic = cli_activation_production_diagnostic, }; - int rc = cbm_cli_activation_guard_with_ops( - &ops, mutation, mutation_context); + int rc = cbm_cli_activation_guard_with_ops(&ops, mutation, mutation_context); if (rc == CLI_OK) { - if (!cli_activation_log_event( - &context, "completed", - "activation mutation completed; configuration APIs do not " - "provide an aggregate rollback status")) { - (void)fprintf(stderr, - "warning: activation completed, but its final log " - "record could not be written.\n"); + if (!cli_activation_log_event(&context, "completed", + "activation mutation completed; configuration APIs do not " + "provide an aggregate rollback status")) { + (void)fprintf(stderr, "warning: activation completed, but its final log " + "record could not be written.\n"); } } else { (void)cli_activation_log_event( @@ -714,9 +865,8 @@ static int cli_activation_guard( } cli_activation_production_context_close(&context); if (!context.cleanup_ok) { - (void)fprintf(stderr, - "error: activation coordination cleanup failed; restart " - "your coding-agent sessions before retrying.\n"); + (void)fprintf(stderr, "error: activation coordination cleanup failed; restart " + "your coding-agent sessions before retrying.\n"); return CLI_TRUE; } return rc; @@ -1019,22 +1169,18 @@ typedef struct { char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; } cli_binary_validator_t; -static bool cli_binary_fingerprint_validator(const char *target_path, - void *opaque) { +static bool cli_binary_fingerprint_validator(const char *target_path, void *opaque) { cli_binary_validator_t *validator = opaque; char actual[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; - return validator && target_path && - cbm_daemon_build_fingerprint_file(target_path, actual) && + return validator && target_path && cbm_daemon_build_fingerprint_file(target_path, actual) && strcmp(actual, validator->fingerprint) == 0; } -static int cli_activation_transaction_abort( - cbm_activation_transaction_t **transaction_io) { +static int cli_activation_transaction_abort(cbm_activation_transaction_t **transaction_io) { if (!transaction_io || !*transaction_io) { return CLI_OK; } - return cbm_activation_transaction_close(transaction_io) == - CBM_ACTIVATION_TRANSACTION_OK + return cbm_activation_transaction_close(transaction_io) == CBM_ACTIVATION_TRANSACTION_OK ? CLI_OK : CLI_ERR; } @@ -1054,8 +1200,8 @@ static void cli_activation_transaction_abort_or_fail_stop( } #endif if (cleanup_status != CLI_OK) { - cli_activation_cleanup_fail_stop( - NULL, component ? component : "activation_transaction_cleanup"); + cli_activation_cleanup_fail_stop(NULL, + component ? component : "activation_transaction_cleanup"); } } @@ -1066,29 +1212,25 @@ void cbm_cli_set_activation_cleanup_failure_for_test(bool enabled) { int cbm_cli_activation_abort_cleanup_probe_for_test(void) { cbm_activation_transaction_t *transaction = NULL; - cli_activation_transaction_abort_or_fail_stop( - &transaction, "activation_transaction_recovery_test"); + cli_activation_transaction_abort_or_fail_stop(&transaction, + "activation_transaction_recovery_test"); return CLI_OK; } #endif -static int cli_activation_transaction_commit_validated( - cbm_activation_transaction_t *transaction, - const cli_binary_validator_t *validator, int mode) { +static int cli_activation_transaction_commit_validated(cbm_activation_transaction_t *transaction, + const cli_binary_validator_t *validator, + int mode) { if (!transaction) { return CLI_ERR; } - cbm_activation_transaction_status_t status = - cbm_activation_transaction_commit( - transaction, - validator ? cli_binary_fingerprint_validator : NULL, - (void *)validator); + cbm_activation_transaction_status_t status = cbm_activation_transaction_commit( + transaction, validator ? cli_binary_fingerprint_validator : NULL, (void *)validator); if (status != CBM_ACTIVATION_TRANSACTION_OK) { return CLI_ERR; } #ifndef _WIN32 - const char *target_path = - cbm_activation_transaction_target_path(transaction); + const char *target_path = cbm_activation_transaction_target_path(transaction); if (!target_path || chmod(target_path, (mode_t)mode) != 0) { (void)cbm_activation_transaction_rollback(transaction); return CLI_ERR; @@ -1106,14 +1248,12 @@ static int cli_activation_transaction_finalize_close( } cbm_activation_transaction_status_t status = cbm_activation_transaction_finalize(*transaction_io); - if (status != CBM_ACTIVATION_TRANSACTION_OK && - status != CBM_ACTIVATION_TRANSACTION_DEFERRED) { + if (status != CBM_ACTIVATION_TRANSACTION_OK && status != CBM_ACTIVATION_TRANSACTION_DEFERRED) { (void)cli_activation_transaction_abort(transaction_io); return CLI_ERR; } if (status == CBM_ACTIVATION_TRANSACTION_DEFERRED) { - const char *deferred = cbm_activation_transaction_deferred_path( - *transaction_io); + const char *deferred = cbm_activation_transaction_deferred_path(*transaction_io); cbm_log_warn("cli.activation_backup_cleanup_deferred", "path", deferred ? deferred : "unknown"); (void)fprintf(stderr, @@ -1131,18 +1271,15 @@ static void cli_activation_transaction_finalize_committed_or_fail_stop( } cbm_activation_transaction_status_t status = cbm_activation_transaction_finalize(*transaction_io); - if (status != CBM_ACTIVATION_TRANSACTION_OK && - status != CBM_ACTIVATION_TRANSACTION_DEFERRED) { + if (status != CBM_ACTIVATION_TRANSACTION_OK && status != CBM_ACTIVATION_TRANSACTION_DEFERRED) { /* The committed executable is already the only state consistent with * any configuration writes that preceded this call. Never roll it * back after finalize itself reports an uncertain cleanup state. */ - cli_activation_cleanup_fail_stop( - NULL, component ? component - : "activation_transaction_finalize"); + cli_activation_cleanup_fail_stop(NULL, + component ? component : "activation_transaction_finalize"); } if (status == CBM_ACTIVATION_TRANSACTION_DEFERRED) { - const char *deferred = cbm_activation_transaction_deferred_path( - *transaction_io); + const char *deferred = cbm_activation_transaction_deferred_path(*transaction_io); cbm_log_warn("cli.activation_backup_cleanup_deferred", "path", deferred ? deferred : "unknown"); (void)fprintf(stderr, @@ -1153,22 +1290,17 @@ static void cli_activation_transaction_finalize_committed_or_fail_stop( cli_activation_transaction_abort_or_fail_stop(transaction_io, component); } -static int cli_activation_transaction_commit_removal( - cbm_activation_transaction_t *transaction) { - return transaction && - cbm_activation_transaction_commit(transaction, NULL, NULL) == - CBM_ACTIVATION_TRANSACTION_OK +static int cli_activation_transaction_commit_removal(cbm_activation_transaction_t *transaction) { + return transaction && cbm_activation_transaction_commit(transaction, NULL, NULL) == + CBM_ACTIVATION_TRANSACTION_OK ? CLI_OK : CLI_ERR; } -static bool cli_activation_transaction_expected_build( - cbm_activation_transaction_t *transaction, - cli_binary_validator_t *validator) { - const char *staged = - cbm_activation_transaction_staged_path(transaction); - return staged && validator && cbm_daemon_build_fingerprint_file( - staged, validator->fingerprint); +static bool cli_activation_transaction_expected_build(cbm_activation_transaction_t *transaction, + cli_binary_validator_t *validator) { + const char *staged = cbm_activation_transaction_staged_path(transaction); + return staged && validator && cbm_daemon_build_fingerprint_file(staged, validator->fingerprint); } /* Copy the running binary transactionally. The command-level install path @@ -1190,8 +1322,8 @@ int cbm_copy_binary_to_target(const char *src, const char *dst) { (void)cli_activation_transaction_abort(&transaction); return CLI_ERR; } - if (cli_activation_transaction_commit_validated( - transaction, &validator, CLI_OCTAL_PERM) != CLI_OK) { + if (cli_activation_transaction_commit_validated(transaction, &validator, CLI_OCTAL_PERM) != + CLI_OK) { (void)cli_activation_transaction_abort(&transaction); return CLI_ERR; } @@ -1200,23 +1332,20 @@ int cbm_copy_binary_to_target(const char *src, const char *dst) { /* Replace a binary transactionally, retaining the old target until the exact * staged bytes have been published and validated. */ -int cbm_replace_binary(const char *path, const unsigned char *data, int len, - int mode) { +int cbm_replace_binary(const char *path, const unsigned char *data, int len, int mode) { if (!path || !data || len <= 0) { return CLI_ERR; } cbm_activation_transaction_t *transaction = NULL; cbm_activation_transaction_status_t status = - cbm_activation_transaction_stage_bytes( - path, data, (size_t)len, &transaction); + cbm_activation_transaction_stage_bytes(path, data, (size_t)len, &transaction); cli_binary_validator_t validator; if (status != CBM_ACTIVATION_TRANSACTION_OK || !transaction || !cli_activation_transaction_expected_build(transaction, &validator)) { (void)cli_activation_transaction_abort(&transaction); return CLI_ERR; } - if (cli_activation_transaction_commit_validated( - transaction, &validator, mode) != CLI_OK) { + if (cli_activation_transaction_commit_validated(transaction, &validator, mode) != CLI_OK) { (void)cli_activation_transaction_abort(&transaction); return CLI_ERR; } @@ -5422,43 +5551,35 @@ static wchar_t *cli_windows_utf8_to_wide(const char *value) { if (!value || !value[0]) { return NULL; } - int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, - NULL, 0); + int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, NULL, 0); if (needed <= 0) { return NULL; } wchar_t *wide = malloc((size_t)needed * sizeof(*wide)); - if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, - wide, needed) <= 0) { + if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, wide, needed) <= 0) { free(wide); return NULL; } return wide; } -static bool cli_windows_path_segment_equal(const wchar_t *segment, - size_t segment_length, +static bool cli_windows_path_segment_equal(const wchar_t *segment, size_t segment_length, const wchar_t *directory) { - while (segment_length > 0 && - (*segment == L' ' || *segment == L'\t')) { + while (segment_length > 0 && (*segment == L' ' || *segment == L'\t')) { segment++; segment_length--; } while (segment_length > 0 && - (segment[segment_length - 1U] == L' ' || - segment[segment_length - 1U] == L'\t' || - segment[segment_length - 1U] == L'/' || - segment[segment_length - 1U] == L'\\')) { + (segment[segment_length - 1U] == L' ' || segment[segment_length - 1U] == L'\t' || + segment[segment_length - 1U] == L'/' || segment[segment_length - 1U] == L'\\')) { segment_length--; } size_t directory_length = wcslen(directory); - while (directory_length > 0 && - (directory[directory_length - 1U] == L'/' || - directory[directory_length - 1U] == L'\\')) { + while (directory_length > 0 && (directory[directory_length - 1U] == L'/' || + directory[directory_length - 1U] == L'\\')) { directory_length--; } - return segment_length == directory_length && - _wcsnicmp(segment, directory, segment_length) == 0; + return segment_length == directory_length && _wcsnicmp(segment, directory, segment_length) == 0; } /* Persist the current-user PATH while the activation lease is held. The @@ -5467,17 +5588,16 @@ static bool cli_windows_path_segment_equal(const wchar_t *segment, static int cli_ensure_windows_user_path(const char *bin_dir, bool dry_run) { wchar_t *wide_dir = cli_windows_utf8_to_wide(bin_dir); HKEY environment = NULL; - if (!wide_dir || RegOpenKeyExW(HKEY_CURRENT_USER, L"Environment", 0, - KEY_QUERY_VALUE | KEY_SET_VALUE, - &environment) != ERROR_SUCCESS) { + if (!wide_dir || + RegOpenKeyExW(HKEY_CURRENT_USER, L"Environment", 0, KEY_QUERY_VALUE | KEY_SET_VALUE, + &environment) != ERROR_SUCCESS) { free(wide_dir); return CLI_ERR; } DWORD type = REG_EXPAND_SZ; DWORD bytes = 0; - LONG queried = RegQueryValueExW(environment, L"Path", NULL, &type, NULL, - &bytes); + LONG queried = RegQueryValueExW(environment, L"Path", NULL, &type, NULL, &bytes); bool missing = queried == ERROR_FILE_NOT_FOUND; if ((!missing && queried != ERROR_SUCCESS) || (!missing && type != REG_SZ && type != REG_EXPAND_SZ)) { @@ -5485,8 +5605,7 @@ static int cli_ensure_windows_user_path(const char *bin_dir, bool dry_run) { free(wide_dir); return CLI_ERR; } - size_t existing_capacity = missing ? 1U : - (size_t)bytes / sizeof(wchar_t) + 1U; + size_t existing_capacity = missing ? 1U : (size_t)bytes / sizeof(wchar_t) + 1U; wchar_t *existing = calloc(existing_capacity, sizeof(*existing)); if (!existing) { RegCloseKey(environment); @@ -5495,8 +5614,8 @@ static int cli_ensure_windows_user_path(const char *bin_dir, bool dry_run) { } if (!missing) { DWORD read_bytes = bytes; - if (RegQueryValueExW(environment, L"Path", NULL, &type, - (BYTE *)existing, &read_bytes) != ERROR_SUCCESS) { + if (RegQueryValueExW(environment, L"Path", NULL, &type, (BYTE *)existing, &read_bytes) != + ERROR_SUCCESS) { RegCloseKey(environment); free(existing); free(wide_dir); @@ -5509,8 +5628,7 @@ static int cli_ensure_windows_user_path(const char *bin_dir, bool dry_run) { const wchar_t *cursor = existing; while (!present && *cursor) { const wchar_t *separator = wcschr(cursor, L';'); - size_t length = separator ? (size_t)(separator - cursor) - : wcslen(cursor); + size_t length = separator ? (size_t)(separator - cursor) : wcslen(cursor); present = cli_windows_path_segment_equal(cursor, length, wide_dir); cursor = separator ? separator + 1 : cursor + length; } @@ -5523,16 +5641,14 @@ static int cli_ensure_windows_user_path(const char *bin_dir, bool dry_run) { size_t existing_length = wcslen(existing); size_t directory_length = wcslen(wide_dir); - bool separator_needed = existing_length > 0 && - existing[existing_length - 1U] != L';'; + bool separator_needed = existing_length > 0 && existing[existing_length - 1U] != L';'; if (existing_length > SIZE_MAX - directory_length - 2U) { RegCloseKey(environment); free(existing); free(wide_dir); return CLI_ERR; } - size_t combined_length = existing_length + directory_length + - (separator_needed ? 1U : 0U); + size_t combined_length = existing_length + directory_length + (separator_needed ? 1U : 0U); if (combined_length + 1U > UINT32_MAX / sizeof(wchar_t)) { RegCloseKey(environment); free(existing); @@ -5551,11 +5667,9 @@ static int cli_ensure_windows_user_path(const char *bin_dir, bool dry_run) { if (separator_needed) { combined[offset++] = L';'; } - memcpy(combined + offset, wide_dir, - (directory_length + 1U) * sizeof(*combined)); + memcpy(combined + offset, wide_dir, (directory_length + 1U) * sizeof(*combined)); DWORD output_bytes = (DWORD)((combined_length + 1U) * sizeof(*combined)); - LONG stored = RegSetValueExW(environment, L"Path", 0, - missing ? REG_EXPAND_SZ : type, + LONG stored = RegSetValueExW(environment, L"Path", 0, missing ? REG_EXPAND_SZ : type, (const BYTE *)combined, output_bytes); RegCloseKey(environment); free(combined); @@ -5566,6 +5680,284 @@ static int cli_ensure_windows_user_path(const char *bin_dir, bool dry_run) { } return CLI_OK; } + +static bool cli_windows_module_path(wchar_t out[CBM_WINDOWS_LAUNCHER_PATH_CAP]) { + DWORD length = GetModuleFileNameW(NULL, out, CBM_WINDOWS_LAUNCHER_PATH_CAP); + return length > 0 && length < CBM_WINDOWS_LAUNCHER_PATH_CAP; +} + +static bool cli_windows_parent_path(const wchar_t *path, wchar_t *out, size_t capacity) { + if (!path || !out || capacity == 0) { + return false; + } + out[0] = L'\0'; + size_t length = wcslen(path); + if (length == 0 || length >= capacity) { + return false; + } + memcpy(out, path, (length + 1U) * sizeof(*out)); + wchar_t *slash = wcsrchr(out, L'\\'); + wchar_t *forward = wcsrchr(out, L'/'); + if (forward && (!slash || forward > slash)) { + slash = forward; + } + if (!slash || slash == out) { + out[0] = L'\0'; + return false; + } + *slash = L'\0'; + return true; +} + +static bool cli_windows_join_path(const wchar_t *directory, const wchar_t *name, wchar_t *out, + size_t capacity) { + if (!directory || !directory[0] || !name || !name[0] || !out || capacity == 0) { + return false; + } + int written = _snwprintf_s(out, capacity, _TRUNCATE, L"%ls\\%ls", directory, name); + if (written <= 0 || (size_t)written >= capacity) { + out[0] = L'\0'; + return false; + } + return true; +} + +static bool cli_windows_regular_file_no_reparse(const wchar_t *path, uint64_t *size_out) { + HANDLE file = + CreateFileW(path, GENERIC_READ | READ_CONTROL, FILE_SHARE_READ | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + BY_HANDLE_FILE_INFORMATION information; + LARGE_INTEGER size; + bool valid = file != INVALID_HANDLE_VALUE && GetFileType(file) == FILE_TYPE_DISK && + GetFileInformationByHandle(file, &information) != 0 && + (information.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + information.nNumberOfLinks == 1 && GetFileSizeEx(file, &size) != 0 && + size.QuadPart > 0; + if (valid && size_out) { + *size_out = (uint64_t)size.QuadPart; + } + if (file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(file); + } + return valid; +} + +/* A portable release pair provides the source launcher adjacent to the + * payload. A managed reinstall uses the already-authenticated canonical + * launcher because immutable generation payloads intentionally have no + * sibling launcher. */ +static bool cli_windows_install_source_launcher( + const wchar_t *payload_path, wchar_t launcher_out[CBM_WINDOWS_LAUNCHER_PATH_CAP]) { + if (g_windows_launcher_context.present && g_windows_launcher_context.managed) { + int copied = _snwprintf_s(launcher_out, CBM_WINDOWS_LAUNCHER_PATH_CAP, _TRUNCATE, L"%ls", + g_windows_launcher_context.canonical_launcher_path); + return copied > 0 && cli_windows_regular_file_no_reparse(launcher_out, NULL); + } + wchar_t directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + return cli_windows_parent_path(payload_path, directory, CBM_WINDOWS_LAUNCHER_PATH_CAP) && + cli_windows_join_path(directory, L"codebase-memory-mcp.exe", launcher_out, + CBM_WINDOWS_LAUNCHER_PATH_CAP) && + cli_windows_regular_file_no_reparse(launcher_out, NULL); +} + +static bool cli_windows_remove_tree(const wchar_t *directory) { + DWORD attributes = GetFileAttributesW(directory); + if (attributes == INVALID_FILE_ATTRIBUTES) { + return GetLastError() == ERROR_FILE_NOT_FOUND || GetLastError() == ERROR_PATH_NOT_FOUND; + } + if ((attributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (attributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + return false; + } + + wchar_t pattern[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!cli_windows_join_path(directory, L"*", pattern, CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + return false; + } + WIN32_FIND_DATAW entry; + HANDLE search = FindFirstFileW(pattern, &entry); + if (search == INVALID_HANDLE_VALUE) { + return GetLastError() == ERROR_FILE_NOT_FOUND && RemoveDirectoryW(directory) != 0; + } + bool ok = true; + do { + if (wcscmp(entry.cFileName, L".") == 0 || wcscmp(entry.cFileName, L"..") == 0) { + continue; + } + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + ok = false; + break; + } + wchar_t child[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!cli_windows_join_path(directory, entry.cFileName, child, + CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + ok = false; + break; + } + if ((entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0) { + ok = cli_windows_remove_tree(child); + } else { + ok = DeleteFileW(child) != 0; + } + } while (ok && FindNextFileW(search, &entry)); + DWORD find_error = GetLastError(); + (void)FindClose(search); + return ok && find_error == ERROR_NO_MORE_FILES && RemoveDirectoryW(directory) != 0; +} + +static bool cli_windows_remove_managed_state(const wchar_t *canonical_launcher) { + wchar_t directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t state[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + return cli_windows_parent_path(canonical_launcher, directory, CBM_WINDOWS_LAUNCHER_PATH_CAP) && + cli_windows_join_path(directory, L".cbm", state, CBM_WINDOWS_LAUNCHER_PATH_CAP) && + cli_windows_remove_tree(state); +} + +static bool cli_windows_publish_generation(const wchar_t *canonical_launcher, + const wchar_t *payload_source, + const cbm_windows_current_v1_t *state, + bool *created_out) { + if (created_out) { + *created_out = false; + } + wchar_t generation[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!cbm_windows_generation_payload_path(canonical_launcher, state->payload_sha256, generation, + CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + return false; + } + uint64_t existing_size = 0; + if (cli_windows_regular_file_no_reparse(generation, &existing_size)) { + char *existing_utf8 = cbm_wide_to_utf8(generation); + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + bool valid = existing_utf8 && existing_size == state->payload_size && + cbm_daemon_build_fingerprint_file(existing_utf8, fingerprint) && + strcmp(fingerprint, state->payload_sha256) == 0; + free(existing_utf8); + return valid; + } + + wchar_t generation_dir[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!cli_windows_parent_path(generation, generation_dir, CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + return false; + } + char *generation_dir_utf8 = cbm_wide_to_utf8(generation_dir); + char *generation_utf8 = cbm_wide_to_utf8(generation); + char *source_utf8 = cbm_wide_to_utf8(payload_source); + bool prepared = generation_dir_utf8 && generation_utf8 && source_utf8 && + cbm_mkdir_p(generation_dir_utf8, CLI_OCTAL_PERM); + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t staged = + prepared ? cbm_activation_transaction_stage_file(generation_utf8, source_utf8, &transaction) + : CBM_ACTIVATION_TRANSACTION_IO; + cli_binary_validator_t validator = {{0}}; + prepared = staged == CBM_ACTIVATION_TRANSACTION_OK && transaction && + cli_activation_transaction_expected_build(transaction, &validator) && + strcmp(validator.fingerprint, state->payload_sha256) == 0 && + cli_activation_transaction_commit_validated(transaction, &validator, + CLI_OCTAL_PERM) == CLI_OK && + cli_activation_transaction_finalize_close(&transaction) == CLI_OK; + (void)cli_activation_transaction_abort(&transaction); + free(generation_dir_utf8); + free(generation_utf8); + free(source_utf8); + if (prepared && created_out) { + *created_out = true; + } + return prepared; +} + +static bool cli_windows_publish_generation_bytes(const wchar_t *canonical_launcher, + const cbm_windows_current_v1_t *state, + const unsigned char *payload, size_t payload_size, + bool *created_out) { + if (created_out) { + *created_out = false; + } + if (!payload || payload_size == 0 || payload_size != state->payload_size) { + return false; + } + wchar_t generation[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!cbm_windows_generation_payload_path(canonical_launcher, state->payload_sha256, generation, + CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + return false; + } + uint64_t existing_size = 0; + if (cli_windows_regular_file_no_reparse(generation, &existing_size)) { + char *existing_utf8 = cbm_wide_to_utf8(generation); + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + bool valid = existing_utf8 && existing_size == payload_size && + cbm_daemon_build_fingerprint_file(existing_utf8, fingerprint) && + strcmp(fingerprint, state->payload_sha256) == 0; + free(existing_utf8); + return valid; + } + wchar_t generation_directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!cli_windows_parent_path(generation, generation_directory, CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + return false; + } + char *directory_utf8 = cbm_wide_to_utf8(generation_directory); + char *generation_utf8 = cbm_wide_to_utf8(generation); + bool ready = directory_utf8 && generation_utf8 && cbm_mkdir_p(directory_utf8, CLI_OCTAL_PERM); + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t staged = + ready ? cbm_activation_transaction_stage_bytes(generation_utf8, payload, payload_size, + &transaction) + : CBM_ACTIVATION_TRANSACTION_IO; + cli_binary_validator_t validator = {{0}}; + ready = staged == CBM_ACTIVATION_TRANSACTION_OK && transaction && + cli_activation_transaction_expected_build(transaction, &validator) && + strcmp(validator.fingerprint, state->payload_sha256) == 0 && + cli_activation_transaction_commit_validated(transaction, &validator, CLI_OCTAL_PERM) == + CLI_OK && + cli_activation_transaction_finalize_close(&transaction) == CLI_OK; + (void)cli_activation_transaction_abort(&transaction); + free(directory_utf8); + free(generation_utf8); + if (ready && created_out) { + *created_out = true; + } + return ready; +} + +static bool cli_windows_stage_launcher_candidate( + const wchar_t *canonical_launcher, const char payload_sha256[65], const unsigned char *launcher, + size_t launcher_size, wchar_t candidate_out[CBM_WINDOWS_LAUNCHER_PATH_CAP]) { + wchar_t current[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t state_directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!launcher || launcher_size == 0 || !cli_windows_current_path(canonical_launcher, current) || + !cli_windows_parent_path(current, state_directory, CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + return false; + } + wchar_t name[96]; + int name_length = _snwprintf_s(name, sizeof(name) / sizeof(name[0]), _TRUNCATE, + L"launcher-next-%hs.exe", payload_sha256); + if (name_length <= 0 || !cli_windows_join_path(state_directory, name, candidate_out, + CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + return false; + } + char *state_utf8 = cbm_wide_to_utf8(state_directory); + char *candidate_utf8 = cbm_wide_to_utf8(candidate_out); + bool ready = state_utf8 && candidate_utf8 && cbm_mkdir_p(state_utf8, CLI_OCTAL_PERM); + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t staged = + ready ? cbm_activation_transaction_stage_bytes(candidate_utf8, launcher, launcher_size, + &transaction) + : CBM_ACTIVATION_TRANSACTION_IO; + cli_binary_validator_t validator = {{0}}; + ready = staged == CBM_ACTIVATION_TRANSACTION_OK && transaction && + cli_activation_transaction_expected_build(transaction, &validator) && + cli_activation_transaction_commit_validated(transaction, &validator, CLI_OCTAL_PERM) == + CLI_OK && + cli_activation_transaction_finalize_close(&transaction) == CLI_OK; + (void)cli_activation_transaction_abort(&transaction); + free(state_utf8); + free(candidate_utf8); + if (!ready) { + candidate_out[0] = L'\0'; + } + return ready; +} #endif /* ── Tar.gz extraction ────────────────────────────────────────── */ @@ -5838,6 +6230,260 @@ unsigned char *cbm_extract_binary_from_zip(const unsigned char *data, int data_l return NULL; } +enum { + ZIP_CENTRAL_HDR_SZ = 46, + ZIP_END_HDR_SZ = 22, + ZIP_OFF_FLAGS = 6, + ZIP_OFF_CRC = 14, + ZIP_CENTRAL_OFF_FLAGS = 8, + ZIP_CENTRAL_OFF_METHOD = 10, + ZIP_CENTRAL_OFF_CRC = 16, + ZIP_CENTRAL_OFF_COMP = 20, + ZIP_CENTRAL_OFF_UNCOMP = 24, + ZIP_CENTRAL_OFF_NAMELEN = 28, + ZIP_CENTRAL_OFF_EXTRALEN = 30, + ZIP_CENTRAL_OFF_COMMENTLEN = 32, + ZIP_CENTRAL_OFF_DISK = 34, + ZIP_CENTRAL_OFF_LOCAL = 42, + ZIP_END_OFF_DISK = 4, + ZIP_END_OFF_CENTRAL_DISK = 6, + ZIP_END_OFF_ENTRIES_DISK = 8, + ZIP_END_OFF_ENTRIES_TOTAL = 10, + ZIP_END_OFF_CENTRAL_SIZE = 12, + ZIP_END_OFF_CENTRAL_OFFSET = 16, + ZIP_END_OFF_COMMENTLEN = 20, + ZIP_UTF8_FLAG = 0x0800, +}; + +static bool zip_signature_is(const unsigned char *data, size_t offset, size_t length, + unsigned char third, unsigned char fourth) { + return offset <= length && length - offset >= 4U && data[offset] == 0x50 && + data[offset + 1U] == 0x4b && data[offset + 2U] == third && data[offset + 3U] == fourth; +} + +static bool zip_find_end_record(const unsigned char *data, size_t length, size_t *offset_out) { + if (length < ZIP_END_HDR_SZ || !offset_out) { + return false; + } + size_t floor = + length > UINT16_MAX + ZIP_END_HDR_SZ ? length - (UINT16_MAX + ZIP_END_HDR_SZ) : 0; + for (size_t offset = length - ZIP_END_HDR_SZ;; offset--) { + if (zip_signature_is(data, offset, length, 0x05, 0x06)) { + uint16_t comment = zip_read_u16le(data + offset + ZIP_END_OFF_COMMENTLEN); + if (offset + ZIP_END_HDR_SZ + comment == length) { + *offset_out = offset; + return true; + } + } + if (offset == floor) { + break; + } + } + return false; +} + +static bool zip_ascii_equal_folded(const unsigned char *name, size_t name_length, + const char *expected) { + size_t expected_length = strlen(expected); + if (name_length != expected_length) { + return false; + } + for (size_t index = 0; index < name_length; index++) { + unsigned char left = name[index]; + unsigned char right = (unsigned char)expected[index]; + if (left >= 'A' && left <= 'Z') { + left = (unsigned char)(left - 'A' + 'a'); + } + if (right >= 'A' && right <= 'Z') { + right = (unsigned char)(right - 'A' + 'a'); + } + if (left != right) { + return false; + } + } + return true; +} + +static int zip_windows_bundle_name_kind(const unsigned char *name, size_t name_length) { + static const char *const allowed[] = { + NULL, "codebase-memory-mcp.exe", "codebase-memory-mcp.payload.exe", "LICENSE", + "install.ps1", "THIRD_PARTY_NOTICES.md", + }; + if (!name || name_length == 0 || name[name_length - 1U] == '.' || + name[name_length - 1U] == ' ') { + return 0; + } + for (size_t index = 0; index < name_length; index++) { + if (name[index] == '\0' || name[index] == '/' || name[index] == '\\' || + name[index] == ':') { + return 0; + } + } + for (size_t kind = 1U; kind < sizeof(allowed) / sizeof(allowed[0]); kind++) { + const char *expected = allowed[kind]; + if (zip_ascii_equal_folded(name, name_length, expected)) { + return name_length == strlen(expected) && memcmp(name, expected, name_length) == 0 + ? (int)kind + : -1; + } + } + return 0; +} + +void cbm_windows_release_pair_free(cbm_windows_release_pair_t *pair) { + if (pair) { + free(pair->launcher); + free(pair->payload); + memset(pair, 0, sizeof(*pair)); + } +} + +bool cbm_extract_windows_release_pair_from_zip(const unsigned char *data, int data_len, + cbm_windows_release_pair_t *pair_out) { + if (!data || data_len <= 0 || !pair_out) { + return false; + } + memset(pair_out, 0, sizeof(*pair_out)); + size_t length = (size_t)data_len; + size_t end_offset = 0; + if (!zip_find_end_record(data, length, &end_offset)) { + return false; + } + const unsigned char *end = data + end_offset; + uint16_t entries_disk = zip_read_u16le(end + ZIP_END_OFF_ENTRIES_DISK); + uint16_t entries_total = zip_read_u16le(end + ZIP_END_OFF_ENTRIES_TOTAL); + uint32_t central_size = zip_read_u32le(end + ZIP_END_OFF_CENTRAL_SIZE); + uint32_t central_offset = zip_read_u32le(end + ZIP_END_OFF_CENTRAL_OFFSET); + if (zip_read_u16le(end + ZIP_END_OFF_DISK) != 0 || + zip_read_u16le(end + ZIP_END_OFF_CENTRAL_DISK) != 0 || entries_disk != 5 || + entries_total != 5 || central_offset > end_offset || + central_size != end_offset - central_offset) { + return false; + } + + size_t cursor = central_offset; + bool seen[6] = {false, false, false, false, false, false}; + size_t local_starts[5] = {0, 0, 0, 0, 0}; + size_t local_ends[5] = {0, 0, 0, 0, 0}; + for (uint16_t entry = 0; entry < entries_total; entry++) { + if (!zip_signature_is(data, cursor, length, 0x01, 0x02) || + length - cursor < ZIP_CENTRAL_HDR_SZ) { + cbm_windows_release_pair_free(pair_out); + return false; + } + const unsigned char *central = data + cursor; + uint16_t flags = zip_read_u16le(central + ZIP_CENTRAL_OFF_FLAGS); + uint16_t method = zip_read_u16le(central + ZIP_CENTRAL_OFF_METHOD); + uint32_t crc = zip_read_u32le(central + ZIP_CENTRAL_OFF_CRC); + uint32_t compressed = zip_read_u32le(central + ZIP_CENTRAL_OFF_COMP); + uint32_t uncompressed = zip_read_u32le(central + ZIP_CENTRAL_OFF_UNCOMP); + uint16_t name_length = zip_read_u16le(central + ZIP_CENTRAL_OFF_NAMELEN); + uint16_t extra_length = zip_read_u16le(central + ZIP_CENTRAL_OFF_EXTRALEN); + uint16_t comment_length = zip_read_u16le(central + ZIP_CENTRAL_OFF_COMMENTLEN); + uint32_t local_offset = zip_read_u32le(central + ZIP_CENTRAL_OFF_LOCAL); + size_t central_record_size = ZIP_CENTRAL_HDR_SZ + (size_t)name_length + + (size_t)extra_length + (size_t)comment_length; + if (name_length == 0 || central_record_size > end_offset - cursor || + zip_read_u16le(central + ZIP_CENTRAL_OFF_DISK) != 0 || + (flags & (uint16_t)~ZIP_UTF8_FLAG) != 0 || + (method != ZIP_STORED && method != ZIP_DEFLATE)) { + cbm_windows_release_pair_free(pair_out); + return false; + } + const unsigned char *name = central + ZIP_CENTRAL_HDR_SZ; + int kind = zip_windows_bundle_name_kind(name, name_length); + if (kind <= 0 || seen[kind]) { + cbm_windows_release_pair_free(pair_out); + return false; + } + seen[kind] = true; + + if (local_offset >= central_offset || + !zip_signature_is(data, local_offset, length, 0x03, 0x04) || + central_offset - local_offset < ZIP_HDR_SZ) { + cbm_windows_release_pair_free(pair_out); + return false; + } + const unsigned char *local = data + local_offset; + uint16_t local_flags = zip_read_u16le(local + ZIP_OFF_FLAGS); + uint16_t local_method = zip_read_u16le(local + ZIP_OFF_METHOD); + uint32_t local_crc = zip_read_u32le(local + ZIP_OFF_CRC); + uint32_t local_compressed = zip_read_u32le(local + ZIP_OFF_COMP); + uint32_t local_uncompressed = zip_read_u32le(local + ZIP_OFF_UNCOMP); + uint16_t local_name_length = zip_read_u16le(local + ZIP_OFF_NAMELEN); + uint16_t local_extra_length = zip_read_u16le(local + ZIP_OFF_EXTRALEN); + size_t data_offset = (size_t)local_offset + ZIP_HDR_SZ + (size_t)local_name_length + + (size_t)local_extra_length; + if (local_flags != flags || local_method != method || local_crc != crc || + local_compressed != compressed || local_uncompressed != uncompressed || + local_name_length != name_length || data_offset > central_offset || + compressed > central_offset - data_offset || + memcmp(local + ZIP_HDR_SZ, name, name_length) != 0) { + cbm_windows_release_pair_free(pair_out); + return false; + } + size_t local_end = data_offset + compressed; + for (uint16_t prior = 0; prior < entry; prior++) { + if (local_offset < local_ends[prior] && local_end > local_starts[prior]) { + cbm_windows_release_pair_free(pair_out); + return false; + } + } + local_starts[entry] = local_offset; + local_ends[entry] = local_end; + int extracted_length = 0; + unsigned char *extracted = zip_extract_entry(data + data_offset, method, compressed, + uncompressed, &extracted_length); + uLong observed_crc = extracted ? crc32(0L, extracted, (uInt)extracted_length) : 0; + if (!extracted || extracted_length <= 0 || (uint32_t)extracted_length != uncompressed || + observed_crc != crc) { + free(extracted); + cbm_windows_release_pair_free(pair_out); + return false; + } + if (kind == 1) { + pair_out->launcher = extracted; + pair_out->launcher_len = extracted_length; + } else if (kind == 2) { + pair_out->payload = extracted; + pair_out->payload_len = extracted_length; + } else { + /* Legal/install metadata is part of the authenticated release + * namespace but is never materialized by the updater. Parsing, + * bounds checks, and CRC verification above still cover it. */ + free(extracted); + } + cursor += central_record_size; + } + for (size_t index = 1U; index < 5U; index++) { + size_t start = local_starts[index]; + size_t end_value = local_ends[index]; + size_t position = index; + while (position > 0U && local_starts[position - 1U] > start) { + local_starts[position] = local_starts[position - 1U]; + local_ends[position] = local_ends[position - 1U]; + position--; + } + local_starts[position] = start; + local_ends[position] = end_value; + } + bool namespace_complete = true; + for (size_t kind = 1U; kind < 6U; kind++) { + namespace_complete = namespace_complete && seen[kind]; + } + bool local_records_contiguous = local_starts[0] == 0U; + for (size_t index = 1U; index < 5U; index++) { + local_records_contiguous = + local_records_contiguous && local_ends[index - 1U] == local_starts[index]; + } + if (cursor != end_offset || !namespace_complete || !local_records_contiguous || + local_ends[4] != central_offset) { + cbm_windows_release_pair_free(pair_out); + return false; + } + return true; +} + /* ── Index management ─────────────────────────────────────────── */ static const char *get_cache_dir(const char *home_dir) { @@ -6050,9 +6696,7 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key) { /* ── Config CLI subcommand ────────────────────────────────────── */ int cbm_cmd_config(int argc, char **argv) { - if (argc == 0 || - (argv && (strcmp(argv[0], "--help") == 0 || - strcmp(argv[0], "-h") == 0))) { + if (argc == 0 || (argv && (strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0))) { printf("Usage: codebase-memory-mcp config [args]\n\n"); printf("Commands:\n"); printf(" list Show all config values\n"); @@ -6242,12 +6886,11 @@ static int cli_checksum_hex_nibble(unsigned char value) { return CLI_ERR; } -static bool cli_checksum_line_references_archive( - const unsigned char *line, size_t line_length, const char *archive_name, - size_t archive_name_length) { +static bool cli_checksum_line_references_archive(const unsigned char *line, size_t line_length, + const char *archive_name, + size_t archive_name_length) { if (!line || !archive_name || line_length < archive_name_length || - memcmp(line + line_length - archive_name_length, archive_name, - archive_name_length) != 0) { + memcmp(line + line_length - archive_name_length, archive_name, archive_name_length) != 0) { return false; } size_t prefix_length = line_length - archive_name_length; @@ -6255,32 +6898,28 @@ static bool cli_checksum_line_references_archive( return true; } unsigned char separator = line[prefix_length - 1U]; - return separator == (unsigned char)' ' || - separator == (unsigned char)'\t' || + return separator == (unsigned char)' ' || separator == (unsigned char)'\t' || separator == (unsigned char)'*'; } -static bool cli_checksum_line_digest( - const unsigned char *line, size_t line_length, const char *archive_name, - size_t archive_name_length, char digest[SHA256_BUF_SIZE]) { +static bool cli_checksum_line_digest(const unsigned char *line, size_t line_length, + const char *archive_name, size_t archive_name_length, + char digest[SHA256_BUF_SIZE]) { if (!line || line_length <= SHA256_HEX_LEN || (line[SHA256_HEX_LEN] != (unsigned char)' ' && line[SHA256_HEX_LEN] != (unsigned char)'\t')) { return false; } size_t filename_offset = SHA256_HEX_LEN; - while (filename_offset < line_length && - (line[filename_offset] == (unsigned char)' ' || - line[filename_offset] == (unsigned char)'\t')) { + while (filename_offset < line_length && (line[filename_offset] == (unsigned char)' ' || + line[filename_offset] == (unsigned char)'\t')) { filename_offset++; } - if (filename_offset < line_length && - line[filename_offset] == (unsigned char)'*') { + if (filename_offset < line_length && line[filename_offset] == (unsigned char)'*') { filename_offset++; } if (line_length - filename_offset != archive_name_length || - memcmp(line + filename_offset, archive_name, - archive_name_length) != 0) { + memcmp(line + filename_offset, archive_name, archive_name_length) != 0) { return false; } @@ -6300,15 +6939,13 @@ static bool cli_checksum_line_digest( * substring matches. This non-header symbol is intentionally exercised by the * focused CLI regression tests. Duplicate entries are accepted only when they * name the exact artifact and normalize to the same SHA-256 digest. */ -int cbm_cli_checksum_manifest_digest(const char *manifest_path, - const char *archive_name, char *out, +int cbm_cli_checksum_manifest_digest(const char *manifest_path, const char *archive_name, char *out, size_t out_size) { if (out && out_size > 0) { out[0] = '\0'; } - if (!manifest_path || !archive_name || !archive_name[0] || !out || - out_size < SHA256_BUF_SIZE || strchr(archive_name, '\n') || - strchr(archive_name, '\r')) { + if (!manifest_path || !archive_name || !archive_name[0] || !out || out_size < SHA256_BUF_SIZE || + strchr(archive_name, '\n') || strchr(archive_name, '\r')) { return CLI_ERR; } @@ -6342,8 +6979,7 @@ int cbm_cli_checksum_manifest_digest(const char *manifest_path, if (fclose(fp) != 0) { read_ok = false; } - if (!read_ok || manifest_length == 0 || - manifest_length > CHECKSUM_MANIFEST_MAX_BYTES || + if (!read_ok || manifest_length == 0 || manifest_length > CHECKSUM_MANIFEST_MAX_BYTES || memchr(manifest, '\0', manifest_length)) { free(manifest); return CLI_ERR; @@ -6355,19 +6991,17 @@ int cbm_cli_checksum_manifest_digest(const char *manifest_path, const unsigned char *cursor = manifest; const unsigned char *end = manifest + manifest_length; while (cursor < end) { - const unsigned char *newline = - memchr(cursor, '\n', (size_t)(end - cursor)); + const unsigned char *newline = memchr(cursor, '\n', (size_t)(end - cursor)); const unsigned char *line_end = newline ? newline : end; size_t line_length = (size_t)(line_end - cursor); if (line_length > 0 && cursor[line_length - 1U] == (unsigned char)'\r') { line_length--; } - bool references = cli_checksum_line_references_archive( - cursor, line_length, archive_name, archive_name_length); + bool references = cli_checksum_line_references_archive(cursor, line_length, archive_name, + archive_name_length); char candidate[SHA256_BUF_SIZE] = {0}; - bool valid = cli_checksum_line_digest( - cursor, line_length, archive_name, archive_name_length, - candidate); + bool valid = cli_checksum_line_digest(cursor, line_length, archive_name, + archive_name_length, candidate); if (references && !valid) { free(manifest); return CLI_ERR; @@ -6394,14 +7028,13 @@ int cbm_cli_checksum_manifest_digest(const char *manifest_path, static bool cli_download_is_explicit_file_override(const char *url) { char override_buffer[CLI_BUF_512]; - const char *override = cbm_safe_getenv( - "CBM_DOWNLOAD_URL", override_buffer, sizeof(override_buffer), NULL); + const char *override = + cbm_safe_getenv("CBM_DOWNLOAD_URL", override_buffer, sizeof(override_buffer), NULL); if (!url || !override || strncmp(override, "file://", 7) != 0) { return false; } size_t override_length = strlen(override); - return override_length > 0 && - strncmp(url, override, override_length) == 0 && + return override_length > 0 && strncmp(url, override, override_length) == 0 && (url[override_length] == '\0' || url[override_length] == '/' || override[override_length - 1U] == '/'); } @@ -6410,8 +7043,7 @@ static const char *cli_download_protocol(const char *url) { if (url && strncmp(url, "https://", 8) == 0) { return "=https"; } - if (url && strncmp(url, "file://", 7) == 0 && - cli_download_is_explicit_file_override(url)) { + if (url && strncmp(url, "file://", 7) == 0 && cli_download_is_explicit_file_override(url)) { return "=file"; } return NULL; @@ -6420,35 +7052,28 @@ static const char *cli_download_protocol(const char *url) { static int cbm_download_to_file(const char *url, const char *dest) { const char *protocol = cli_download_protocol(url); if (!protocol || !dest) { - (void)fprintf(stderr, - "error: update downloads require HTTPS (file:// is " - "reserved for an explicit CBM_DOWNLOAD_URL test " - "override)\n"); + (void)fprintf(stderr, "error: update downloads require HTTPS (file:// is " + "reserved for an explicit CBM_DOWNLOAD_URL test " + "override)\n"); return CLI_TRUE; } - const char *argv[] = {"curl", "-fSL", - "--progress-bar", "--proto", - protocol, "--proto-redir", - protocol, "-o", - dest, url, - NULL}; + const char *argv[] = {"curl", "-fSL", "--progress-bar", + "--proto", protocol, "--proto-redir", + protocol, "-o", dest, + url, NULL}; return cbm_exec_no_shell(argv); } static int cbm_download_to_file_quiet(const char *url, const char *dest) { const char *protocol = cli_download_protocol(url); if (!protocol || !dest) { - (void)fprintf(stderr, - "error: checksum downloads require HTTPS (file:// is " - "reserved for an explicit CBM_DOWNLOAD_URL test " - "override)\n"); + (void)fprintf(stderr, "error: checksum downloads require HTTPS (file:// is " + "reserved for an explicit CBM_DOWNLOAD_URL test " + "override)\n"); return CLI_TRUE; } - const char *argv[] = {"curl", "-fsSL", - "--proto", protocol, - "--proto-redir", protocol, - "-o", dest, - url, NULL}; + const char *argv[] = {"curl", "-fsSL", "--proto", protocol, "--proto-redir", + protocol, "-o", dest, url, NULL}; return cbm_exec_no_shell(argv); } @@ -6457,13 +7082,11 @@ static int cbm_download_to_file_quiet(const char *url, const char *dest) { #ifdef __APPLE__ static int cbm_macos_adhoc_sign(const char *binary_path) { /* Remove quarantine xattr (best effort — may not exist) */ - const char *xattr_argv[] = {"/usr/bin/xattr", "-d", "com.apple.quarantine", binary_path, - NULL}; + const char *xattr_argv[] = {"/usr/bin/xattr", "-d", "com.apple.quarantine", binary_path, NULL}; (void)cbm_exec_no_shell(xattr_argv); /* Ad-hoc sign (required for arm64, harmless for x86_64) */ - const char *sign_argv[] = {"/usr/bin/codesign", "--sign", "-", "--force", binary_path, - NULL}; + const char *sign_argv[] = {"/usr/bin/codesign", "--sign", "-", "--force", binary_path, NULL}; return cbm_exec_no_shell(sign_argv); } #endif @@ -6472,11 +7095,9 @@ static int cbm_macos_adhoc_sign(const char *binary_path) { * result is a fail-closed refusal; verification is never optional. */ static int verify_download_checksum(const char *archive_path, const char *archive_name) { char checksum_file[CLI_BUF_256]; - int checksum_path_length = snprintf( - checksum_file, sizeof(checksum_file), "%s/cbm-checksums-XXXXXX", - cbm_tmpdir()); - if (checksum_path_length <= 0 || - (size_t)checksum_path_length >= sizeof(checksum_file)) { + int checksum_path_length = + snprintf(checksum_file, sizeof(checksum_file), "%s/cbm-checksums-XXXXXX", cbm_tmpdir()); + if (checksum_path_length <= 0 || (size_t)checksum_path_length >= sizeof(checksum_file)) { return CLI_ERR; } int checksum_descriptor = cbm_mkstemp(checksum_file); @@ -6499,31 +7120,29 @@ static int verify_download_checksum(const char *archive_path, const char *archiv char checksum_url[CLI_BUF_512]; int checksum_url_length; if (dl_base && dl_base[0]) { - checksum_url_length = snprintf(checksum_url, sizeof(checksum_url), - "%s/checksums.txt", dl_base); + checksum_url_length = + snprintf(checksum_url, sizeof(checksum_url), "%s/checksums.txt", dl_base); } else { - checksum_url_length = snprintf( - checksum_url, sizeof(checksum_url), "%s", - "https://github.com/DeusData/codebase-memory-mcp/releases/latest/" - "download/checksums.txt"); + checksum_url_length = + snprintf(checksum_url, sizeof(checksum_url), "%s", + "https://github.com/DeusData/codebase-memory-mcp/releases/latest/" + "download/checksums.txt"); } - if (checksum_url_length <= 0 || - (size_t)checksum_url_length >= sizeof(checksum_url)) { + if (checksum_url_length <= 0 || (size_t)checksum_url_length >= sizeof(checksum_url)) { cbm_unlink(checksum_file); return CLI_ERR; } int rc = cbm_download_to_file_quiet(checksum_url, checksum_file); if (rc != 0) { - (void)fprintf(stderr, - "error: could not download checksums.txt for mandatory " - "verification\n"); + (void)fprintf(stderr, "error: could not download checksums.txt for mandatory " + "verification\n"); cbm_unlink(checksum_file); return CLI_ERR; } char expected[SHA256_BUF_SIZE] = {0}; - int manifest_status = cbm_cli_checksum_manifest_digest( - checksum_file, archive_name, expected, sizeof(expected)); + int manifest_status = + cbm_cli_checksum_manifest_digest(checksum_file, archive_name, expected, sizeof(expected)); cbm_unlink(checksum_file); if (manifest_status != CLI_OK) { (void)fprintf(stderr, @@ -7278,7 +7897,15 @@ static void print_detected_registry_agents(const char *home, bool *any) { static void cbm_agent_installed_binary_path(const char *home, char *binary_path, size_t binary_path_size) { #ifdef _WIN32 - snprintf(binary_path, binary_path_size, "%s/.local/bin/codebase-memory-mcp.exe", home); + char *managed = g_windows_launcher_context.present && g_windows_launcher_context.managed + ? cbm_wide_to_utf8(g_windows_launcher_context.canonical_launcher_path) + : NULL; + if (managed) { + (void)snprintf(binary_path, binary_path_size, "%s", managed); + free(managed); + } else { + snprintf(binary_path, binary_path_size, "%s/.local/bin/codebase-memory-mcp.exe", home); + } #else snprintf(binary_path, binary_path_size, "%s/.local/bin/codebase-memory-mcp", home); #endif @@ -8503,6 +9130,7 @@ int cbm_install_handle_existing_indexes(const char *home, bool reset, bool dry_r /* ── Subcommand: install ──────────────────────────────────────── */ +#ifndef _WIN32 /* Detect the running binary's path at runtime. Falls back to ~/.local/bin/. */ static void cbm_detect_self_path(char *buf, size_t buf_sz, const char *home) { buf[0] = '\0'; @@ -8528,13 +9156,13 @@ static void cbm_detect_self_path(char *buf, size_t buf_sz, const char *home) { #endif } } +#endif /* Build the agent.install.plan.v1 receipt (#388): a machine-readable list of * the config / instruction / skill / agent / hook files `install` WOULD write, produced by * running the real install dispatch in record-only mode (no mutation, no * network). Returns a heap JSON string (caller frees) or NULL. */ -static char *cbm_build_install_plan_json_options(const char *home, - const char *binary_path, +static char *cbm_build_install_plan_json_options(const char *home, const char *binary_path, bool skip_config) { if (!home || !binary_path) { return NULL; @@ -8646,11 +9274,11 @@ static char *cbm_build_install_plan_json_options(const char *home, return json; /* malloc'd; caller frees */ } -char *cbm_build_install_plan_json(const char *home, - const char *binary_path) { +char *cbm_build_install_plan_json(const char *home, const char *binary_path) { return cbm_build_install_plan_json_options(home, binary_path, false); } +#ifndef _WIN32 typedef struct { const char *bin_target; const char *bin_dir; @@ -8669,8 +9297,8 @@ typedef struct { static int cli_install_activate(void *opaque) { cli_install_activation_t *activation = opaque; - if (!activation || !activation->bin_target || !activation->bin_dir || - !activation->home || !activation->shell_rc) { + if (!activation || !activation->bin_target || !activation->bin_dir || !activation->home || + !activation->shell_rc) { return CLI_TRUE; } if (activation->copy_binary) { @@ -8678,47 +9306,36 @@ static int cli_install_activate(void *opaque) { printf("Would install binary -> %s\n\n", activation->bin_target); } } - if (!activation->dry_run && !activation->binary_transaction && - activation->prepared_candidate) { + if (!activation->dry_run && !activation->binary_transaction && activation->prepared_candidate) { if (!cbm_mkdir_p(activation->bin_dir, CLI_OCTAL_PERM)) { - (void)fprintf(stderr, - "error: cannot create install directory %s\n", + (void)fprintf(stderr, "error: cannot create install directory %s\n", activation->bin_dir); return CLI_TRUE; } - cbm_activation_transaction_status_t stage_status = - cbm_activation_transaction_stage_file( - activation->bin_target, activation->prepared_candidate, - &activation->binary_transaction); + cbm_activation_transaction_status_t stage_status = cbm_activation_transaction_stage_file( + activation->bin_target, activation->prepared_candidate, + &activation->binary_transaction); cli_binary_validator_t restaged_validator = {{0}}; - if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || - !activation->binary_transaction || - !cli_activation_transaction_expected_build( - activation->binary_transaction, &restaged_validator) || + if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || !activation->binary_transaction || + !cli_activation_transaction_expected_build(activation->binary_transaction, + &restaged_validator) || !activation->has_binary_validator || - strcmp(restaged_validator.fingerprint, - activation->binary_validator.fingerprint) != 0) { - (void)fprintf(stderr, - "error: verified install candidate could not be " - "re-staged on the target filesystem\n"); - cli_activation_transaction_abort_or_fail_stop( - &activation->binary_transaction, - "install_transaction_restaging_cleanup"); + strcmp(restaged_validator.fingerprint, activation->binary_validator.fingerprint) != 0) { + (void)fprintf(stderr, "error: verified install candidate could not be " + "re-staged on the target filesystem\n"); + cli_activation_transaction_abort_or_fail_stop(&activation->binary_transaction, + "install_transaction_restaging_cleanup"); return CLI_TRUE; } } if (!activation->dry_run && activation->binary_transaction) { if (cli_activation_transaction_commit_validated( activation->binary_transaction, - activation->has_binary_validator - ? &activation->binary_validator - : NULL, + activation->has_binary_validator ? &activation->binary_validator : NULL, CLI_OCTAL_PERM) != CLI_OK) { - cli_activation_transaction_abort_or_fail_stop( - &activation->binary_transaction, - "install_transaction_publish_recovery"); - (void)fprintf(stderr, - "error: failed to publish the staged binary to %s\n", + cli_activation_transaction_abort_or_fail_stop(&activation->binary_transaction, + "install_transaction_publish_recovery"); + (void)fprintf(stderr, "error: failed to publish the staged binary to %s\n", activation->bin_target); return CLI_TRUE; } @@ -8729,26 +9346,21 @@ static int cli_install_activate(void *opaque) { * including same-binary and non-force installs. */ int agent_config_rc = CLI_OK; if (!activation->skip_config) { - agent_config_rc = cbm_install_agent_configs( - activation->home, activation->bin_target, activation->force, - activation->dry_run); + agent_config_rc = cbm_install_agent_configs(activation->home, activation->bin_target, + activation->force, activation->dry_run); } if (agent_config_rc != CLI_OK) { cli_activation_transaction_finalize_committed_or_fail_stop( - &activation->binary_transaction, - "install_transaction_partial_finalize"); - (void)fprintf( - stderr, - "error: one or more agent configurations failed; the " - "published/current executable was kept, and PATH/index cleanup " - "was not attempted\n"); + &activation->binary_transaction, "install_transaction_partial_finalize"); + (void)fprintf(stderr, "error: one or more agent configurations failed; the " + "published/current executable was kept, and PATH/index cleanup " + "was not attempted\n"); return CLI_ACTIVATION_PARTIAL; } int path_rc = CLI_TRUE; #ifdef _WIN32 path_rc = cli_ensure_windows_user_path(activation->bin_dir, - activation->dry_run || - g_cli_activation_test_ops_set); + activation->dry_run || g_cli_activation_test_ops_set); if (path_rc == CLI_OK) { printf("\nAdded %s to the current-user PATH\n", activation->bin_dir); } else if (path_rc == CLI_TRUE) { @@ -8756,11 +9368,9 @@ static int cli_install_activate(void *opaque) { } #else if (activation->shell_rc[0]) { - path_rc = cbm_ensure_path(activation->bin_dir, activation->shell_rc, - activation->dry_run); + path_rc = cbm_ensure_path(activation->bin_dir, activation->shell_rc, activation->dry_run); if (path_rc == 0) { - printf("\nAdded %s to PATH in %s\n", activation->bin_dir, - activation->shell_rc); + printf("\nAdded %s to PATH in %s\n", activation->bin_dir, activation->shell_rc); } else if (path_rc == CLI_TRUE) { printf("\nPATH already includes %s\n", activation->bin_dir); } @@ -8768,12 +9378,9 @@ static int cli_install_activate(void *opaque) { #endif if (path_rc == CLI_ERR) { cli_activation_transaction_finalize_committed_or_fail_stop( - &activation->binary_transaction, - "install_transaction_path_failure_finalize"); - (void)fprintf( - stderr, - "error: PATH configuration failed; the published/current " - "executable was kept, and index cleanup was not attempted\n"); + &activation->binary_transaction, "install_transaction_path_failure_finalize"); + (void)fprintf(stderr, "error: PATH configuration failed; the published/current " + "executable was kept, and index cleanup was not attempted\n"); return CLI_ACTIVATION_PARTIAL; } if (!activation->dry_run && activation->delete_indexes) { @@ -8782,60 +9389,632 @@ static int cli_install_activate(void *opaque) { printf("Removed %d index(es).\n\n", removed); if (removed != expected) { cli_activation_transaction_finalize_committed_or_fail_stop( - &activation->binary_transaction, - "install_transaction_index_failure_finalize"); - (void)fprintf(stderr, - "error: only %d of %d indexes could be removed\n", - removed, expected); + &activation->binary_transaction, "install_transaction_index_failure_finalize"); + (void)fprintf(stderr, "error: only %d of %d indexes could be removed\n", removed, + expected); return CLI_ACTIVATION_PARTIAL; } } - cli_activation_transaction_finalize_committed_or_fail_stop( - &activation->binary_transaction, "install_transaction_finalize"); + cli_activation_transaction_finalize_committed_or_fail_stop(&activation->binary_transaction, + "install_transaction_finalize"); return CLI_OK; } +#endif -int cbm_cmd_install(int argc, char **argv) { - parse_auto_answer(argc, argv); - bool dry_run = false; - bool force = false; - bool plan = false; - bool reset_indexes = false; - bool skip_config = false; - const char *requested_bin_dir = NULL; - for (int i = 0; i < argc; i++) { - if (strcmp(argv[i], "--dry-run") == 0) { - dry_run = true; - } else if (strcmp(argv[i], "--force") == 0) { - force = true; - } else if (strcmp(argv[i], "--plan") == 0) { - plan = true; - } else if (strcmp(argv[i], "--reset-indexes") == 0) { - reset_indexes = true; - } else if (strcmp(argv[i], "--skip-config") == 0) { - skip_config = true; - } else if (strncmp(argv[i], "--dir=", SLEN("--dir=")) == 0) { - requested_bin_dir = argv[i] + SLEN("--dir="); - if (!requested_bin_dir[0]) { - (void)fprintf(stderr, - "error: --dir requires a non-empty path\n"); - return CLI_TRUE; - } - } else if (strcmp(argv[i], "--dir") == 0) { - if (i + 1 >= argc || !argv[i + 1] || !argv[i + 1][0] || - argv[i + 1][0] == '-') { - (void)fprintf(stderr, - "error: --dir requires a non-empty path\n"); - return CLI_TRUE; - } - requested_bin_dir = argv[++i]; - } else if (strcmp(argv[i], "-y") != 0 && - strcmp(argv[i], "--yes") != 0 && - strcmp(argv[i], "-n") != 0 && - strcmp(argv[i], "--no") != 0) { - (void)fprintf(stderr, "error: unknown install option: %s\n", - argv[i]); - return CLI_TRUE; +#ifdef _WIN32 +typedef struct { + const char *home; + const char *bin_dir; + const char *bin_target; + wchar_t canonical_launcher[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t launcher_source[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t payload_source[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + cbm_windows_current_v1_t state; + cbm_windows_current_v1_t previous_state; + cbm_windows_transition_plan_t transition_plan; + bool initial_install; + bool previous_state_valid; + bool delete_indexes; + bool skip_config; + bool force; + bool dry_run; +} cli_windows_install_activation_t; + +static bool cli_windows_current_path(const wchar_t *canonical_launcher, + wchar_t out[CBM_WINDOWS_LAUNCHER_PATH_CAP]) { + wchar_t directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t state_directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + return cli_windows_parent_path(canonical_launcher, directory, CBM_WINDOWS_LAUNCHER_PATH_CAP) && + cli_windows_join_path(directory, L".cbm", state_directory, + CBM_WINDOWS_LAUNCHER_PATH_CAP) && + cli_windows_join_path(state_directory, L"current-v1", out, + CBM_WINDOWS_LAUNCHER_PATH_CAP); +} + +static bool cli_windows_read_current(const wchar_t *canonical_launcher, + cbm_windows_current_v1_t *state_out, bool *exists_out) { + if (!state_out || !exists_out) { + return false; + } + memset(state_out, 0, sizeof(*state_out)); + *exists_out = false; + wchar_t path[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!cli_windows_current_path(canonical_launcher, path)) { + return false; + } + HANDLE file = + CreateFileW(path, GENERIC_READ | READ_CONTROL, FILE_SHARE_READ | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + if (file == INVALID_HANDLE_VALUE) { + DWORD error = GetLastError(); + return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND; + } + BY_HANDLE_FILE_INFORMATION information; + LARGE_INTEGER size; + uint8_t record[CBM_WINDOWS_CURRENT_V1_SIZE]; + DWORD received = 0; + bool valid = GetFileInformationByHandle(file, &information) != 0 && + (information.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + information.nNumberOfLinks == 1 && GetFileSizeEx(file, &size) != 0 && + size.QuadPart == CBM_WINDOWS_CURRENT_V1_SIZE && + ReadFile(file, record, sizeof(record), &received, NULL) != 0 && + received == sizeof(record) && + cbm_windows_current_v1_decode(record, sizeof(record), state_out); + (void)CloseHandle(file); + *exists_out = true; + return valid; +} + +static bool cli_windows_stage_private_file( + const char *source, const char *target, + char fingerprint_out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t status = + cbm_activation_transaction_stage_file(target, source, &transaction); + cli_binary_validator_t validator = {{0}}; + bool ready = status == CBM_ACTIVATION_TRANSACTION_OK && transaction && + cli_activation_transaction_expected_build(transaction, &validator) && + cli_activation_transaction_commit_validated(transaction, &validator, + CLI_OCTAL_PERM) == CLI_OK && + cli_activation_transaction_finalize_close(&transaction) == CLI_OK; + (void)cli_activation_transaction_abort(&transaction); + if (ready && fingerprint_out) { + (void)snprintf(fingerprint_out, CBM_DAEMON_BUILD_FINGERPRINT_SIZE, "%s", + validator.fingerprint); + } + return ready; +} + +static bool cli_windows_stage_private_bytes( + const unsigned char *bytes, size_t bytes_size, const char *target, + char fingerprint_out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!bytes || bytes_size == 0 || !target || !target[0]) { + return false; + } + cbm_activation_transaction_t *transaction = NULL; + cbm_activation_transaction_status_t status = + cbm_activation_transaction_stage_bytes(target, bytes, bytes_size, &transaction); + cli_binary_validator_t validator = {{0}}; + bool ready = status == CBM_ACTIVATION_TRANSACTION_OK && transaction && + cli_activation_transaction_expected_build(transaction, &validator) && + cli_activation_transaction_commit_validated(transaction, &validator, + CLI_OCTAL_PERM) == CLI_OK && + cli_activation_transaction_finalize_close(&transaction) == CLI_OK; + (void)cli_activation_transaction_abort(&transaction); + if (ready && fingerprint_out) { + (void)snprintf(fingerprint_out, CBM_DAEMON_BUILD_FINGERPRINT_SIZE, "%s", + validator.fingerprint); + } + return ready; +} + +static bool cli_windows_prepare_install_pair(const wchar_t *launcher_source, + const wchar_t *payload_source, + const char expected_payload_sha256[65], + wchar_t launcher_out[CBM_WINDOWS_LAUNCHER_PATH_CAP], + wchar_t payload_out[CBM_WINDOWS_LAUNCHER_PATH_CAP], + char directory_out[CLI_BUF_1K]) { + char *launcher_source_utf8 = cbm_wide_to_utf8(launcher_source); + char *payload_source_utf8 = cbm_wide_to_utf8(payload_source); + int directory_length = + snprintf(directory_out, CLI_BUF_1K, "%s/cbm-win-install-XXXXXX", cbm_tmpdir()); + bool ready = launcher_source_utf8 && payload_source_utf8 && directory_length > 0 && + directory_length < CLI_BUF_1K && cbm_mkdtemp(directory_out); + char launcher_target[CLI_BUF_1K]; + char payload_target[CLI_BUF_1K]; + int launcher_length = ready ? snprintf(launcher_target, sizeof(launcher_target), + "%s/codebase-memory-mcp.exe", directory_out) + : CLI_ERR; + int payload_length = ready ? snprintf(payload_target, sizeof(payload_target), + "%s/codebase-memory-mcp.payload.exe", directory_out) + : CLI_ERR; + ready = ready && launcher_length > 0 && (size_t)launcher_length < sizeof(launcher_target) && + payload_length > 0 && (size_t)payload_length < sizeof(payload_target); + char launcher_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + char payload_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + ready = + ready && + cli_windows_stage_private_file(launcher_source_utf8, launcher_target, + launcher_fingerprint) && + cli_windows_stage_private_file(payload_source_utf8, payload_target, payload_fingerprint) && + strcmp(payload_fingerprint, expected_payload_sha256) == 0; + const char *version_argv[] = {launcher_target, "--version", NULL}; + ready = ready && cbm_exec_no_shell(version_argv) == CLI_OK; + wchar_t *launcher_wide = ready ? cbm_utf8_to_wide(launcher_target) : NULL; + wchar_t *payload_wide = ready ? cbm_utf8_to_wide(payload_target) : NULL; + if (ready && launcher_wide && payload_wide) { + (void)_snwprintf_s(launcher_out, CBM_WINDOWS_LAUNCHER_PATH_CAP, _TRUNCATE, L"%ls", + launcher_wide); + (void)_snwprintf_s(payload_out, CBM_WINDOWS_LAUNCHER_PATH_CAP, _TRUNCATE, L"%ls", + payload_wide); + } else { + ready = false; + } + free(launcher_wide); + free(payload_wide); + free(launcher_source_utf8); + free(payload_source_utf8); + if (!ready) { + if (launcher_length > 0 && (size_t)launcher_length < sizeof(launcher_target)) { + (void)cbm_unlink(launcher_target); + } + if (payload_length > 0 && (size_t)payload_length < sizeof(payload_target)) { + (void)cbm_unlink(payload_target); + } + if (directory_out[0]) { + (void)cbm_rmdir(directory_out); + } + directory_out[0] = '\0'; + } + return ready; +} + +static void cli_windows_cleanup_install_pair(const wchar_t *launcher, const wchar_t *payload, + const char *directory) { + char *launcher_utf8 = cbm_wide_to_utf8(launcher); + char *payload_utf8 = cbm_wide_to_utf8(payload); + if (launcher_utf8) { + (void)cbm_unlink(launcher_utf8); + } + if (payload_utf8) { + (void)cbm_unlink(payload_utf8); + } + if (directory && directory[0]) { + (void)cbm_rmdir(directory); + } + free(launcher_utf8); + free(payload_utf8); +} + +static bool cli_windows_prepare_update_pair(const cbm_windows_release_pair_t *pair, + const char expected_payload_sha256[65], + wchar_t launcher_out[CBM_WINDOWS_LAUNCHER_PATH_CAP], + wchar_t payload_out[CBM_WINDOWS_LAUNCHER_PATH_CAP], + char directory_out[CLI_BUF_1K]) { + if (!pair || !pair->launcher || pair->launcher_len <= 0 || !pair->payload || + pair->payload_len <= 0 || !expected_payload_sha256) { + return false; + } + int directory_length = + snprintf(directory_out, CLI_BUF_1K, "%s/cbm-win-update-XXXXXX", cbm_tmpdir()); + bool ready = + directory_length > 0 && directory_length < CLI_BUF_1K && cbm_mkdtemp(directory_out); + char launcher_target[CLI_BUF_1K]; + char payload_target[CLI_BUF_1K]; + int launcher_length = ready ? snprintf(launcher_target, sizeof(launcher_target), + "%s/codebase-memory-mcp.exe", directory_out) + : CLI_ERR; + int payload_length = ready ? snprintf(payload_target, sizeof(payload_target), + "%s/codebase-memory-mcp.payload.exe", directory_out) + : CLI_ERR; + ready = ready && launcher_length > 0 && (size_t)launcher_length < sizeof(launcher_target) && + payload_length > 0 && (size_t)payload_length < sizeof(payload_target); + char payload_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + ready = ready && + cli_windows_stage_private_bytes(pair->launcher, (size_t)pair->launcher_len, + launcher_target, NULL) && + cli_windows_stage_private_bytes(pair->payload, (size_t)pair->payload_len, + payload_target, payload_fingerprint) && + strcmp(payload_fingerprint, expected_payload_sha256) == 0; + const char *version_argv[] = {launcher_target, "--version", NULL}; + ready = ready && cbm_exec_no_shell(version_argv) == CLI_OK; + wchar_t *launcher_wide = ready ? cbm_utf8_to_wide(launcher_target) : NULL; + wchar_t *payload_wide = ready ? cbm_utf8_to_wide(payload_target) : NULL; + if (ready && launcher_wide && payload_wide) { + int launcher_copied = _snwprintf_s(launcher_out, CBM_WINDOWS_LAUNCHER_PATH_CAP, _TRUNCATE, + L"%ls", launcher_wide); + int payload_copied = _snwprintf_s(payload_out, CBM_WINDOWS_LAUNCHER_PATH_CAP, _TRUNCATE, + L"%ls", payload_wide); + ready = launcher_copied > 0 && payload_copied > 0; + } else { + ready = false; + } + free(launcher_wide); + free(payload_wide); + if (!ready) { + if (launcher_length > 0 && (size_t)launcher_length < sizeof(launcher_target)) { + (void)cbm_unlink(launcher_target); + } + if (payload_length > 0 && (size_t)payload_length < sizeof(payload_target)) { + (void)cbm_unlink(payload_target); + } + if (directory_out[0]) { + (void)cbm_rmdir(directory_out); + } + directory_out[0] = '\0'; + } + return ready; +} + +static int cli_windows_managed_install_activate(void *opaque) { + cli_windows_install_activation_t *activation = opaque; + if (!activation || !activation->home || !activation->bin_dir || !activation->bin_target) { + return CLI_TRUE; + } + int activation_status = CLI_OK; + if (activation->dry_run) { + printf("Would install managed launcher -> %s\n\n", activation->bin_target); + } else { + bool generation_created = false; + bool launcher_committed = false; + bool current_committed = false; + char error[CLI_BUF_512] = {0}; + bool generation_ready = cli_windows_publish_generation( + activation->canonical_launcher, activation->payload_source, &activation->state, + &generation_created); + if (generation_ready && + activation->transition_plan == CBM_WINDOWS_TRANSITION_CURRENT_FIRST) { + current_committed = cbm_windows_current_v1_write_atomic( + activation->canonical_launcher, &activation->state, error, sizeof(error)); + launcher_committed = current_committed && + cbm_windows_launcher_replace_atomic(activation->canonical_launcher, + activation->launcher_source, + error, sizeof(error)); + } else if (generation_ready) { + launcher_committed = cbm_windows_launcher_replace_atomic( + activation->canonical_launcher, activation->launcher_source, error, sizeof(error)); + current_committed = launcher_committed && cbm_windows_current_v1_write_atomic( + activation->canonical_launcher, + &activation->state, error, sizeof(error)); + } + if (!generation_ready || !launcher_committed || !current_committed) { + if (activation->transition_plan == CBM_WINDOWS_TRANSITION_CURRENT_FIRST && + current_committed && !launcher_committed && activation->previous_state_valid) { + char restore_error[CLI_BUF_256] = {0}; + if (!cbm_windows_current_v1_write_atomic(activation->canonical_launcher, + &activation->previous_state, restore_error, + sizeof(restore_error))) { + (void)fprintf(stderr, + "error: managed Windows install could not restore " + "the previous current-v1 after launcher failure: %s\n", + restore_error[0] ? restore_error : "atomic restoration failed"); + } + } + if (activation->initial_install) { + char cleanup_error[CLI_BUF_256] = {0}; + (void)cbm_windows_launcher_remove_posix(activation->canonical_launcher, + cleanup_error, sizeof(cleanup_error)); + (void)cli_windows_remove_managed_state(activation->canonical_launcher); + } + char rollback_error[CLI_BUF_256] = {0}; + if (!cbm_windows_generation_rollback_if_unreferenced( + activation->canonical_launcher, activation->state.payload_sha256, + generation_created, rollback_error, sizeof(rollback_error))) { + (void)fprintf(stderr, + "error: managed Windows install generation rollback " + "failed: %s\n", + rollback_error[0] ? rollback_error + : "generation remains for safe recovery"); + } + (void)fprintf(stderr, "error: managed Windows install commit failed: %s\n", + error[0] ? error : "generation/launcher/current publish failed"); + return CLI_TRUE; + } + size_t generations_removed = 0U; + char prune_error[CLI_BUF_256] = {0}; + if (!cbm_windows_generations_prune(activation->canonical_launcher, &generations_removed, + prune_error, sizeof(prune_error))) { + (void)fprintf(stderr, + "error: managed Windows install committed, but old " + "generation pruning was incomplete: %s\n", + prune_error[0] ? prune_error : "unsafe generation entry"); + activation_status = CLI_ACTIVATION_PARTIAL; + } + printf("Installed managed launcher -> %s\n\n", activation->bin_target); + } + + if (!activation->skip_config && + cbm_install_agent_configs(activation->home, activation->bin_target, activation->force, + activation->dry_run) != CLI_OK) { + (void)fprintf(stderr, "error: managed launcher was kept, but one or more agent " + "configuration updates failed\n"); + return CLI_ACTIVATION_PARTIAL; + } + int path_status = cli_ensure_windows_user_path( + activation->bin_dir, activation->dry_run || g_cli_activation_test_ops_set); + if (path_status == CLI_ERR) { + (void)fprintf(stderr, "error: managed launcher was kept, but current-user PATH " + "configuration failed\n"); + return CLI_ACTIVATION_PARTIAL; + } + if (path_status == CLI_OK) { + printf("\nAdded %s to the current-user PATH\n", activation->bin_dir); + } else { + printf("\nPATH already includes %s\n", activation->bin_dir); + } + if (!activation->dry_run && activation->delete_indexes) { + int expected = count_db_indexes(activation->home); + int removed = cbm_remove_indexes(activation->home); + printf("Removed %d index(es).\n\n", removed); + if (removed != expected) { + (void)fprintf(stderr, "error: only %d of %d indexes could be removed\n", removed, + expected); + return CLI_ACTIVATION_PARTIAL; + } + } + return activation_status; +} + +static int cli_windows_managed_install(const char *home, const char *requested_bin_dir, + bool dry_run, bool force, bool reset_indexes, + bool skip_config) { + char default_dir[CLI_BUF_1K]; + const char *directory_input = requested_bin_dir; + if (!directory_input) { + int written = snprintf(default_dir, sizeof(default_dir), "%s/.local/bin", home); + if (written <= 0 || (size_t)written >= sizeof(default_dir)) { + return CLI_TRUE; + } + directory_input = default_dir; + } + + wchar_t *requested_wide = cli_windows_utf8_to_wide(directory_input); + wchar_t full_directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + DWORD full_length = + requested_wide + ? GetFullPathNameW(requested_wide, CBM_WINDOWS_LAUNCHER_PATH_CAP, full_directory, NULL) + : 0; + free(requested_wide); + if (full_length == 0 || full_length >= CBM_WINDOWS_LAUNCHER_PATH_CAP) { + (void)fprintf(stderr, "error: install directory could not be resolved\n"); + return CLI_TRUE; + } + + wchar_t canonical_launcher[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t payload_source[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t launcher_source[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!cli_windows_join_path(full_directory, L"codebase-memory-mcp.exe", canonical_launcher, + CBM_WINDOWS_LAUNCHER_PATH_CAP) || + !cli_windows_module_path(payload_source) || + !cli_windows_regular_file_no_reparse(payload_source, NULL) || + !cli_windows_install_source_launcher(payload_source, launcher_source)) { + (void)fprintf(stderr, "error: managed install requires a verified adjacent " + "codebase-memory-mcp.exe launcher (or an authenticated existing " + "managed launcher)\n"); + return CLI_TRUE; + } + + char *bin_dir = cbm_wide_to_utf8(full_directory); + char *bin_target = cbm_wide_to_utf8(canonical_launcher); + char *payload_utf8 = cbm_wide_to_utf8(payload_source); + char *launcher_utf8 = cbm_wide_to_utf8(launcher_source); + char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + char launcher_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + uint64_t payload_size = 0; + bool sources_valid = bin_dir && bin_target && payload_utf8 && launcher_utf8 && + cli_windows_regular_file_no_reparse(payload_source, &payload_size) && + cbm_daemon_build_fingerprint_file(payload_utf8, fingerprint) && + cbm_daemon_build_fingerprint_file(launcher_utf8, launcher_fingerprint); + if (!sources_valid) { + (void)fprintf(stderr, "error: launcher/payload source verification failed before " + "managed install\n"); + free(bin_dir); + free(bin_target); + free(payload_utf8); + free(launcher_utf8); + return CLI_TRUE; + } + + wchar_t prepared_launcher[CBM_WINDOWS_LAUNCHER_PATH_CAP] = {0}; + wchar_t prepared_payload[CBM_WINDOWS_LAUNCHER_PATH_CAP] = {0}; + char prepared_directory[CLI_BUF_1K] = {0}; + bool pair_prepared = false; + cbm_windows_release_descriptor_v1_t descriptor = { + .launcher_abi = CBM_WINDOWS_LAUNCHER_ABI_CURRENT, + .payload_launcher_abi_min = CBM_WINDOWS_PAYLOAD_LAUNCHER_ABI_MIN, + .payload_launcher_abi_max = CBM_WINDOWS_PAYLOAD_LAUNCHER_ABI_MAX, + .payload_size = payload_size, + }; + (void)snprintf(descriptor.payload_sha256, sizeof(descriptor.payload_sha256), "%s", fingerprint); + + cbm_windows_current_v1_t previous; + memset(&previous, 0, sizeof(previous)); + bool current_exists = false; + bool current_valid = cli_windows_read_current(canonical_launcher, &previous, ¤t_exists); + bool launcher_exists = cli_windows_regular_file_no_reparse(canonical_launcher, NULL); + const char *current_version_argv[] = {bin_target, "--version", NULL}; + char target_error[CLI_BUF_256] = {0}; + bool current_target_secure = + !launcher_exists || + cbm_windows_launcher_file_secure(canonical_launcher, target_error, sizeof(target_error)); + char installed_launcher_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + /* A crash between the fresh install's atomic launcher publication and + * current-v1 publication leaves one recognizable partial state. Repair it + * only when the visible launcher is still private and byte-identical to + * this candidate; an arbitrary launcher/current mismatch remains a hard + * conflict. The guarded activation republishes the launcher and current-v1 + * before it reports success. */ + bool interrupted_initial_install = + current_valid && !current_exists && launcher_exists && current_target_secure && + cbm_daemon_build_fingerprint_file(bin_target, installed_launcher_fingerprint) && + strcmp(installed_launcher_fingerprint, launcher_fingerprint) == 0; + bool current_pair_runnable = + !current_exists || + (current_target_secure && cbm_exec_no_shell(current_version_argv) == CLI_OK); + if (!current_valid || (current_exists != launcher_exists && !interrupted_initial_install) || + !current_target_secure || !current_pair_runnable) { + (void)fprintf(stderr, + "error: target is not an intact compatible managed Windows " + "installation; remove the conflicting files before install%s%s\n", + target_error[0] ? ": " : "", target_error[0] ? target_error : ""); + free(bin_dir); + free(bin_target); + free(payload_utf8); + free(launcher_utf8); + return CLI_TRUE; + } + + if (!dry_run) { + pair_prepared = cli_windows_prepare_install_pair(launcher_source, payload_source, + fingerprint, prepared_launcher, + prepared_payload, prepared_directory); + if (!pair_prepared) { + (void)fprintf(stderr, "error: launcher/payload source pair did not remain " + "runnable after private staging\n"); + free(bin_dir); + free(bin_target); + free(payload_utf8); + free(launcher_utf8); + return CLI_TRUE; + } + char probe_error[CLI_BUF_512] = {0}; + if (!cbm_windows_release_descriptor_probe(prepared_launcher, &descriptor, probe_error, + sizeof(probe_error)) || + descriptor.payload_size != payload_size || + strcmp(descriptor.payload_sha256, fingerprint) != 0) { + (void)fprintf(stderr, + "error: managed Windows install release descriptor is " + "invalid or does not match the staged payload before " + "stopping CBM sessions: %s\n", + probe_error[0] ? probe_error : "payload identity mismatch"); + cli_windows_cleanup_install_pair(prepared_launcher, prepared_payload, + prepared_directory); + free(bin_dir); + free(bin_target); + free(payload_utf8); + free(launcher_utf8); + return CLI_TRUE; + } + cbm_windows_transition_plan_t candidate_plan = + current_exists ? cbm_windows_transition_plan(&previous, &descriptor) + : CBM_WINDOWS_TRANSITION_LAUNCHER_FIRST; + if (candidate_plan == CBM_WINDOWS_TRANSITION_INCOMPATIBLE) { + (void)fprintf(stderr, "error: managed Windows install requires an intermediate " + "launcher ABI bridge; active CBM sessions were not stopped\n"); + cli_windows_cleanup_install_pair(prepared_launcher, prepared_payload, + prepared_directory); + free(bin_dir); + free(bin_target); + free(payload_utf8); + free(launcher_utf8); + return CLI_TRUE; + } + if (!cbm_windows_launcher_capability_probe(full_directory, prepared_launcher, probe_error, + sizeof(probe_error))) { + (void)fprintf(stderr, + "error: managed launcher capability probe failed before " + "stopping CBM sessions: %s\n", + probe_error[0] ? probe_error + : "local fixed NTFS atomic replacement is unavailable"); + cli_windows_cleanup_install_pair(prepared_launcher, prepared_payload, + prepared_directory); + free(bin_dir); + free(bin_target); + free(payload_utf8); + free(launcher_utf8); + return CLI_TRUE; + } + } + + bool delete_indexes = false; + if (cbm_install_prepare_existing_indexes(home, reset_indexes, dry_run, &delete_indexes) == 0) { + if (pair_prepared) { + cli_windows_cleanup_install_pair(prepared_launcher, prepared_payload, + prepared_directory); + } + free(bin_dir); + free(bin_target); + free(payload_utf8); + free(launcher_utf8); + return CLI_TRUE; + } + cbm_windows_current_v1_t state = { + .launcher_abi_min = descriptor.payload_launcher_abi_min, + .launcher_abi_max = descriptor.payload_launcher_abi_max, + .payload_size = descriptor.payload_size, + }; + (void)snprintf(state.payload_sha256, sizeof(state.payload_sha256), "%s", fingerprint); + cli_windows_install_activation_t activation = { + .home = home, + .bin_dir = bin_dir, + .bin_target = bin_target, + .state = state, + .previous_state = previous, + .transition_plan = current_exists ? cbm_windows_transition_plan(&previous, &descriptor) + : CBM_WINDOWS_TRANSITION_LAUNCHER_FIRST, + .initial_install = !current_exists, + .previous_state_valid = current_exists, + .delete_indexes = delete_indexes, + .skip_config = skip_config, + .force = force, + .dry_run = dry_run, + }; + memcpy(activation.canonical_launcher, canonical_launcher, sizeof(canonical_launcher)); + memcpy(activation.launcher_source, pair_prepared ? prepared_launcher : launcher_source, + sizeof(launcher_source)); + memcpy(activation.payload_source, pair_prepared ? prepared_payload : payload_source, + sizeof(payload_source)); + int result = dry_run ? cli_windows_managed_install_activate(&activation) + : cli_activation_guard(CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL, CBM_VERSION, + state.payload_sha256, + cli_windows_managed_install_activate, &activation); + if (pair_prepared) { + cli_windows_cleanup_install_pair(prepared_launcher, prepared_payload, prepared_directory); + } + free(bin_dir); + free(bin_target); + free(payload_utf8); + free(launcher_utf8); + return result == CLI_OK ? CLI_OK : CLI_TRUE; +} +#endif + +int cbm_cmd_install(int argc, char **argv) { + parse_auto_answer(argc, argv); + bool dry_run = false; + bool force = false; + bool plan = false; + bool reset_indexes = false; + bool skip_config = false; + const char *requested_bin_dir = NULL; + for (int i = 0; i < argc; i++) { + if (strcmp(argv[i], "--dry-run") == 0) { + dry_run = true; + } else if (strcmp(argv[i], "--force") == 0) { + force = true; + } else if (strcmp(argv[i], "--plan") == 0) { + plan = true; + } else if (strcmp(argv[i], "--reset-indexes") == 0) { + reset_indexes = true; + } else if (strcmp(argv[i], "--skip-config") == 0) { + skip_config = true; + } else if (strncmp(argv[i], "--dir=", SLEN("--dir=")) == 0) { + requested_bin_dir = argv[i] + SLEN("--dir="); + if (!requested_bin_dir[0]) { + (void)fprintf(stderr, "error: --dir requires a non-empty path\n"); + return CLI_TRUE; + } + } else if (strcmp(argv[i], "--dir") == 0) { + if (i + 1 >= argc || !argv[i + 1] || !argv[i + 1][0] || argv[i + 1][0] == '-') { + (void)fprintf(stderr, "error: --dir requires a non-empty path\n"); + return CLI_TRUE; + } + requested_bin_dir = argv[++i]; + } else if (strcmp(argv[i], "-y") != 0 && strcmp(argv[i], "--yes") != 0 && + strcmp(argv[i], "-n") != 0 && strcmp(argv[i], "--no") != 0) { + (void)fprintf(stderr, "error: unknown install option: %s\n", argv[i]); + return CLI_TRUE; } } @@ -8847,10 +10026,8 @@ int cbm_cmd_install(int argc, char **argv) { char bin_dir[CLI_BUF_1K]; int bin_dir_length = requested_bin_dir - ? snprintf(bin_dir, sizeof(bin_dir), "%s", - requested_bin_dir) - : snprintf(bin_dir, sizeof(bin_dir), - "%s/.local/bin", home); + ? snprintf(bin_dir, sizeof(bin_dir), "%s", requested_bin_dir) + : snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", home); if (bin_dir_length <= 0 || (size_t)bin_dir_length >= sizeof(bin_dir)) { (void)fprintf(stderr, "error: install directory path is too long\n"); return CLI_TRUE; @@ -8858,11 +10035,10 @@ int cbm_cmd_install(int argc, char **argv) { cbm_normalize_path_sep(bin_dir); char bin_target[CLI_BUF_1K]; #ifdef _WIN32 - int target_length = snprintf(bin_target, sizeof(bin_target), - "%s/codebase-memory-mcp.exe", bin_dir); + int target_length = + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp.exe", bin_dir); #else - int target_length = snprintf(bin_target, sizeof(bin_target), - "%s/codebase-memory-mcp", bin_dir); + int target_length = snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp", bin_dir); #endif if (target_length <= 0 || (size_t)target_length >= sizeof(bin_target)) { (void)fprintf(stderr, "error: install target path is too long\n"); @@ -8873,8 +10049,7 @@ int cbm_cmd_install(int argc, char **argv) { * mutating anything (no config writes, no index deletion, no network) so * an agent can inspect exactly what install would touch first (#388). */ if (plan) { - char *json = cbm_build_install_plan_json_options( - home, bin_target, skip_config); + char *json = cbm_build_install_plan_json_options(home, bin_target, skip_config); if (!json) { (void)fprintf(stderr, "error: failed to build install plan\n"); return CLI_TRUE; @@ -8886,6 +10061,12 @@ int cbm_cmd_install(int argc, char **argv) { printf("codebase-memory-mcp install %s\n\n", CBM_VERSION); +#ifdef _WIN32 + return cli_windows_managed_install(home, requested_bin_dir, dry_run, force, reset_indexes, + skip_config); +#endif + +#ifndef _WIN32 char self_path[CLI_BUF_1K] = {0}; cbm_detect_self_path(self_path, sizeof(self_path), home); @@ -8948,42 +10129,33 @@ int cbm_cmd_install(int argc, char **argv) { #endif const char *stage_target = bin_target; if (prepare_out_of_line) { - int dir_length = snprintf( - prepared_dir, sizeof(prepared_dir), "%s/cbm-install-XXXXXX", - cbm_tmpdir()); + int dir_length = + snprintf(prepared_dir, sizeof(prepared_dir), "%s/cbm-install-XXXXXX", cbm_tmpdir()); if (dir_length <= 0 || (size_t)dir_length >= sizeof(prepared_dir) || !cbm_mkdtemp(prepared_dir)) { - (void)fprintf(stderr, - "error: cannot create private install staging " - "directory\n"); + (void)fprintf(stderr, "error: cannot create private install staging " + "directory\n"); return CLI_TRUE; } #ifdef _WIN32 - int candidate_length = snprintf( - prepared_candidate, sizeof(prepared_candidate), - "%s/codebase-memory-mcp.exe", prepared_dir); + int candidate_length = snprintf(prepared_candidate, sizeof(prepared_candidate), + "%s/codebase-memory-mcp.exe", prepared_dir); #else - int candidate_length = snprintf( - prepared_candidate, sizeof(prepared_candidate), - "%s/codebase-memory-mcp", prepared_dir); + int candidate_length = snprintf(prepared_candidate, sizeof(prepared_candidate), + "%s/codebase-memory-mcp", prepared_dir); #endif - if (candidate_length <= 0 || - (size_t)candidate_length >= sizeof(prepared_candidate)) { + if (candidate_length <= 0 || (size_t)candidate_length >= sizeof(prepared_candidate)) { (void)cbm_rmdir(prepared_dir); - (void)fprintf(stderr, - "error: private install staging path is too long\n"); + (void)fprintf(stderr, "error: private install staging path is too long\n"); return CLI_TRUE; } stage_target = prepared_candidate; } cbm_activation_transaction_status_t stage_status = - cbm_activation_transaction_stage_file( - stage_target, candidate, &binary_transaction); + cbm_activation_transaction_stage_file(stage_target, candidate, &binary_transaction); cli_binary_validator_t staged_validator = {{0}}; - if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || - !binary_transaction || - !cli_activation_transaction_expected_build( - binary_transaction, &staged_validator)) { + if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || !binary_transaction || + !cli_activation_transaction_expected_build(binary_transaction, &staged_validator)) { (void)fprintf(stderr, "error: failed to stage install candidate: %s\n", cbm_activation_transaction_status_message(stage_status)); (void)cli_activation_transaction_abort(&binary_transaction); @@ -8993,62 +10165,48 @@ int cbm_cmd_install(int argc, char **argv) { return CLI_TRUE; } if (prepare_out_of_line) { - if (cli_activation_transaction_commit_validated( - binary_transaction, &staged_validator, - CLI_OCTAL_PERM) != CLI_OK || - cli_activation_transaction_finalize_close( - &binary_transaction) != CLI_OK) { + if (cli_activation_transaction_commit_validated(binary_transaction, &staged_validator, + CLI_OCTAL_PERM) != CLI_OK || + cli_activation_transaction_finalize_close(&binary_transaction) != CLI_OK) { (void)cli_activation_transaction_abort(&binary_transaction); (void)cbm_unlink(prepared_candidate); (void)cbm_rmdir(prepared_dir); - (void)fprintf(stderr, - "error: private install candidate preparation " - "failed\n"); + (void)fprintf(stderr, "error: private install candidate preparation " + "failed\n"); return CLI_TRUE; } #ifdef __APPLE__ - if (sign_binary && - cbm_macos_adhoc_sign(prepared_candidate) != 0) { - (void)fprintf( - stderr, - "error: ad-hoc signing the private macOS candidate failed\n"); + if (sign_binary && cbm_macos_adhoc_sign(prepared_candidate) != 0) { + (void)fprintf(stderr, "error: ad-hoc signing the private macOS candidate failed\n"); (void)cbm_unlink(prepared_candidate); (void)cbm_rmdir(prepared_dir); return CLI_TRUE; } #endif - has_binary_validator = cbm_daemon_build_fingerprint_file( - prepared_candidate, binary_validator.fingerprint); + has_binary_validator = + cbm_daemon_build_fingerprint_file(prepared_candidate, binary_validator.fingerprint); if (has_binary_validator && !g_cli_activation_test_ops_set) { - const char *candidate_argv[] = { - prepared_candidate, "--version", NULL}; - has_binary_validator = - cbm_exec_no_shell(candidate_argv) == CLI_OK; + const char *candidate_argv[] = {prepared_candidate, "--version", NULL}; + has_binary_validator = cbm_exec_no_shell(candidate_argv) == CLI_OK; } if (!has_binary_validator) { - (void)fprintf(stderr, - "error: prepared install candidate could not be " - "verified\n"); + (void)fprintf(stderr, "error: prepared install candidate could not be " + "verified\n"); (void)cbm_unlink(prepared_candidate); (void)cbm_rmdir(prepared_dir); return CLI_TRUE; } if (target_parent_exists) { - stage_status = cbm_activation_transaction_stage_file( - bin_target, prepared_candidate, &binary_transaction); + stage_status = cbm_activation_transaction_stage_file(bin_target, prepared_candidate, + &binary_transaction); cli_binary_validator_t final_validator = {{0}}; - if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || - !binary_transaction || - !cli_activation_transaction_expected_build( - binary_transaction, &final_validator) || - strcmp(final_validator.fingerprint, - binary_validator.fingerprint) != 0) { - (void)fprintf( - stderr, - "error: signed install candidate could not be staged " - "on the target filesystem\n"); - (void)cli_activation_transaction_abort( - &binary_transaction); + if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || !binary_transaction || + !cli_activation_transaction_expected_build(binary_transaction, + &final_validator) || + strcmp(final_validator.fingerprint, binary_validator.fingerprint) != 0) { + (void)fprintf(stderr, "error: signed install candidate could not be staged " + "on the target filesystem\n"); + (void)cli_activation_transaction_abort(&binary_transaction); (void)cbm_unlink(prepared_candidate); (void)cbm_rmdir(prepared_dir); return CLI_TRUE; @@ -9060,8 +10218,7 @@ int cbm_cmd_install(int argc, char **argv) { has_binary_validator = true; } if (!has_binary_validator) { - (void)fprintf(stderr, - "error: staged install candidate could not be verified\n"); + (void)fprintf(stderr, "error: staged install candidate could not be verified\n"); if (binary_transaction) { (void)cli_activation_transaction_abort(&binary_transaction); } @@ -9083,9 +10240,7 @@ int cbm_cmd_install(int argc, char **argv) { .bin_dir = bin_dir, .home = home, .shell_rc = shell_rc, - .prepared_candidate = prepared_candidate[0] - ? prepared_candidate - : NULL, + .prepared_candidate = prepared_candidate[0] ? prepared_candidate : NULL, .binary_transaction = binary_transaction, .binary_validator = binary_validator, .has_binary_validator = has_binary_validator, @@ -9095,17 +10250,13 @@ int cbm_cmd_install(int argc, char **argv) { .force = force, .dry_run = dry_run, }; - int activation_rc = dry_run ? cli_install_activate(&activation) - : cli_activation_guard( - CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL, - CBM_VERSION, - has_binary_validator - ? binary_validator.fingerprint - : NULL, - cli_install_activate, &activation); + int activation_rc = + dry_run ? cli_install_activate(&activation) + : cli_activation_guard(CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL, CBM_VERSION, + has_binary_validator ? binary_validator.fingerprint : NULL, + cli_install_activate, &activation); if (activation.binary_transaction) { - (void)cli_activation_transaction_abort( - &activation.binary_transaction); + (void)cli_activation_transaction_abort(&activation.binary_transaction); } if (prepared_candidate[0]) { (void)cbm_unlink(prepared_candidate); @@ -9126,6 +10277,7 @@ int cbm_cmd_install(int argc, char **argv) { printf("\n(dry-run — no files were modified)\n"); } return 0; +#endif } /* ── Subcommand: uninstall ────────────────────────────────────── */ @@ -10227,6 +11379,7 @@ static void uninstall_additional_agents(const cbm_detected_agents_t *agents, con } } +#ifndef _WIN32 typedef struct { const char *home; const char *bin_path; @@ -10252,18 +11405,14 @@ static int cli_uninstall_activate(void *opaque) { } uninstall_cli_agents(&activation->agents, activation->home, activation->dry_run); uninstall_editor_agents(&activation->agents, activation->home, activation->dry_run); - uninstall_additional_agents(&activation->agents, activation->home, - activation->dry_run); + uninstall_additional_agents(&activation->agents, activation->home, activation->dry_run); uninstall_agent_client_registry(activation->home, activation->dry_run); if (g_agent_uninstall_errors != 0) { - cli_activation_transaction_abort_or_fail_stop( - &activation->binary_transaction, - "uninstall_transaction_config_cleanup_abort"); - (void)fprintf( - stderr, - "error: one or more agent cleanup operations failed; executable " - "and index removal were not started\n"); + cli_activation_transaction_abort_or_fail_stop(&activation->binary_transaction, + "uninstall_transaction_config_cleanup_abort"); + (void)fprintf(stderr, "error: one or more agent cleanup operations failed; executable " + "and index removal were not started\n"); return CLI_ACTIVATION_PARTIAL; } @@ -10273,34 +11422,169 @@ static int cli_uninstall_activate(void *opaque) { printf("Removed %d index(es).\n", idx_removed); if (idx_removed != expected) { cli_activation_transaction_abort_or_fail_stop( - &activation->binary_transaction, - "uninstall_transaction_index_failure_abort"); - (void)fprintf(stderr, - "error: only %d of %d indexes could be removed\n", - idx_removed, expected); + &activation->binary_transaction, "uninstall_transaction_index_failure_abort"); + (void)fprintf(stderr, "error: only %d of %d indexes could be removed\n", idx_removed, + expected); return CLI_ACTIVATION_PARTIAL; } } if (!activation->dry_run && activation->binary_transaction) { - if (cli_activation_transaction_commit_removal( - activation->binary_transaction) != CLI_OK) { - cli_activation_transaction_abort_or_fail_stop( - &activation->binary_transaction, - "uninstall_transaction_removal_recovery"); - (void)fprintf(stderr, "error: failed to remove %s; completed " - "configuration/index cleanup may remain\n", + if (cli_activation_transaction_commit_removal(activation->binary_transaction) != CLI_OK) { + cli_activation_transaction_abort_or_fail_stop(&activation->binary_transaction, + "uninstall_transaction_removal_recovery"); + (void)fprintf(stderr, + "error: failed to remove %s; completed " + "configuration/index cleanup may remain\n", activation->bin_path); return CLI_ACTIVATION_PARTIAL; } cli_activation_transaction_finalize_committed_or_fail_stop( - &activation->binary_transaction, - "uninstall_transaction_removal_finalize"); + &activation->binary_transaction, "uninstall_transaction_removal_finalize"); } if (activation->binary_exists) { printf("Removed %s\n", activation->bin_path); } return CLI_OK; } +#endif + +#ifdef _WIN32 +typedef struct { + const char *home; + const char *canonical_launcher_utf8; + wchar_t canonical_launcher[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + cbm_detected_agents_t agents; + bool delete_indexes; + bool dry_run; +} cli_windows_uninstall_activation_t; + +static int cli_windows_managed_uninstall_activate(void *opaque) { + cli_windows_uninstall_activation_t *activation = opaque; + if (!activation || !activation->home || !activation->canonical_launcher_utf8) { + return CLI_TRUE; + } + if (activation->agents.claude_code) { + uninstall_claude_code(activation->home, activation->dry_run); + } + uninstall_cli_agents(&activation->agents, activation->home, activation->dry_run); + uninstall_editor_agents(&activation->agents, activation->home, activation->dry_run); + uninstall_additional_agents(&activation->agents, activation->home, activation->dry_run); + uninstall_agent_client_registry(activation->home, activation->dry_run); + if (g_agent_uninstall_errors != 0) { + (void)fprintf(stderr, "error: one or more agent cleanup operations failed; managed " + "launcher and generation state were kept\n"); + return CLI_ACTIVATION_PARTIAL; + } + if (activation->dry_run) { + printf("Would remove managed launcher and generation state -> %s\n", + activation->canonical_launcher_utf8); + return CLI_OK; + } + + char error[CLI_BUF_512] = {0}; + if (!cbm_windows_launcher_remove_posix(activation->canonical_launcher, error, sizeof(error))) { + (void)fprintf(stderr, "error: managed launcher removal failed: %s\n", + error[0] ? error : "atomic POSIX unlink unavailable"); + return CLI_TRUE; + } + printf("Removed %s\n", activation->canonical_launcher_utf8); + + /* The visible launcher unlink is the uninstall commit point. The private + * launcher/payload copies are not inside this tree, so cleanup is + * synchronous and no installed image remains mapped. */ + if (!cli_windows_remove_managed_state(activation->canonical_launcher)) { + (void)fprintf(stderr, "error: launcher was removed, but managed current/generation " + "cleanup did not complete\n"); + return CLI_ACTIVATION_PARTIAL; + } + if (activation->delete_indexes) { + int expected = count_db_indexes(activation->home); + int removed = cbm_remove_indexes(activation->home); + printf("Removed %d index(es).\n", removed); + if (removed != expected) { + (void)fprintf(stderr, "error: only %d of %d indexes could be removed\n", removed, + expected); + return CLI_ACTIVATION_PARTIAL; + } + } + return CLI_OK; +} + +static int cli_windows_managed_uninstall(const char *home, bool dry_run) { + if (!g_windows_launcher_context.present || !g_windows_launcher_context.managed || + !g_windows_launcher_context.private_activation || + g_windows_launcher_context.action != CBM_WINDOWS_LAUNCHER_ACTION_UNINSTALL) { + return CLI_TRUE; + } + cbm_windows_current_v1_t current; + bool current_exists = false; + if (!cli_windows_read_current(g_windows_launcher_context.canonical_launcher_path, ¤t, + ¤t_exists) || + !current_exists || + !cbm_windows_current_v1_supports_launcher_abi(¤t, CBM_WINDOWS_LAUNCHER_ABI_CURRENT) || + current.payload_size != g_windows_launcher_context.payload_size || + strcmp(current.payload_sha256, g_windows_launcher_context.expected_payload_sha256) != 0 || + !cli_windows_regular_file_no_reparse(g_windows_launcher_context.canonical_launcher_path, + NULL)) { + (void)fprintf(stderr, "error: authenticated managed launcher state changed before " + "uninstall; no sessions were stopped\n"); + return CLI_TRUE; + } + wchar_t target_directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!cli_windows_parent_path(g_windows_launcher_context.canonical_launcher_path, + target_directory, CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + return CLI_TRUE; + } + if (!dry_run) { + char probe_error[CLI_BUF_512] = {0}; + if (!cbm_windows_launcher_capability_probe( + target_directory, g_windows_launcher_context.canonical_launcher_path, probe_error, + sizeof(probe_error))) { + (void)fprintf(stderr, + "error: managed launcher capability probe failed before " + "stopping CBM sessions: %s\n", + probe_error[0] ? probe_error + : "local fixed NTFS atomic removal is unavailable"); + return CLI_TRUE; + } + } + + bool delete_indexes = false; + int index_count = count_db_indexes(home); + if (index_count > 0) { + printf("\nFound %d index(es):\n", index_count); + cbm_list_indexes(home); + if (prompt_yn("Delete these indexes?")) { + delete_indexes = !dry_run; + if (dry_run) { + printf("(dry-run — indexes would be deleted)\n"); + } + } else { + printf("Indexes kept.\n"); + } + } + char *canonical_utf8 = cbm_wide_to_utf8(g_windows_launcher_context.canonical_launcher_path); + if (!canonical_utf8) { + return CLI_TRUE; + } + g_agent_uninstall_errors = 0; + cli_windows_uninstall_activation_t activation = { + .home = home, + .canonical_launcher_utf8 = canonical_utf8, + .agents = cbm_detect_agents(home), + .delete_indexes = delete_indexes, + .dry_run = dry_run, + }; + memcpy(activation.canonical_launcher, g_windows_launcher_context.canonical_launcher_path, + sizeof(activation.canonical_launcher)); + int result = dry_run + ? cli_windows_managed_uninstall_activate(&activation) + : cli_activation_guard(CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL, NULL, NULL, + cli_windows_managed_uninstall_activate, &activation); + free(canonical_utf8); + return result; +} +#endif int cbm_cmd_uninstall(int argc, char **argv) { parse_auto_answer(argc, argv); @@ -10313,16 +11597,22 @@ int cbm_cmd_uninstall(int argc, char **argv) { } if (strcmp(argv[i], "--dry-run") == 0) { dry_run = true; - } else if (strcmp(argv[i], "-y") != 0 && - strcmp(argv[i], "--yes") != 0 && - strcmp(argv[i], "-n") != 0 && - strcmp(argv[i], "--no") != 0) { - (void)fprintf(stderr, "error: unknown uninstall option: %s\n", - argv[i]); + } else if (strcmp(argv[i], "-y") != 0 && strcmp(argv[i], "--yes") != 0 && + strcmp(argv[i], "-n") != 0 && strcmp(argv[i], "--no") != 0) { + (void)fprintf(stderr, "error: unknown uninstall option: %s\n", argv[i]); return CLI_TRUE; } } +#ifdef _WIN32 + /* A direct/package-manager payload is a one-shot portable instance. Fail + * before HOME/cache discovery, prompts, daemon IPC, or filesystem writes: + * only the permanent launcher may authorize managed removal. */ + if (!cli_windows_require_managed_mutation(CBM_WINDOWS_LAUNCHER_ACTION_UNINSTALL)) { + return CLI_TRUE; + } +#endif + const char *home = cbm_get_home_dir(); if (!home) { (void)fprintf(stderr, "error: HOME not set (use USERPROFILE on Windows)\n"); @@ -10331,6 +11621,16 @@ int cbm_cmd_uninstall(int argc, char **argv) { printf("codebase-memory-mcp uninstall\n\n"); +#ifdef _WIN32 + int windows_result = cli_windows_managed_uninstall(home, dry_run); + if (windows_result == CLI_OK) { + printf("\nUninstall complete. Please restart your coding-agent " + "sessions to properly take this into account.\n"); + } + return windows_result == CLI_OK ? CLI_OK : CLI_TRUE; +#endif + +#ifndef _WIN32 g_agent_uninstall_errors = 0; cbm_detected_agents_t agents = cbm_detect_agents(home); @@ -10352,37 +11652,17 @@ int cbm_cmd_uninstall(int argc, char **argv) { } } - char bin_path[CLI_BUF_1K]; -#ifdef _WIN32 - snprintf(bin_path, sizeof(bin_path), - "%s/.local/bin/codebase-memory-mcp.exe", home); -#else - snprintf(bin_path, sizeof(bin_path), - "%s/.local/bin/codebase-memory-mcp", home); -#endif + char bin_path_storage[CLI_BUF_1K]; + const char *bin_path = bin_path_storage; + snprintf(bin_path_storage, sizeof(bin_path_storage), "%s/.local/bin/codebase-memory-mcp", home); struct stat binary_status; bool binary_exists = stat(bin_path, &binary_status) == 0; cbm_activation_transaction_t *binary_transaction = NULL; if (!dry_run && binary_exists) { -#ifdef _WIN32 - char self_path[CLI_BUF_1K] = {0}; - cbm_detect_self_path(self_path, sizeof(self_path), home); - if (cbm_same_file(self_path, bin_path)) { - (void)fprintf( - stderr, - "error: Windows cannot remove the executable that is running " - "this uninstall. Run uninstall from a verified candidate copy; " - "no CBM sessions were stopped.\n"); - return CLI_TRUE; - } -#endif cbm_activation_transaction_status_t stage_status = - cbm_activation_transaction_stage_removal( - bin_path, &binary_transaction); - if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || - !binary_transaction) { - (void)fprintf(stderr, - "error: failed to stage uninstall transaction: %s\n", + cbm_activation_transaction_stage_removal(bin_path, &binary_transaction); + if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || !binary_transaction) { + (void)fprintf(stderr, "error: failed to stage uninstall transaction: %s\n", cbm_activation_transaction_status_message(stage_status)); (void)cli_activation_transaction_abort(&binary_transaction); return CLI_TRUE; @@ -10397,14 +11677,15 @@ int cbm_cmd_uninstall(int argc, char **argv) { .delete_indexes = delete_indexes, .dry_run = dry_run, }; - int activation_rc = - dry_run ? cli_uninstall_activate(&activation) - : cli_activation_guard( - CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL, NULL, NULL, - cli_uninstall_activate, &activation); + int activation_rc; + if (dry_run) { + activation_rc = cli_uninstall_activate(&activation); + } else { + activation_rc = cli_activation_guard(CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL, NULL, NULL, + cli_uninstall_activate, &activation); + } if (activation.binary_transaction) { - (void)cli_activation_transaction_abort( - &activation.binary_transaction); + (void)cli_activation_transaction_abort(&activation.binary_transaction); } if (activation_rc != CLI_OK) { return CLI_TRUE; @@ -10416,6 +11697,7 @@ int cbm_cmd_uninstall(int argc, char **argv) { printf("(dry-run — no files were modified)\n"); } return g_agent_uninstall_errors == 0 ? 0 : CLI_TRUE; +#endif } /* ── Subcommand: update ───────────────────────────────────────── */ @@ -10431,6 +11713,289 @@ typedef struct { bool delete_indexes; } extract_install_args_t; +#ifdef _WIN32 +typedef struct { + const char *home; + const char *canonical_launcher_utf8; + wchar_t canonical_launcher[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + cbm_windows_release_pair_t pair; + cbm_windows_current_v1_t state; + cbm_windows_current_v1_t previous_state; + cbm_windows_transition_plan_t transition_plan; + bool delete_indexes; +} cli_windows_update_activation_t; + +static int cli_windows_managed_update_activate(void *opaque) { + cli_windows_update_activation_t *activation = opaque; + if (!activation || !activation->home || !activation->canonical_launcher_utf8 || + !activation->pair.launcher || !activation->pair.payload) { + return CLI_TRUE; + } + cbm_windows_current_v1_t observed; + bool observed_exists = false; + if (!cli_windows_read_current(activation->canonical_launcher, &observed, &observed_exists) || + !observed_exists || + observed.launcher_abi_min != activation->previous_state.launcher_abi_min || + observed.launcher_abi_max != activation->previous_state.launcher_abi_max || + observed.payload_size != activation->previous_state.payload_size || + strcmp(observed.payload_sha256, activation->previous_state.payload_sha256) != 0 || + activation->transition_plan == CBM_WINDOWS_TRANSITION_INCOMPATIBLE) { + (void)fprintf(stderr, "error: managed Windows state changed before update " + "activation; no generation was published\n"); + return CLI_TRUE; + } + wchar_t candidate[CBM_WINDOWS_LAUNCHER_PATH_CAP] = {0}; + char error[CLI_BUF_512] = {0}; + bool candidate_ready = cli_windows_stage_launcher_candidate( + activation->canonical_launcher, activation->state.payload_sha256, activation->pair.launcher, + (size_t)activation->pair.launcher_len, candidate); + bool generation_created = false; + bool generation_ready = + candidate_ready && + cli_windows_publish_generation_bytes( + activation->canonical_launcher, &activation->state, activation->pair.payload, + (size_t)activation->pair.payload_len, &generation_created); + bool launcher_committed = false; + bool current_committed = false; + if (generation_ready && activation->transition_plan == CBM_WINDOWS_TRANSITION_CURRENT_FIRST) { + current_committed = cbm_windows_current_v1_write_atomic( + activation->canonical_launcher, &activation->state, error, sizeof(error)); + launcher_committed = current_committed && + cbm_windows_launcher_replace_atomic(activation->canonical_launcher, + candidate, error, sizeof(error)); + } else if (generation_ready) { + launcher_committed = cbm_windows_launcher_replace_atomic(activation->canonical_launcher, + candidate, error, sizeof(error)); + current_committed = launcher_committed && cbm_windows_current_v1_write_atomic( + activation->canonical_launcher, + &activation->state, error, sizeof(error)); + } + if (candidate[0] != L'\0') { + DWORD attributes = GetFileAttributesW(candidate); + if (attributes != INVALID_FILE_ATTRIBUTES) { + (void)DeleteFileW(candidate); + } + } + if (!generation_ready || !launcher_committed || !current_committed) { + if (activation->transition_plan == CBM_WINDOWS_TRANSITION_CURRENT_FIRST && + current_committed && !launcher_committed) { + char restore_error[CLI_BUF_256] = {0}; + if (!cbm_windows_current_v1_write_atomic(activation->canonical_launcher, + &activation->previous_state, restore_error, + sizeof(restore_error))) { + (void)fprintf(stderr, + "error: managed Windows update could not restore the " + "previous current-v1 after launcher failure: %s\n", + restore_error[0] ? restore_error : "atomic restoration failed"); + } + } + char rollback_error[CLI_BUF_256] = {0}; + if (!cbm_windows_generation_rollback_if_unreferenced( + activation->canonical_launcher, activation->state.payload_sha256, + generation_created, rollback_error, sizeof(rollback_error))) { + (void)fprintf(stderr, + "error: managed Windows update generation rollback failed: " + "%s\n", + rollback_error[0] ? rollback_error + : "generation remains for safe recovery"); + } + (void)fprintf(stderr, + "error: managed Windows update commit failed: %s. The " + "compatible launcher/current pair remains runnable; retry " + "update after reviewing activation-events.ndjson.\n", + error[0] ? error : "generation/launcher/current publish failed"); + return CLI_TRUE; + } + + int activation_status = CLI_OK; + size_t generations_removed = 0U; + char prune_error[CLI_BUF_256] = {0}; + if (!cbm_windows_generations_prune(activation->canonical_launcher, &generations_removed, + prune_error, sizeof(prune_error))) { + (void)fprintf(stderr, + "error: managed Windows update committed, but old generation " + "pruning was incomplete: %s\n", + prune_error[0] ? prune_error : "unsafe generation entry"); + activation_status = CLI_ACTIVATION_PARTIAL; + } + printf("Refreshing agent configurations...\n"); + if (cbm_install_agent_configs(activation->home, activation->canonical_launcher_utf8, true, + false) != CLI_OK) { + (void)fprintf(stderr, "error: update was published, but one or more agent " + "configuration refreshes failed\n"); + return CLI_ACTIVATION_PARTIAL; + } + if (activation->delete_indexes) { + int expected = count_db_indexes(activation->home); + int removed = cbm_remove_indexes(activation->home); + printf("Removed %d index(es).\n\n", removed); + if (removed != expected) { + (void)fprintf(stderr, "error: only %d of %d indexes could be removed\n", removed, + expected); + return CLI_ACTIVATION_PARTIAL; + } + } + return activation_status; +} + +static int cli_windows_extract_and_activate_update(const char *archive_path, const char *home, + bool delete_indexes) { + FILE *archive = cbm_fopen(archive_path, "rb"); + if (!archive || fseek(archive, 0, SEEK_END) != 0) { + if (archive) { + (void)fclose(archive); + } + (void)cbm_unlink(archive_path); + return CLI_TRUE; + } + long length = ftell(archive); + if (length <= 0 || length > INT_MAX || fseek(archive, 0, SEEK_SET) != 0) { + (void)fclose(archive); + (void)cbm_unlink(archive_path); + return CLI_TRUE; + } + unsigned char *bytes = malloc((size_t)length); + size_t received = bytes ? fread(bytes, 1, (size_t)length, archive) : 0; + int closed = fclose(archive); + (void)cbm_unlink(archive_path); + cbm_windows_release_pair_t pair; + bool extracted = bytes && received == (size_t)length && closed == 0 && + cbm_extract_windows_release_pair_from_zip(bytes, (int)length, &pair); + free(bytes); + if (!extracted || pair.launcher_len < 2 || pair.payload_len < 2 || pair.launcher[0] != 'M' || + pair.launcher[1] != 'Z' || pair.payload[0] != 'M' || pair.payload[1] != 'Z') { + if (extracted) { + cbm_windows_release_pair_free(&pair); + } + (void)fprintf(stderr, "error: Windows release must contain exactly one verified root " + "launcher and payload executable\n"); + return CLI_TRUE; + } + + char payload_sha[CBM_SHA256_HEX_LEN + 1]; + cbm_sha256_hex(pair.payload, (size_t)pair.payload_len, payload_sha); + + cbm_windows_current_v1_t current; + memset(¤t, 0, sizeof(current)); + bool current_exists = false; + bool current_compatible = + cli_windows_read_current(g_windows_launcher_context.canonical_launcher_path, ¤t, + ¤t_exists) && + current_exists && + cbm_windows_current_v1_supports_launcher_abi(¤t, CBM_WINDOWS_LAUNCHER_ABI_CURRENT); + char *canonical_utf8 = + current_compatible ? cbm_wide_to_utf8(g_windows_launcher_context.canonical_launcher_path) + : NULL; + if (!canonical_utf8) { + cbm_windows_release_pair_free(&pair); + (void)fprintf(stderr, "error: old/new managed launcher ABI compatibility check " + "failed before update activation\n"); + return CLI_TRUE; + } + wchar_t prepared_launcher[CBM_WINDOWS_LAUNCHER_PATH_CAP] = {0}; + wchar_t prepared_payload[CBM_WINDOWS_LAUNCHER_PATH_CAP] = {0}; + wchar_t target_directory[CBM_WINDOWS_LAUNCHER_PATH_CAP] = {0}; + char prepared_directory[CLI_BUF_1K] = {0}; + char candidate_error[CLI_BUF_512] = {0}; + bool pair_prepared = cli_windows_prepare_update_pair(&pair, payload_sha, prepared_launcher, + prepared_payload, prepared_directory); + cbm_windows_release_descriptor_v1_t descriptor; + memset(&descriptor, 0, sizeof(descriptor)); + bool descriptor_ready = + pair_prepared && + cbm_windows_release_descriptor_probe(prepared_launcher, &descriptor, candidate_error, + sizeof(candidate_error)) && + descriptor.payload_size == (uint64_t)pair.payload_len && + strcmp(descriptor.payload_sha256, payload_sha) == 0; + cbm_windows_transition_plan_t transition_plan = + descriptor_ready ? cbm_windows_transition_plan(¤t, &descriptor) + : CBM_WINDOWS_TRANSITION_INCOMPATIBLE; + bool candidate_supported = + descriptor_ready && transition_plan != CBM_WINDOWS_TRANSITION_INCOMPATIBLE && + cli_windows_parent_path(g_windows_launcher_context.canonical_launcher_path, + target_directory, CBM_WINDOWS_LAUNCHER_PATH_CAP) && + cbm_windows_launcher_capability_probe(target_directory, prepared_launcher, candidate_error, + sizeof(candidate_error)); + if (pair_prepared) { + cli_windows_cleanup_install_pair(prepared_launcher, prepared_payload, prepared_directory); + } + if (!candidate_supported) { + cbm_windows_release_pair_free(&pair); + free(canonical_utf8); + (void)fprintf(stderr, + "error: downloaded Windows launcher candidate is not runnable, " + "has an incompatible launcher/payload ABI descriptor, or lacks " + "required mapped-image capability: %s. Active CBM sessions were " + "not stopped.\n", + candidate_error[0] ? candidate_error + : transition_plan == CBM_WINDOWS_TRANSITION_INCOMPATIBLE + ? "an intermediate launcher ABI bridge is required" + : "private launcher/payload validation failed"); + return CLI_TRUE; + } + cbm_windows_current_v1_t next = { + .launcher_abi_min = descriptor.payload_launcher_abi_min, + .launcher_abi_max = descriptor.payload_launcher_abi_max, + .payload_size = descriptor.payload_size, + }; + (void)snprintf(next.payload_sha256, sizeof(next.payload_sha256), "%s", + descriptor.payload_sha256); + cli_windows_update_activation_t activation = { + .home = home, + .canonical_launcher_utf8 = canonical_utf8, + .pair = pair, + .state = next, + .previous_state = current, + .transition_plan = transition_plan, + .delete_indexes = delete_indexes, + }; + memcpy(activation.canonical_launcher, g_windows_launcher_context.canonical_launcher_path, + sizeof(activation.canonical_launcher)); + int result = + cli_activation_guard(CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE, NULL, next.payload_sha256, + cli_windows_managed_update_activate, &activation); + cbm_windows_release_pair_free(&activation.pair); + free(canonical_utf8); + return result == CLI_OK ? CLI_OK : CLI_TRUE; +} + +static bool cli_windows_managed_update_preflight(bool probe_capability) { + cbm_windows_current_v1_t current; + bool current_exists = false; + if (!g_windows_launcher_context.present || !g_windows_launcher_context.managed || + !g_windows_launcher_context.private_activation || + g_windows_launcher_context.action != CBM_WINDOWS_LAUNCHER_ACTION_UPDATE || + !cli_windows_read_current(g_windows_launcher_context.canonical_launcher_path, ¤t, + ¤t_exists) || + !current_exists || current.payload_size != g_windows_launcher_context.payload_size || + strcmp(current.payload_sha256, g_windows_launcher_context.expected_payload_sha256) != 0 || + !cbm_windows_current_v1_supports_launcher_abi(¤t, CBM_WINDOWS_LAUNCHER_ABI_CURRENT) || + !cli_windows_regular_file_no_reparse(g_windows_launcher_context.canonical_launcher_path, + NULL)) { + (void)fprintf(stderr, "error: authenticated managed launcher state changed before " + "update; no network or daemon operation was started\n"); + return false; + } + if (!probe_capability) { + return true; + } + wchar_t directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + char error[CLI_BUF_512] = {0}; + if (!cli_windows_parent_path(g_windows_launcher_context.canonical_launcher_path, directory, + CBM_WINDOWS_LAUNCHER_PATH_CAP) || + !cbm_windows_launcher_capability_probe( + directory, g_windows_launcher_context.canonical_launcher_path, error, sizeof(error))) { + (void)fprintf(stderr, + "error: managed launcher capability probe failed before update: " + "%s\n", + error[0] ? error : "local fixed NTFS atomic replacement is unavailable"); + return false; + } + return true; +} +#endif + +#ifndef _WIN32 typedef struct { const char *bin_dest; const char *home; @@ -10441,33 +12006,27 @@ typedef struct { static int cli_update_activate_binary(void *opaque) { cli_update_activation_t *activation = opaque; - if (!activation || !activation->bin_dest || - !activation->binary_transaction) { + if (!activation || !activation->bin_dest || !activation->binary_transaction) { return CLI_TRUE; } - if (cli_activation_transaction_commit_validated( - activation->binary_transaction, &activation->binary_validator, - CLI_OCTAL_PERM) != CLI_OK) { - cli_activation_transaction_abort_or_fail_stop( - &activation->binary_transaction, - "update_transaction_publish_recovery"); - (void)fprintf(stderr, "error: cannot publish staged update to %s\n", - activation->bin_dest); + if (cli_activation_transaction_commit_validated(activation->binary_transaction, + &activation->binary_validator, + CLI_OCTAL_PERM) != CLI_OK) { + cli_activation_transaction_abort_or_fail_stop(&activation->binary_transaction, + "update_transaction_publish_recovery"); + (void)fprintf(stderr, "error: cannot publish staged update to %s\n", activation->bin_dest); return CLI_TRUE; } /* Agent configs must never observe a replacement binary outside the same * exact-build activation window. Otherwise another CBM process can start * after the binary swap but before its MCP/hook entries are refreshed. */ printf("Refreshing agent configurations...\n"); - if (cbm_install_agent_configs(activation->home, activation->bin_dest, true, - false) != CLI_OK) { + if (cbm_install_agent_configs(activation->home, activation->bin_dest, true, false) != CLI_OK) { cli_activation_transaction_finalize_committed_or_fail_stop( - &activation->binary_transaction, - "update_transaction_config_failure_finalize"); - (void)fprintf(stderr, - "error: one or more agent configurations failed; the " - "published update was kept. Review the errors above and " - "rerun update\n"); + &activation->binary_transaction, "update_transaction_config_failure_finalize"); + (void)fprintf(stderr, "error: one or more agent configurations failed; the " + "published update was kept. Review the errors above and " + "rerun update\n"); return CLI_ACTIVATION_PARTIAL; } if (activation->delete_indexes) { @@ -10476,20 +12035,23 @@ static int cli_update_activate_binary(void *opaque) { printf("Removed %d index(es).\n\n", removed); if (removed != expected) { cli_activation_transaction_finalize_committed_or_fail_stop( - &activation->binary_transaction, - "update_transaction_index_failure_finalize"); - (void)fprintf(stderr, - "error: only %d of %d indexes could be removed\n", - removed, expected); + &activation->binary_transaction, "update_transaction_index_failure_finalize"); + (void)fprintf(stderr, "error: only %d of %d indexes could be removed\n", removed, + expected); return CLI_ACTIVATION_PARTIAL; } } - cli_activation_transaction_finalize_committed_or_fail_stop( - &activation->binary_transaction, "update_transaction_finalize"); + cli_activation_transaction_finalize_committed_or_fail_stop(&activation->binary_transaction, + "update_transaction_finalize"); return CLI_OK; } +#endif static int extract_and_install_binary(extract_install_args_t args) { +#ifdef _WIN32 + return cli_windows_extract_and_activate_update(args.tmp_archive, args.home, + args.delete_indexes); +#else const char *tmp_archive = args.tmp_archive; const char *ext = args.ext; const char *bin_dest = args.bin_dest; @@ -10549,50 +12111,39 @@ static int extract_and_install_binary(extract_install_args_t args) { * bytes into the final transaction. */ char prepared_dir[CLI_BUF_1K]; char prepared_candidate[CLI_BUF_1K]; - int prepared_dir_length = snprintf( - prepared_dir, sizeof(prepared_dir), "%s/cbm-update-sign-XXXXXX", - cbm_tmpdir()); - bool prepared = - prepared_dir_length > 0 && - (size_t)prepared_dir_length < sizeof(prepared_dir) && - cbm_mkdtemp(prepared_dir) != NULL; - int prepared_candidate_length = - prepared - ? snprintf(prepared_candidate, sizeof(prepared_candidate), - "%s/codebase-memory-mcp", prepared_dir) - : CLI_ERR; + int prepared_dir_length = + snprintf(prepared_dir, sizeof(prepared_dir), "%s/cbm-update-sign-XXXXXX", cbm_tmpdir()); + bool prepared = prepared_dir_length > 0 && (size_t)prepared_dir_length < sizeof(prepared_dir) && + cbm_mkdtemp(prepared_dir) != NULL; + int prepared_candidate_length = prepared + ? snprintf(prepared_candidate, sizeof(prepared_candidate), + "%s/codebase-memory-mcp", prepared_dir) + : CLI_ERR; prepared = prepared && prepared_candidate_length > 0 && (size_t)prepared_candidate_length < sizeof(prepared_candidate); cbm_activation_transaction_t *preparation = NULL; - stage_status = - prepared - ? cbm_activation_transaction_stage_bytes( - prepared_candidate, bin_data, (size_t)bin_len, &preparation) - : CBM_ACTIVATION_TRANSACTION_IO; + stage_status = prepared ? cbm_activation_transaction_stage_bytes(prepared_candidate, bin_data, + (size_t)bin_len, &preparation) + : CBM_ACTIVATION_TRANSACTION_IO; free(bin_data); cli_binary_validator_t unsigned_validator = {{0}}; prepared = stage_status == CBM_ACTIVATION_TRANSACTION_OK && preparation && - cli_activation_transaction_expected_build( - preparation, &unsigned_validator) && - cli_activation_transaction_commit_validated( - preparation, &unsigned_validator, CLI_OCTAL_PERM) == CLI_OK && + cli_activation_transaction_expected_build(preparation, &unsigned_validator) && + cli_activation_transaction_commit_validated(preparation, &unsigned_validator, + CLI_OCTAL_PERM) == CLI_OK && cli_activation_transaction_finalize_close(&preparation) == CLI_OK && cbm_macos_adhoc_sign(prepared_candidate) == CLI_OK && - cbm_daemon_build_fingerprint_file( - prepared_candidate, validator.fingerprint); - const char *prepared_argv[] = { - prepared_candidate, "--version", NULL}; + cbm_daemon_build_fingerprint_file(prepared_candidate, validator.fingerprint); + const char *prepared_argv[] = {prepared_candidate, "--version", NULL}; prepared = prepared && cbm_exec_no_shell(prepared_argv) == CLI_OK; if (prepared) { - stage_status = cbm_activation_transaction_stage_file( - bin_dest, prepared_candidate, &binary_transaction); + stage_status = cbm_activation_transaction_stage_file(bin_dest, prepared_candidate, + &binary_transaction); cli_binary_validator_t staged_validator = {{0}}; - prepared = stage_status == CBM_ACTIVATION_TRANSACTION_OK && - binary_transaction && - cli_activation_transaction_expected_build( - binary_transaction, &staged_validator) && - strcmp(staged_validator.fingerprint, - validator.fingerprint) == 0; + prepared = + stage_status == CBM_ACTIVATION_TRANSACTION_OK && binary_transaction && + cli_activation_transaction_expected_build(binary_transaction, &staged_validator) && + strcmp(staged_validator.fingerprint, validator.fingerprint) == 0; if (prepared) { validator = staged_validator; } @@ -10602,53 +12153,34 @@ static int extract_and_install_binary(extract_install_args_t args) { (size_t)prepared_candidate_length < sizeof(prepared_candidate)) { (void)cbm_unlink(prepared_candidate); } - if (prepared_dir_length > 0 && - (size_t)prepared_dir_length < sizeof(prepared_dir)) { + if (prepared_dir_length > 0 && (size_t)prepared_dir_length < sizeof(prepared_dir)) { (void)cbm_rmdir(prepared_dir); } if (!prepared) { - (void)fprintf(stderr, - "error: signed update candidate preparation failed\n"); + (void)fprintf(stderr, "error: signed update candidate preparation failed\n"); (void)cli_activation_transaction_abort(&binary_transaction); return CLI_TRUE; } #else - stage_status = cbm_activation_transaction_stage_bytes( - bin_dest, bin_data, (size_t)bin_len, &binary_transaction); + stage_status = cbm_activation_transaction_stage_bytes(bin_dest, bin_data, (size_t)bin_len, + &binary_transaction); free(bin_data); - if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || - !binary_transaction || - !cli_activation_transaction_expected_build(binary_transaction, - &validator)) { + if (stage_status != CBM_ACTIVATION_TRANSACTION_OK || !binary_transaction || + !cli_activation_transaction_expected_build(binary_transaction, &validator)) { (void)fprintf(stderr, "error: failed to stage verified update: %s\n", cbm_activation_transaction_status_message(stage_status)); (void)cli_activation_transaction_abort(&binary_transaction); return CLI_TRUE; } #ifndef _WIN32 - const char *staged = - cbm_activation_transaction_staged_path(binary_transaction); + const char *staged = cbm_activation_transaction_staged_path(binary_transaction); const char *candidate_argv[] = {staged, "--version", NULL}; if (!staged || cbm_exec_no_shell(candidate_argv) != 0) { - (void)fprintf(stderr, - "error: staged update candidate failed its execution check\n"); + (void)fprintf(stderr, "error: staged update candidate failed its execution check\n"); (void)cli_activation_transaction_abort(&binary_transaction); return CLI_TRUE; } #endif -#endif -#ifdef _WIN32 - char self_path[CLI_BUF_1K] = {0}; - cbm_detect_self_path(self_path, sizeof(self_path), args.home); - if (cbm_same_file(self_path, bin_dest)) { - (void)fprintf( - stderr, - "error: Windows cannot atomically replace the executable running " - "this update. Re-run update from a verified candidate copy; no " - "CBM sessions were stopped.\n"); - (void)cli_activation_transaction_abort(&binary_transaction); - return CLI_TRUE; - } #endif cli_update_activation_t activation = { .bin_dest = bin_dest, @@ -10657,14 +12189,14 @@ static int extract_and_install_binary(extract_install_args_t args) { .binary_validator = validator, .delete_indexes = args.delete_indexes, }; - int activation_rc = cli_activation_guard( - CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE, NULL, - validator.fingerprint, cli_update_activate_binary, &activation); + int activation_rc = + cli_activation_guard(CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE, NULL, validator.fingerprint, + cli_update_activate_binary, &activation); if (activation.binary_transaction) { - (void)cli_activation_transaction_abort( - &activation.binary_transaction); + (void)cli_activation_transaction_abort(&activation.binary_transaction); } return activation_rc == CLI_OK ? CLI_OK : CLI_TRUE; +#endif } /* Build the download URL for the update command. */ @@ -10688,8 +12220,7 @@ static void build_update_url(char *url, int url_sz, const char *os, const char * /* Confirm index deletion before network I/O, but defer the deletion itself to * the final guarded activation after the verified binary is ready. Returns 0 * to continue and 1 to abort. */ -static int update_prepare_clear_indexes(const char *home, bool dry_run, - bool *delete_indexes_out) { +static int update_prepare_clear_indexes(const char *home, bool dry_run, bool *delete_indexes_out) { if (delete_indexes_out) { *delete_indexes_out = false; } @@ -10719,11 +12250,9 @@ static int download_verify_install(const char *url, const char *ext, const char const char *arch, bool want_ui, const char *bin_dest, const char *home, bool delete_indexes) { char tmp_archive[CLI_BUF_256]; - int archive_path_length = snprintf( - tmp_archive, sizeof(tmp_archive), "%s/cbm-update-XXXXXX", - cbm_tmpdir()); - if (archive_path_length <= 0 || - (size_t)archive_path_length >= sizeof(tmp_archive)) { + int archive_path_length = + snprintf(tmp_archive, sizeof(tmp_archive), "%s/cbm-update-XXXXXX", cbm_tmpdir()); + if (archive_path_length <= 0 || (size_t)archive_path_length >= sizeof(tmp_archive)) { return CLI_TRUE; } int archive_descriptor = cbm_mkstemp(tmp_archive); @@ -10891,22 +12420,33 @@ int cbm_cmd_update(int argc, char **argv) { variant_flag = VARIANT_B; } else if (strcmp(argv[i], "--force") == 0) { force = true; - } else if (strcmp(argv[i], "-y") != 0 && - strcmp(argv[i], "--yes") != 0 && - strcmp(argv[i], "-n") != 0 && - strcmp(argv[i], "--no") != 0) { - (void)fprintf(stderr, "error: unknown update option: %s\n", - argv[i]); + } else if (strcmp(argv[i], "-y") != 0 && strcmp(argv[i], "--yes") != 0 && + strcmp(argv[i], "-n") != 0 && strcmp(argv[i], "--no") != 0) { + (void)fprintf(stderr, "error: unknown update option: %s\n", argv[i]); return CLI_TRUE; } } +#ifdef _WIN32 + /* Refuse portable self-update before HOME/cache discovery, the release + * version check, network I/O, prompts, or cohort construction. */ + if (!cli_windows_require_managed_mutation(CBM_WINDOWS_LAUNCHER_ACTION_UPDATE)) { + return CLI_TRUE; + } +#endif + const char *home = cbm_get_home_dir(); if (!home) { (void)fprintf(stderr, "error: HOME not set (use USERPROFILE on Windows)\n"); return CLI_TRUE; } +#ifdef _WIN32 + if (!cli_windows_managed_update_preflight(!dry_run)) { + return CLI_TRUE; + } +#endif + printf("codebase-memory-mcp update (current: %s)\n\n", CBM_VERSION); /* Version check — skip download if already on latest (not in dry-run). */ @@ -10945,7 +12485,13 @@ int cbm_cmd_update(int argc, char **argv) { if (dry_run) { printf("\n(dry-run — skipping download, extraction, and binary replacement)\n"); +#ifdef _WIN32 + char *dry_run_target = cbm_wide_to_utf8(g_windows_launcher_context.canonical_launcher_path); + printf(" target: %s\n", dry_run_target ? dry_run_target : "(managed launcher)"); + free(dry_run_target); +#else printf(" target: %s/.local/bin/codebase-memory-mcp\n", home); +#endif printf(" variant: %s\n", variant_label); printf(" os/arch: %s/%s\n", os, arch); printf("\nUpdate dry-run complete.\n"); @@ -10954,23 +12500,29 @@ int cbm_cmd_update(int argc, char **argv) { } /* Step 4-5: Download, verify, and install binary */ - char bin_dest[CLI_BUF_1K]; + char bin_dest_storage[CLI_BUF_1K]; + const char *bin_dest = bin_dest_storage; #ifdef _WIN32 - snprintf(bin_dest, sizeof(bin_dest), "%s/.local/bin/codebase-memory-mcp.exe", home); + char *managed_target = cbm_wide_to_utf8(g_windows_launcher_context.canonical_launcher_path); + if (!managed_target) { + return CLI_TRUE; + } + bin_dest = managed_target; #else - snprintf(bin_dest, sizeof(bin_dest), "%s/.local/bin/codebase-memory-mcp", home); -#endif + snprintf(bin_dest_storage, sizeof(bin_dest_storage), "%s/.local/bin/codebase-memory-mcp", home); char bin_dir[CLI_BUF_1K]; snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", home); if (!cbm_mkdir_p(bin_dir, CLI_OCTAL_PERM)) { - (void)fprintf(stderr, "error: cannot prepare update directory %s\n", - bin_dir); + (void)fprintf(stderr, "error: cannot prepare update directory %s\n", bin_dir); return CLI_TRUE; } +#endif - int rc = download_verify_install(url, ext, os, arch, want_ui, bin_dest, home, - delete_indexes); + int rc = download_verify_install(url, ext, os, arch, want_ui, bin_dest, home, delete_indexes); if (rc != 0) { +#ifdef _WIN32 + free(managed_target); +#endif return CLI_TRUE; } @@ -10987,6 +12539,9 @@ int cbm_cmd_update(int argc, char **argv) { printf("\nUpdate complete. Please restart your coding-agent sessions to " "properly take this into account.\n"); (void)variant; +#ifdef _WIN32 + free(managed_target); +#endif return 0; } diff --git a/src/cli/cli.h b/src/cli/cli.h index 9bb899836..a626a2bf8 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -11,6 +11,7 @@ #include #include +#include typedef struct cbm_mcp_server cbm_mcp_server_t; @@ -43,6 +44,11 @@ int cbm_cli_print_tool_help(const char *tool_name); * strings, and nested lookalikes are not tool errors. */ bool cbm_cli_mcp_result_is_error(const char *result); +/* Maintenance cancellation is authoritative even if a tool races to produce + * a nominal success result. Preserve an existing failure code; otherwise turn + * accepted cancellation into EXIT_FAILURE. */ +int cbm_cli_exit_status_after_maintenance(int exit_status, bool maintenance_cancelled); + /* ── Self-update: version comparison ──────────────────────────── */ /* Compare two semver strings (e.g. "0.2.1" vs "0.2.0"). @@ -332,6 +338,19 @@ unsigned char *cbm_extract_binary_from_targz(const unsigned char *data, int data * Returns NULL on error. Caller must free. */ unsigned char *cbm_extract_binary_from_zip(const unsigned char *data, int data_len, int *out_len); +/* Strict two-file Windows release bundle extraction. The archive must contain + * exactly one root launcher and one root payload; ambiguous aliases, + * traversal, duplicates, and malformed central/local metadata fail closed. */ +typedef struct { + unsigned char *launcher; + int launcher_len; + unsigned char *payload; + int payload_len; +} cbm_windows_release_pair_t; +bool cbm_extract_windows_release_pair_from_zip(const unsigned char *data, int data_len, + cbm_windows_release_pair_t *pair_out); +void cbm_windows_release_pair_free(cbm_windows_release_pair_t *pair); + /* ── Index management ─────────────────────────────────────────── */ /* List .db files in the cache directory (~/.cache/codebase-memory-mcp/). @@ -385,10 +404,8 @@ typedef int (*cbm_cli_activation_mutation_fn)(void *context); * lease. Tests use it to prove that mutation never runs before the cohort has * drained. Return 1 with a non-NULL lease on success, 0 when participants did * not drain before the deadline, or -1 on an unsafe/IO failure. */ -typedef int (*cbm_cli_activation_reserve_fn)( - void *context, cbm_cli_activation_lock_t *lease_out); -typedef void (*cbm_cli_activation_release_fn)( - void *context, cbm_cli_activation_lock_t lease); +typedef int (*cbm_cli_activation_reserve_fn)(void *context, cbm_cli_activation_lock_t *lease_out); +typedef void (*cbm_cli_activation_release_fn)(void *context, cbm_cli_activation_lock_t lease); /* Injectable high-level boundary for deterministic drain/mutation ordering * tests. Production never acquires startup while asking participants to @@ -418,8 +435,15 @@ void cbm_cli_set_activation_ops_for_test(const cbm_cli_activation_ops_t *ops); /* Internal integration-test seam: isolate the stable endpoint beneath a * private runtime parent. NULL restores the platform default. This is not a * command-line or environment override. */ -void cbm_cli_set_activation_runtime_parent_for_test( - const char *runtime_parent); +void cbm_cli_set_activation_runtime_parent_for_test(const char *runtime_parent); + +/* Consume and authenticate any inherited permanent-launcher context before + * process-role classification. An absent context is the normal portable + * payload case; an advertised but invalid context fails closed. */ +int cbm_cli_windows_launcher_startup_authenticate(int argc, char *const argv[]); +/* Internal release-pair probe. Returns -1 when argv does not select the role, + * otherwise a process exit code. It runs before cache/daemon initialization. */ +int cbm_cli_windows_payload_descriptor_role(int argc, char *const argv[]); /* ── Subcommands (wired from main.c) ─────────────────────────── */ @@ -467,12 +491,9 @@ char *cbm_hook_augment_process(cbm_mcp_server_t *srv, const char *input_json); /* Dialect-aware daemon entry. forced_event and dialect_name are borrowed and * may be NULL for the ordinary event dialect. Unsupported combinations fail * open with NULL, matching the direct hook command. */ -bool cbm_hook_augment_invocation_supported(const char *forced_event, - const char *dialect_name); -char *cbm_hook_augment_process_for(cbm_mcp_server_t *srv, - const char *input_json, - const char *forced_event, - const char *dialect_name); +bool cbm_hook_augment_invocation_supported(const char *forced_event, const char *dialect_name); +char *cbm_hook_augment_process_for(cbm_mcp_server_t *srv, const char *input_json, + const char *forced_event, const char *dialect_name); /* True for an absolute path the augmenter can walk up: POSIX "/..." or a * Windows drive root — "X:/..." or a bare "X:" (callers normalize '\\' to '/' diff --git a/src/cli/hook_augment.c b/src/cli/hook_augment.c index 64b71445a..2d1048b30 100644 --- a/src/cli/hook_augment.c +++ b/src/cli/hook_augment.c @@ -646,7 +646,6 @@ static char *ha_build_cline_json(const char *text) { return json; } - /* True for an absolute path we can walk up: POSIX "/..." or a Windows drive * root — "X:/..." or a bare "X:" (callers normalize '\\' to '/' first). * Declared in cli.h so the Windows drive-letter handling (#618) has direct @@ -868,10 +867,8 @@ static const char *ha_hook_event_name(yyjson_val *root) { return event ? event : ha_obj_str(root, "hookEventName"); } -static const char *ha_normalized_cwd_with_server(yyjson_val *root, - cbm_mcp_server_t *srv, - char *buffer, - size_t buffer_size) { +static const char *ha_normalized_cwd_with_server(yyjson_val *root, cbm_mcp_server_t *srv, + char *buffer, size_t buffer_size) { const char *cwd = ha_obj_str(root, "cwd"); if (!cwd) { yyjson_val *roots = root ? yyjson_obj_get(root, "workspace_roots") : NULL; @@ -911,8 +908,7 @@ static const char *ha_normalized_cwd_with_server(yyjson_val *root, #endif } -static const char *ha_normalized_cwd(yyjson_val *root, char *buffer, - size_t buffer_size) { +static const char *ha_normalized_cwd(yyjson_val *root, char *buffer, size_t buffer_size) { return ha_normalized_cwd_with_server(root, NULL, buffer, buffer_size); } @@ -1116,10 +1112,8 @@ static bool ha_invocation_supported(ha_lifecycle_dialect_t dialect, const char * return !forced_event || ha_dialect_event_supported(dialect, forced_event); } -static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, - yyjson_val *root, - const char *forced_event, - ha_lifecycle_dialect_t dialect) { +static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, yyjson_val *root, + const char *forced_event, ha_lifecycle_dialect_t dialect) { if (!root || !yyjson_is_obj(root)) { return NULL; } @@ -1134,8 +1128,7 @@ static char *ha_lifecycle_json_from_root(cbm_mcp_server_t *srv, owned_server = cbm_mcp_server_new(NULL); srv = owned_server; } - const char *cwd = ha_normalized_cwd_with_server( - root, srv, cwd_buffer, sizeof(cwd_buffer)); + const char *cwd = ha_normalized_cwd_with_server(root, srv, cwd_buffer, sizeof(cwd_buffer)); char *project = srv && cwd ? ha_resolve_indexed_project(srv, cwd) : NULL; cbm_mcp_server_free(owned_server); @@ -1215,8 +1208,7 @@ char *cbm_hook_augment_lifecycle_json_for(const char *input, const char *forced_ return NULL; } ha_lifecycle_dialect_t dialect = copilot_dialect ? HA_DIALECT_COPILOT : HA_DIALECT_EVENT; - char *json = ha_lifecycle_json_from_root( - NULL, yyjson_doc_get_root(doc), forced_event, dialect); + char *json = ha_lifecycle_json_from_root(NULL, yyjson_doc_get_root(doc), forced_event, dialect); yyjson_doc_free(doc); return json; } @@ -1235,8 +1227,7 @@ char *cbm_hook_augment_lifecycle_json_for_dialect(const char *input, const char if (!doc) { return NULL; } - char *json = ha_lifecycle_json_from_root( - NULL, yyjson_doc_get_root(doc), forced_event, dialect); + char *json = ha_lifecycle_json_from_root(NULL, yyjson_doc_get_root(doc), forced_event, dialect); yyjson_doc_free(doc); return json; } @@ -1287,8 +1278,7 @@ const char *cbm_hook_no_project_index_guidance_for_testing(const char *event) { } #endif -static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, - const char *forced_event, +static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, const char *forced_event, ha_lifecycle_dialect_t dialect) { if (!srv || !input_json) { return NULL; @@ -1302,8 +1292,7 @@ static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, } yyjson_val *root = yyjson_doc_get_root(doc); - char *lifecycle = - ha_lifecycle_json_from_root(srv, root, forced_event, dialect); + char *lifecycle = ha_lifecycle_json_from_root(srv, root, forced_event, dialect); if (lifecycle) { yyjson_doc_free(doc); return lifecycle; @@ -1343,8 +1332,7 @@ static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, } char cwdbuf[4096]; - const char *cwd = ha_normalized_cwd_with_server( - root, srv, cwdbuf, sizeof(cwdbuf)); + const char *cwd = ha_normalized_cwd_with_server(root, srv, cwdbuf, sizeof(cwdbuf)); if (!cwd) { yyjson_doc_free(doc); return NULL; @@ -1357,29 +1345,23 @@ static char *ha_process(cbm_mcp_server_t *srv, const char *input_json, return output; } -char *cbm_hook_augment_process(cbm_mcp_server_t *srv, - const char *input_json) { +char *cbm_hook_augment_process(cbm_mcp_server_t *srv, const char *input_json) { return cbm_hook_augment_process_for(srv, input_json, NULL, NULL); } -bool cbm_hook_augment_invocation_supported(const char *forced_event, - const char *dialect_name) { +bool cbm_hook_augment_invocation_supported(const char *forced_event, const char *dialect_name) { ha_lifecycle_dialect_t dialect = HA_DIALECT_EVENT; - if (dialect_name && dialect_name[0] && - !ha_dialect_from_name(dialect_name, &dialect)) { + if (dialect_name && dialect_name[0] && !ha_dialect_from_name(dialect_name, &dialect)) { return false; } return ha_invocation_supported(dialect, forced_event); } -char *cbm_hook_augment_process_for(cbm_mcp_server_t *srv, - const char *input_json, - const char *forced_event, - const char *dialect_name) { +char *cbm_hook_augment_process_for(cbm_mcp_server_t *srv, const char *input_json, + const char *forced_event, const char *dialect_name) { ha_lifecycle_dialect_t dialect = HA_DIALECT_EVENT; if (!srv || !input_json || - (dialect_name && dialect_name[0] && - !ha_dialect_from_name(dialect_name, &dialect)) || + (dialect_name && dialect_name[0] && !ha_dialect_from_name(dialect_name, &dialect)) || !ha_invocation_supported(dialect, forced_event)) { return NULL; } @@ -1412,8 +1394,7 @@ int cbm_cmd_hook_augment(int argc, char **argv) { return 0; } cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); - char *output = - srv ? ha_process(srv, input, forced_event, dialect) : NULL; + char *output = srv ? ha_process(srv, input, forced_event, dialect) : NULL; if (output) { fputs(output, stdout); } diff --git a/src/cli/progress_sink.c b/src/cli/progress_sink.c index 97279332d..7f1e69425 100644 --- a/src/cli/progress_sink.c +++ b/src/cli/progress_sink.c @@ -38,17 +38,13 @@ static atomic_int s_sink_mutex_state = ATOMIC_VAR_INIT(LOCK_UNINITIALIZED); * so keep one process-lifetime mutex rather than destroying it at fini. */ static void progress_sink_mutex_ensure(void) { int expected = LOCK_UNINITIALIZED; - if (atomic_compare_exchange_strong_explicit( - &s_sink_mutex_state, &expected, LOCK_INITIALIZING, - memory_order_acq_rel, memory_order_acquire)) { + if (atomic_compare_exchange_strong_explicit(&s_sink_mutex_state, &expected, LOCK_INITIALIZING, + memory_order_acq_rel, memory_order_acquire)) { cbm_mutex_init(&s_sink_mutex); - atomic_store_explicit(&s_sink_mutex_state, LOCK_READY, - memory_order_release); + atomic_store_explicit(&s_sink_mutex_state, LOCK_READY, memory_order_release); return; } - while (atomic_load_explicit(&s_sink_mutex_state, memory_order_acquire) != - LOCK_READY) { - } + while (atomic_load_explicit(&s_sink_mutex_state, memory_order_acquire) != LOCK_READY) {} } bool cbm_cli_progress_enabled(bool explicitly_requested, bool stderr_is_tty) { @@ -84,21 +80,19 @@ void cbm_cli_progress_start(FILE *out, const char *tool_name) { (void)fflush(stream); } -void cbm_cli_progress_finish(FILE *out, const char *tool_name, bool success, - uint64_t elapsed_ms) { +void cbm_cli_progress_finish(FILE *out, const char *tool_name, bool success, uint64_t elapsed_ms) { FILE *stream = out ? out : stderr; char safe_name[CBM_SZ_64] = {0}; progress_tool_name(tool_name, safe_name); - (void)fprintf(stream, "%s %s (%llu ms)\n", success ? "Completed" : "Failed", - safe_name, (unsigned long long)elapsed_ms); + (void)fprintf(stream, "%s %s (%llu ms)\n", success ? "Completed" : "Failed", safe_name, + (unsigned long long)elapsed_ms); (void)fflush(stream); } /* Extract one string field from the logger's compact JSON format. Keys are * accepted only at object boundaries so worker-controlled values cannot spoof * a progress event by merely containing a key-shaped substring. */ -static const char *extract_json_field(const char *line, const char *key, char *buf, - int buf_len) { +static const char *extract_json_field(const char *line, const char *key, char *buf, int buf_len) { char needle[CBM_SZ_64]; int needle_len = snprintf(needle, sizeof(needle), "\"%s\":", key); if (needle_len <= 0 || needle_len >= (int)sizeof(needle)) { diff --git a/src/cli/progress_sink.h b/src/cli/progress_sink.h index f6c1e9157..7301a3357 100644 --- a/src/cli/progress_sink.h +++ b/src/cli/progress_sink.h @@ -20,8 +20,7 @@ * forces the same behavior for redirected stderr without touching stdout. */ bool cbm_cli_progress_enabled(bool explicitly_requested, bool stderr_is_tty); void cbm_cli_progress_start(FILE *out, const char *tool_name); -void cbm_cli_progress_finish(FILE *out, const char *tool_name, bool success, - uint64_t elapsed_ms); +void cbm_cli_progress_finish(FILE *out, const char *tool_name, bool success, uint64_t elapsed_ms); void cbm_progress_sink_init(FILE *out); void cbm_progress_sink_fini(void); diff --git a/src/cli/windows_launcher_state.c b/src/cli/windows_launcher_state.c new file mode 100644 index 000000000..91af10f87 --- /dev/null +++ b/src/cli/windows_launcher_state.c @@ -0,0 +1,1851 @@ +#include "cli/windows_launcher_state.h" + +#include +#include +#include + +static const uint8_t current_v1_magic[8] = { + 'C', 'B', 'M', 'C', 'U', 'R', '1', '\0', +}; + +static const uint8_t release_descriptor_v1_magic[8] = { + 'C', 'B', 'M', 'W', 'R', 'D', '1', '\0', +}; + +static uint32_t read_u32_le(const uint8_t *input) { + return (uint32_t)input[0] | ((uint32_t)input[1] << 8U) | ((uint32_t)input[2] << 16U) | + ((uint32_t)input[3] << 24U); +} + +static uint64_t read_u64_le(const uint8_t *input) { + uint64_t value = 0; + for (unsigned int index = 0; index < 8U; index++) { + value |= (uint64_t)input[index] << (index * 8U); + } + return value; +} + +static void write_u32_le(uint8_t *output, uint32_t value) { + for (unsigned int index = 0; index < 4U; index++) { + output[index] = (uint8_t)(value >> (index * 8U)); + } +} + +static void write_u64_le(uint8_t *output, uint64_t value) { + for (unsigned int index = 0; index < 8U; index++) { + output[index] = (uint8_t)(value >> (index * 8U)); + } +} + +static bool lowercase_sha256_valid(const char *digest) { + if (!digest) { + return false; + } + for (size_t index = 0; index < 64U; index++) { + char value = digest[index]; + if (!((value >= '0' && value <= '9') || (value >= 'a' && value <= 'f'))) { + return false; + } + } + return digest[64] == '\0'; +} + +static bool current_v1_state_valid(const cbm_windows_current_v1_t *state) { + return state && state->launcher_abi_min > 0U && + state->launcher_abi_min <= state->launcher_abi_max && state->payload_size > 0U && + lowercase_sha256_valid(state->payload_sha256); +} + +static bool release_descriptor_v1_valid(const cbm_windows_release_descriptor_v1_t *descriptor) { + return descriptor && descriptor->launcher_abi > 0U && + descriptor->payload_launcher_abi_min > 0U && + descriptor->payload_launcher_abi_min <= descriptor->payload_launcher_abi_max && + descriptor->launcher_abi >= descriptor->payload_launcher_abi_min && + descriptor->launcher_abi <= descriptor->payload_launcher_abi_max && + descriptor->payload_size > 0U && lowercase_sha256_valid(descriptor->payload_sha256); +} + +bool cbm_windows_current_v1_encode(const cbm_windows_current_v1_t *state, + uint8_t out[CBM_WINDOWS_CURRENT_V1_SIZE]) { + if (!current_v1_state_valid(state) || !out) { + return false; + } + memset(out, 0, CBM_WINDOWS_CURRENT_V1_SIZE); + memcpy(out, current_v1_magic, sizeof(current_v1_magic)); + write_u32_le(out + 8U, 1U); + write_u32_le(out + 12U, CBM_WINDOWS_CURRENT_V1_SIZE); + write_u32_le(out + 16U, state->launcher_abi_min); + write_u32_le(out + 20U, state->launcher_abi_max); + write_u64_le(out + 24U, state->payload_size); + memcpy(out + 32U, state->payload_sha256, 64U); + return true; +} + +bool cbm_windows_current_v1_decode(const uint8_t *record, size_t record_size, + cbm_windows_current_v1_t *state_out) { + if (state_out) { + memset(state_out, 0, sizeof(*state_out)); + } + if (!record || !state_out || record_size != CBM_WINDOWS_CURRENT_V1_SIZE || + memcmp(record, current_v1_magic, sizeof(current_v1_magic)) != 0 || + read_u32_le(record + 8U) != 1U || + read_u32_le(record + 12U) != CBM_WINDOWS_CURRENT_V1_SIZE) { + return false; + } + for (size_t index = 96U; index < CBM_WINDOWS_CURRENT_V1_SIZE; index++) { + if (record[index] != 0U) { + return false; + } + } + cbm_windows_current_v1_t decoded; + memset(&decoded, 0, sizeof(decoded)); + decoded.launcher_abi_min = read_u32_le(record + 16U); + decoded.launcher_abi_max = read_u32_le(record + 20U); + decoded.payload_size = read_u64_le(record + 24U); + memcpy(decoded.payload_sha256, record + 32U, 64U); + decoded.payload_sha256[64] = '\0'; + if (!current_v1_state_valid(&decoded)) { + return false; + } + *state_out = decoded; + return true; +} + +bool cbm_windows_current_v1_supports_launcher_abi(const cbm_windows_current_v1_t *state, + uint32_t launcher_abi) { + return current_v1_state_valid(state) && launcher_abi > 0U && + launcher_abi >= state->launcher_abi_min && launcher_abi <= state->launcher_abi_max; +} + +bool cbm_windows_release_descriptor_v1_encode(const cbm_windows_release_descriptor_v1_t *descriptor, + uint8_t out[CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE]) { + if (!release_descriptor_v1_valid(descriptor) || !out) { + return false; + } + memset(out, 0, CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE); + memcpy(out, release_descriptor_v1_magic, sizeof(release_descriptor_v1_magic)); + write_u32_le(out + 8U, 1U); + write_u32_le(out + 12U, CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE); + write_u32_le(out + 16U, descriptor->launcher_abi); + write_u32_le(out + 20U, descriptor->payload_launcher_abi_min); + write_u32_le(out + 24U, descriptor->payload_launcher_abi_max); + write_u32_le(out + 28U, 0U); + write_u64_le(out + 32U, descriptor->payload_size); + memcpy(out + 40U, descriptor->payload_sha256, 64U); + return true; +} + +bool cbm_windows_release_descriptor_v1_decode(const uint8_t *record, size_t record_size, + cbm_windows_release_descriptor_v1_t *descriptor_out) { + if (descriptor_out) { + memset(descriptor_out, 0, sizeof(*descriptor_out)); + } + if (!record || !descriptor_out || record_size != CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE || + memcmp(record, release_descriptor_v1_magic, sizeof(release_descriptor_v1_magic)) != 0 || + read_u32_le(record + 8U) != 1U || + read_u32_le(record + 12U) != CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE || + read_u32_le(record + 28U) != 0U) { + return false; + } + for (size_t index = 104U; index < CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE; index++) { + if (record[index] != 0U) { + return false; + } + } + cbm_windows_release_descriptor_v1_t decoded; + memset(&decoded, 0, sizeof(decoded)); + decoded.launcher_abi = read_u32_le(record + 16U); + decoded.payload_launcher_abi_min = read_u32_le(record + 20U); + decoded.payload_launcher_abi_max = read_u32_le(record + 24U); + decoded.payload_size = read_u64_le(record + 32U); + memcpy(decoded.payload_sha256, record + 40U, 64U); + decoded.payload_sha256[64] = '\0'; + if (!release_descriptor_v1_valid(&decoded)) { + return false; + } + *descriptor_out = decoded; + return true; +} + +cbm_windows_transition_plan_t cbm_windows_transition_plan( + const cbm_windows_current_v1_t *current, const cbm_windows_release_descriptor_v1_t *candidate) { + if (!current_v1_state_valid(current) || !release_descriptor_v1_valid(candidate)) { + return CBM_WINDOWS_TRANSITION_INCOMPATIBLE; + } + if (cbm_windows_current_v1_supports_launcher_abi(current, candidate->launcher_abi)) { + return CBM_WINDOWS_TRANSITION_LAUNCHER_FIRST; + } + if (candidate->payload_launcher_abi_min <= current->launcher_abi_min && + candidate->payload_launcher_abi_max >= current->launcher_abi_max) { + return CBM_WINDOWS_TRANSITION_CURRENT_FIRST; + } + return CBM_WINDOWS_TRANSITION_INCOMPATIBLE; +} + +bool cbm_windows_generation_payload_path(const wchar_t *canonical_launcher_path, + const char payload_sha256[65], wchar_t *path_out, + size_t path_capacity) { + if (path_out && path_capacity > 0U) { + path_out[0] = L'\0'; + } + if (!canonical_launcher_path || !lowercase_sha256_valid(payload_sha256) || !path_out || + path_capacity == 0U) { + return false; + } + const wchar_t *last_backslash = wcsrchr(canonical_launcher_path, L'\\'); + const wchar_t *last_slash = wcsrchr(canonical_launcher_path, L'/'); + const wchar_t *separator = last_backslash; + if (!separator || (last_slash && last_slash > separator)) { + separator = last_slash; + } + if (!separator || separator == canonical_launcher_path || + (separator == canonical_launcher_path + 2 && canonical_launcher_path[1] != L':')) { + return false; + } + size_t directory_length = (size_t)(separator - canonical_launcher_path); + if (directory_length == 2U && canonical_launcher_path[1] == L':') { + directory_length = 3U; /* Preserve C:\ rather than producing C:.cbm. */ + } + static const wchar_t middle[] = L".cbm\\generations\\"; + static const wchar_t leaf[] = L"\\codebase-memory-mcp.payload.exe"; + size_t middle_length = sizeof(middle) / sizeof(middle[0]) - 1U; + size_t leaf_length = sizeof(leaf) / sizeof(leaf[0]) - 1U; + bool root = directory_length == 3U && canonical_launcher_path[1] == L':' && + (canonical_launcher_path[2] == L'\\' || canonical_launcher_path[2] == L'/'); + size_t separator_count = root ? 0U : 1U; + size_t needed = directory_length + separator_count + middle_length + 64U + leaf_length + 1U; + if (needed > path_capacity) { + return false; + } + size_t offset = 0U; + memcpy(path_out + offset, canonical_launcher_path, directory_length * sizeof(*path_out)); + offset += directory_length; + if (!root) { + path_out[offset++] = L'\\'; + } else { + path_out[2] = L'\\'; + } + memcpy(path_out + offset, middle, middle_length * sizeof(*path_out)); + offset += middle_length; + for (size_t index = 0; index < 64U; index++) { + path_out[offset++] = (wchar_t)(unsigned char)payload_sha256[index]; + } + memcpy(path_out + offset, leaf, (leaf_length + 1U) * sizeof(*path_out)); + return true; +} + +static bool argument_is(const char *argument, const char *expected) { + return argument && expected && strcmp(argument, expected) == 0; +} + +cbm_windows_launcher_action_t cbm_windows_launcher_classify_action(int argc, + const char *const argv[]) { + if (argc <= 1 || !argv) { + return CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY; + } + for (int index = 1; index < argc; index++) { + const char *argument = argv[index]; + if (argument_is(argument, "cli") || argument_is(argument, "hook-augment") || + argument_is(argument, "config") || argument_is(argument, "install") || + argument_is(argument, "--help") || argument_is(argument, "-h") || + argument_is(argument, "--version")) { + return CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY; + } + if (argument_is(argument, "update")) { + return CBM_WINDOWS_LAUNCHER_ACTION_UPDATE; + } + if (argument_is(argument, "uninstall")) { + return CBM_WINDOWS_LAUNCHER_ACTION_UNINSTALL; + } + } + return CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY; +} + +bool cbm_windows_launcher_action_allowed(cbm_windows_launcher_action_t action, bool managed) { + switch (action) { + case CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY: + return true; + case CBM_WINDOWS_LAUNCHER_ACTION_UPDATE: + case CBM_WINDOWS_LAUNCHER_ACTION_UNINSTALL: + return managed; + default: + return false; + } +} + +static void launcher_error(char *error, size_t error_size, const char *message) { + if (error && error_size > 0U) { + (void)snprintf(error, error_size, "%s", message ? message : "error"); + } +} + +#ifndef _WIN32 + +bool cbm_windows_launcher_context_consume(cbm_windows_launcher_context_t *context_out, char *error, + size_t error_size) { + if (context_out) { + memset(context_out, 0, sizeof(*context_out)); + } + if (!context_out) { + launcher_error(error, error_size, "invalid launcher context output"); + return false; + } + if (error && error_size > 0U) { + error[0] = '\0'; + } + return true; +} + +bool cbm_windows_launcher_context_complete(cbm_windows_launcher_context_t *context, bool accepted, + char *error, size_t error_size) { + (void)accepted; + if (error && error_size > 0U) + error[0] = '\0'; + if (!context || context->_authority_handle != 0U) { + launcher_error(error, error_size, "invalid launcher context completion"); + return false; + } + return true; +} + +bool cbm_windows_launcher_capability_probe(const wchar_t *target_directory, + const wchar_t *launcher_candidate, char *error, + size_t error_size) { + (void)target_directory; + (void)launcher_candidate; + launcher_error(error, error_size, "Windows launcher support is unavailable"); + return false; +} + +bool cbm_windows_launcher_file_secure(const wchar_t *launcher_path, char *error, + size_t error_size) { + (void)launcher_path; + launcher_error(error, error_size, "Windows launcher support is unavailable"); + return false; +} + +bool cbm_windows_release_descriptor_probe(const wchar_t *launcher_candidate, + cbm_windows_release_descriptor_v1_t *descriptor_out, + char *error, size_t error_size) { + (void)launcher_candidate; + if (descriptor_out) { + memset(descriptor_out, 0, sizeof(*descriptor_out)); + } + launcher_error(error, error_size, "Windows launcher support is unavailable"); + return false; +} + +bool cbm_windows_current_v1_write_atomic(const wchar_t *canonical_launcher_path, + const cbm_windows_current_v1_t *state, char *error, + size_t error_size) { + (void)canonical_launcher_path; + (void)state; + launcher_error(error, error_size, "Windows launcher support is unavailable"); + return false; +} + +bool cbm_windows_launcher_replace_atomic(const wchar_t *target_path, const wchar_t *candidate_path, + char *error, size_t error_size) { + (void)target_path; + (void)candidate_path; + launcher_error(error, error_size, "Windows launcher support is unavailable"); + return false; +} + +bool cbm_windows_launcher_remove_posix(const wchar_t *target_path, char *error, size_t error_size) { + (void)target_path; + launcher_error(error, error_size, "Windows launcher support is unavailable"); + return false; +} + +bool cbm_windows_generation_rollback_if_unreferenced(const wchar_t *canonical_launcher_path, + const char payload_sha256[65], + bool created_by_activation, char *error, + size_t error_size) { + (void)canonical_launcher_path; + (void)payload_sha256; + (void)created_by_activation; + launcher_error(error, error_size, "Windows launcher support is unavailable"); + return false; +} + +bool cbm_windows_generations_prune(const wchar_t *canonical_launcher_path, size_t *removed_out, + char *error, size_t error_size) { + (void)canonical_launcher_path; + if (removed_out) { + *removed_out = 0U; + } + launcher_error(error, error_size, "Windows launcher support is unavailable"); + return false; +} + +#else + +/* The Windows implementation follows below. Keep the byte codec above free + * of platform conditionals so all hosts continuously test the release ABI. */ + +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0A00 +#endif +#define WIN32_LEAN_AND_MEAN +#include +#include +#include + +#ifndef PROC_THREAD_ATTRIBUTE_JOB_LIST +#define PROC_THREAD_ATTRIBUTE_JOB_LIST ((DWORD_PTR)0x0002000dU) +#endif + +#define CBM_LAUNCH_CONTEXT_ENV L"CBM_WINDOWS_LAUNCH_CONTEXT_HANDLE_V1" +#define CBM_LAUNCH_CONTEXT_HEADER_SIZE 128U +#define CBM_LAUNCH_CONTEXT_FLAG_MANAGED 0x00000001U +#define CBM_LAUNCH_CONTEXT_FLAG_PRIVATE 0x00000002U + +static const uint8_t launch_context_magic[8] = { + 'C', 'B', 'M', 'L', 'C', 'T', '1', '\0', +}; + +/* The launcher source writes this exact explicit byte layout. */ +static bool launch_context_header_decode(const uint8_t input[CBM_LAUNCH_CONTEXT_HEADER_SIZE], + uint32_t *flags_out, + cbm_windows_launcher_action_t *action_out, + DWORD *server_pid_out, FILETIME *creation_out, + uint64_t *payload_size_out, char digest_out[65], + uint32_t *path_chars_out) { + if (memcmp(input, launch_context_magic, sizeof(launch_context_magic)) != 0 || + read_u32_le(input + 8U) != 1U || + read_u32_le(input + 12U) != CBM_LAUNCH_CONTEXT_HEADER_SIZE) { + return false; + } + uint32_t flags = read_u32_le(input + 16U); + uint32_t action = read_u32_le(input + 20U); + uint32_t server_pid = read_u32_le(input + 24U); + uint32_t path_chars = read_u32_le(input + 28U); + uint64_t creation = read_u64_le(input + 32U); + uint64_t payload_size = read_u64_le(input + 40U); + char digest[65]; + memcpy(digest, input + 48U, 64U); + digest[64] = '\0'; + for (size_t index = 112U; index < CBM_LAUNCH_CONTEXT_HEADER_SIZE; index++) { + if (input[index] != 0U) { + return false; + } + } + bool managed = (flags & CBM_LAUNCH_CONTEXT_FLAG_MANAGED) != 0U; + bool digest_zero = true; + for (size_t index = 0U; index < 64U; index++) { + if (input[48U + index] != 0U) { + digest_zero = false; + } + } + if ((flags & ~(CBM_LAUNCH_CONTEXT_FLAG_MANAGED | CBM_LAUNCH_CONTEXT_FLAG_PRIVATE)) != 0U || + (!managed && (flags & CBM_LAUNCH_CONTEXT_FLAG_PRIVATE) != 0U) || + action > (uint32_t)CBM_WINDOWS_LAUNCHER_ACTION_UNINSTALL || server_pid == 0U || + path_chars < 4U || path_chars >= CBM_WINDOWS_LAUNCHER_PATH_CAP || creation == 0U || + (managed && (payload_size == 0U || !lowercase_sha256_valid(digest))) || + (!managed && (payload_size != 0U || !digest_zero))) { + return false; + } + *flags_out = flags; + *action_out = (cbm_windows_launcher_action_t)action; + *server_pid_out = server_pid; + creation_out->dwLowDateTime = (DWORD)creation; + creation_out->dwHighDateTime = (DWORD)(creation >> 32U); + *payload_size_out = payload_size; + memcpy(digest_out, digest, sizeof(digest)); + *path_chars_out = path_chars; + return true; +} + +static bool windows_read_exact(HANDLE file, void *buffer, size_t size) { + size_t offset = 0U; + while (offset < size) { + DWORD chunk = size - offset > (size_t)MAXDWORD ? MAXDWORD : (DWORD)(size - offset); + DWORD amount = 0U; + if (!ReadFile(file, (uint8_t *)buffer + offset, chunk, &amount, NULL) || amount == 0U) { + return false; + } + offset += amount; + } + return true; +} + +static bool windows_pipe_wait_available(HANDLE pipe, size_t needed, uint64_t deadline) { + if (needed > (size_t)MAXDWORD) + return false; + while (GetTickCount64() < deadline) { + DWORD available = 0U; + if (!PeekNamedPipe(pipe, NULL, 0U, NULL, &available, NULL)) { + return false; + } + if ((size_t)available >= needed) + return true; + Sleep(2U); + } + return false; +} + +static bool windows_file_identity(HANDLE file, BY_HANDLE_FILE_INFORMATION *information) { + return file && file != INVALID_HANDLE_VALUE && GetFileType(file) == FILE_TYPE_DISK && + GetFileInformationByHandle(file, information) != 0 && + (information->dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + information->nNumberOfLinks == 1U; +} + +static bool windows_same_identity(const BY_HANDLE_FILE_INFORMATION *first, + const BY_HANDLE_FILE_INFORMATION *second) { + return first->dwVolumeSerialNumber == second->dwVolumeSerialNumber && + first->nFileIndexHigh == second->nFileIndexHigh && + first->nFileIndexLow == second->nFileIndexLow; +} + +static uint32_t windows_sid_read_u32_le(const uint8_t *bytes) { + return (uint32_t)bytes[0] | ((uint32_t)bytes[1] << 8U) | ((uint32_t)bytes[2] << 16U) | + ((uint32_t)bytes[3] << 24U); +} + +static bool windows_sid_is_trusted_installer(PSID sid) { + static const uint32_t subauthorities[] = { + 80U, 956008885U, 3418522649U, 1831038044U, 1853292631U, 2271478464U, + }; + if (!sid || !IsValidSid(sid)) { + return false; + } + DWORD sid_length = GetLengthSid(sid); + const uint8_t *bytes = (const uint8_t *)sid; + if (sid_length != 32U || bytes[0] != 1U || bytes[1] != 6U || bytes[2] != 0U || bytes[3] != 0U || + bytes[4] != 0U || bytes[5] != 0U || bytes[6] != 0U || bytes[7] != 5U) { + return false; + } + for (size_t index = 0U; index < sizeof(subauthorities) / sizeof(subauthorities[0]); index++) { + if (windows_sid_read_u32_le(bytes + 8U + index * 4U) != subauthorities[index]) { + return false; + } + } + return true; +} + +static bool windows_sid_is_trusted(PSID sid, PSID current_user) { + return sid && current_user && IsValidSid(sid) && + (EqualSid(sid, current_user) || IsWellKnownSid(sid, WinLocalSystemSid) || + IsWellKnownSid(sid, WinBuiltinAdministratorsSid) || + windows_sid_is_trusted_installer(sid)); +} + +static bool windows_bounded_ace_sid_is_trusted(const ACE_HEADER *header, PSID current_user) { + size_t sid_offset = offsetof(ACCESS_ALLOWED_ACE, SidStart); + if (!header || (size_t)header->AceSize < sid_offset + 8U) { + return false; + } + const ACCESS_ALLOWED_ACE *ace = (const ACCESS_ALLOWED_ACE *)header; + const uint8_t *sid = (const uint8_t *)&ace->SidStart; + size_t sid_capacity = (size_t)header->AceSize - sid_offset; + if (sid[0] != 1U || sid[1] > 15U) { + return false; + } + size_t sid_length = 8U + (size_t)sid[1] * 4U; + return sid_length <= sid_capacity && IsValidSid((PSID)sid) && + GetLengthSid((PSID)sid) == (DWORD)sid_length && + (windows_sid_is_trusted((PSID)sid, current_user) || + (((header->AceFlags & INHERIT_ONLY_ACE) != 0U) && + IsWellKnownSid((PSID)sid, WinCreatorOwnerSid))); +} + +static bool windows_owner_secure(HANDLE file, bool require_current_user) { + HANDLE token = NULL; + DWORD token_size = 0U; + PTOKEN_USER user = NULL; + PSID owner = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + bool ok = OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token) != 0; + if (ok) { + (void)GetTokenInformation(token, TokenUser, NULL, 0U, &token_size); + user = token_size ? (PTOKEN_USER)malloc(token_size) : NULL; + ok = user && GetTokenInformation(token, TokenUser, user, token_size, &token_size) != 0; + } + if (ok) { + ok = GetSecurityInfo(file, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, NULL, NULL, + NULL, &descriptor) == ERROR_SUCCESS && + owner && IsValidSid(owner) && + (require_current_user ? EqualSid(owner, user->User.Sid) != 0 + : windows_sid_is_trusted(owner, user->User.Sid)); + } + if (descriptor) { + (void)LocalFree(descriptor); + } + free(user); + if (token) { + (void)CloseHandle(token); + } + return ok; +} + +static bool windows_owner_is_current(HANDLE file) { + return windows_owner_secure(file, true); +} + +static bool windows_acl_secure(HANDLE file) { + HANDLE token = NULL; + DWORD token_size = 0U; + PTOKEN_USER user = NULL; + PACL dacl = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + bool secure = OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token) != 0; + if (secure) { + (void)GetTokenInformation(token, TokenUser, NULL, 0U, &token_size); + user = token_size ? (PTOKEN_USER)malloc(token_size) : NULL; + secure = user && GetTokenInformation(token, TokenUser, user, token_size, &token_size) != 0; + } + DWORD status = secure ? GetSecurityInfo(file, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, NULL, + NULL, &dacl, NULL, &descriptor) + : ERROR_ACCESS_DENIED; + ACL_SIZE_INFORMATION information; + memset(&information, 0, sizeof(information)); + secure = secure && status == ERROR_SUCCESS && descriptor && dacl && IsValidAcl(dacl) != 0 && + GetAclInformation(dacl, &information, sizeof(information), AclSizeInformation) != 0; + const DWORD mutation = GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | + FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | + FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | + WRITE_OWNER | ACCESS_SYSTEM_SECURITY; + enum { + CBM_ACE_ALLOW = 0x00, + CBM_ACE_DENY = 0x01, + CBM_ACE_DENY_OBJECT = 0x06, + CBM_ACE_DENY_CALLBACK = 0x0a, + CBM_ACE_DENY_CALLBACK_OBJECT = 0x0c, + }; + for (DWORD index = 0U; secure && index < information.AceCount; index++) { + void *opaque = NULL; + if (!GetAce(dacl, index, &opaque) || !opaque) { + secure = false; + break; + } + ACE_HEADER *header = opaque; + if (header->AceType == CBM_ACE_DENY || header->AceType == CBM_ACE_DENY_OBJECT || + header->AceType == CBM_ACE_DENY_CALLBACK || + header->AceType == CBM_ACE_DENY_CALLBACK_OBJECT) { + continue; + } + if (header->AceType != CBM_ACE_ALLOW || + (size_t)header->AceSize < + offsetof(ACCESS_ALLOWED_ACE, SidStart) + offsetof(SID, SubAuthority)) { + secure = false; + break; + } + ACCESS_ALLOWED_ACE *ace = opaque; + if ((ace->Mask & mutation) == 0U) { + continue; + } + if (!windows_bounded_ace_sid_is_trusted(header, user->User.Sid)) { + secure = false; + } + } + if (descriptor) + (void)LocalFree(descriptor); + free(user); + if (token) + (void)CloseHandle(token); + return secure; +} + +static bool windows_path_tree_plain(const wchar_t *file_path) { + size_t length = file_path ? wcslen(file_path) : 0U; + if (length < 4U || length >= CBM_WINDOWS_LAUNCHER_PATH_CAP || file_path[1] != L':' || + (file_path[2] != L'\\' && file_path[2] != L'/')) { + return false; + } + wchar_t *path = malloc((length + 1U) * sizeof(*path)); + if (!path) { + return false; + } + memcpy(path, file_path, (length + 1U) * sizeof(*path)); + for (size_t index = 0U; index < length; index++) { + if (path[index] == L'/') + path[index] = L'\\'; + } + wchar_t *last = wcsrchr(path, L'\\'); + if (!last || last <= path + 2) { + free(path); + return false; + } + *last = L'\0'; + size_t directory_length = wcslen(path); + bool valid = true; + for (size_t index = 3U; valid && index <= directory_length; index++) { + if (index < directory_length && path[index] != L'\\') + continue; + wchar_t saved = path[index]; + path[index] = L'\0'; + HANDLE component = + CreateFileW(path, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + BY_HANDLE_FILE_INFORMATION information; + valid = component != INVALID_HANDLE_VALUE && GetFileType(component) == FILE_TYPE_DISK && + GetFileInformationByHandle(component, &information) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && + windows_owner_secure(component, false) && windows_acl_secure(component); + if (component != INVALID_HANDLE_VALUE) + (void)CloseHandle(component); + path[index] = saved; + } + free(path); + return valid; +} + +static HANDLE windows_open_regular_no_reparse(const wchar_t *path, DWORD access) { + HANDLE file = + CreateFileW(path, access | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, NULL); + BY_HANDLE_FILE_INFORMATION information; + if (!windows_path_tree_plain(path) || !windows_file_identity(file, &information) || + !windows_owner_is_current(file) || !windows_acl_secure(file)) { + if (file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(file); + } + return INVALID_HANDLE_VALUE; + } + return file; +} + +static HANDLE windows_open_directory_secure(const wchar_t *path) { + HANDLE directory = + CreateFileW(path, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + BY_HANDLE_FILE_INFORMATION information; + bool valid = directory != INVALID_HANDLE_VALUE && GetFileType(directory) == FILE_TYPE_DISK && + GetFileInformationByHandle(directory, &information) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && + windows_owner_is_current(directory) && windows_acl_secure(directory); + if (!valid) { + if (directory != INVALID_HANDLE_VALUE) + (void)CloseHandle(directory); + return INVALID_HANDLE_VALUE; + } + return directory; +} + +static bool windows_process_creation(HANDLE process, FILETIME *creation_out) { + FILETIME exit_time; + FILETIME kernel_time; + FILETIME user_time; + return GetProcessTimes(process, creation_out, &exit_time, &kernel_time, &user_time) != 0; +} + +static bool windows_filetime_equal(const FILETIME *first, const FILETIME *second) { + return first->dwLowDateTime == second->dwLowDateTime && + first->dwHighDateTime == second->dwHighDateTime; +} + +bool cbm_windows_launcher_context_consume(cbm_windows_launcher_context_t *context_out, char *error, + size_t error_size) { + if (context_out) { + memset(context_out, 0, sizeof(*context_out)); + } + if (error && error_size > 0U) { + error[0] = '\0'; + } + if (!context_out) { + launcher_error(error, error_size, "invalid launcher context output"); + return false; + } + wchar_t encoded[32]; + DWORD length = GetEnvironmentVariableW(CBM_LAUNCH_CONTEXT_ENV, encoded, + (DWORD)(sizeof(encoded) / sizeof(encoded[0]))); + if (length == 0U && GetLastError() == ERROR_ENVVAR_NOT_FOUND) { + return true; + } + /* Scrub before validation: malformed authority must never reach a child. */ + (void)SetEnvironmentVariableW(CBM_LAUNCH_CONTEXT_ENV, NULL); + if (length == 0U || length >= sizeof(encoded) / sizeof(encoded[0])) { + launcher_error(error, error_size, "invalid inherited launcher context handle"); + return false; + } + wchar_t *end = NULL; + unsigned long long raw = wcstoull(encoded, &end, 16); + if (!end || *end != L'\0' || raw == 0ULL || raw > (unsigned long long)(uintptr_t)UINTPTR_MAX) { + launcher_error(error, error_size, "invalid inherited launcher context handle"); + return false; + } + HANDLE pipe = (HANDLE)(uintptr_t)raw; + uint8_t header[CBM_LAUNCH_CONTEXT_HEADER_SIZE]; + uint32_t flags = 0U; + cbm_windows_launcher_action_t action = CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY; + DWORD claimed_pid = 0U; + FILETIME claimed_creation; + uint64_t payload_size = 0U; + char digest[65]; + uint32_t path_chars = 0U; + ULONG actual_pid = 0U; + uint64_t context_now = GetTickCount64(); + uint64_t context_deadline = UINT64_MAX - context_now < 5000U ? UINT64_MAX : context_now + 5000U; + bool valid = + GetFileType(pipe) == FILE_TYPE_PIPE && + GetNamedPipeServerProcessId(pipe, &actual_pid) != 0 && + windows_pipe_wait_available(pipe, sizeof(header), context_deadline) && + windows_read_exact(pipe, header, sizeof(header)) && + launch_context_header_decode(header, &flags, &action, &claimed_pid, &claimed_creation, + &payload_size, digest, &path_chars) && + actual_pid == claimed_pid; + wchar_t claimed_path[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + memset(claimed_path, 0, sizeof(claimed_path)); + valid = valid && + windows_pipe_wait_available(pipe, (size_t)path_chars * sizeof(*claimed_path), + context_deadline) && + windows_read_exact(pipe, claimed_path, (size_t)path_chars * sizeof(*claimed_path)) && + claimed_path[path_chars - 1U] == L'\0' && + wmemchr(claimed_path, L'\0', path_chars) == claimed_path + path_chars - 1U; + DWORD trailing_count = 0U; + valid = valid && PeekNamedPipe(pipe, NULL, 0U, NULL, &trailing_count, NULL) != 0 && + trailing_count == 0U; + + HANDLE server = + valid ? OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, actual_pid) : NULL; + FILETIME actual_creation; + wchar_t actual_path[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + DWORD actual_path_length = CBM_WINDOWS_LAUNCHER_PATH_CAP; + valid = valid && server && windows_process_creation(server, &actual_creation) && + windows_filetime_equal(&actual_creation, &claimed_creation) && + QueryFullProcessImageNameW(server, 0U, actual_path, &actual_path_length) != 0; + if (server) { + (void)CloseHandle(server); + } + HANDLE actual_file = + valid ? windows_open_regular_no_reparse(actual_path, GENERIC_READ) : INVALID_HANDLE_VALUE; + HANDLE claimed_file = + valid ? windows_open_regular_no_reparse(claimed_path, GENERIC_READ) : INVALID_HANDLE_VALUE; + BY_HANDLE_FILE_INFORMATION actual_info; + BY_HANDLE_FILE_INFORMATION claimed_info; + valid = valid && windows_file_identity(actual_file, &actual_info) && + windows_file_identity(claimed_file, &claimed_info) && + windows_same_identity(&actual_info, &claimed_info); + if (actual_file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(actual_file); + } + if (claimed_file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(claimed_file); + } + if (!valid) { + (void)CloseHandle(pipe); + launcher_error(error, error_size, "invalid or unauthenticated Windows launcher context"); + memset(context_out, 0, sizeof(*context_out)); + return false; + } + context_out->present = true; + context_out->managed = (flags & CBM_LAUNCH_CONTEXT_FLAG_MANAGED) != 0U; + context_out->private_activation = (flags & CBM_LAUNCH_CONTEXT_FLAG_PRIVATE) != 0U; + context_out->action = action; + context_out->payload_size = payload_size; + memcpy(context_out->expected_payload_sha256, digest, sizeof(digest)); + memcpy(context_out->canonical_launcher_path, claimed_path, + (size_t)path_chars * sizeof(*claimed_path)); + context_out->_authority_handle = (uintptr_t)pipe; + return true; +} + +static bool windows_pipe_read_byte_until(HANDLE pipe, uint8_t *value_out, uint64_t deadline) { + while (GetTickCount64() < deadline) { + DWORD available = 0U; + if (!PeekNamedPipe(pipe, NULL, 0U, NULL, &available, NULL)) { + return false; + } + if (available > 0U) { + DWORD received = 0U; + return ReadFile(pipe, value_out, 1U, &received, NULL) != 0 && received == 1U; + } + Sleep(2U); + } + return false; +} + +bool cbm_windows_launcher_context_complete(cbm_windows_launcher_context_t *context, bool accepted, + char *error, size_t error_size) { + if (error && error_size > 0U) + error[0] = '\0'; + if (!context) { + launcher_error(error, error_size, "invalid launcher context completion"); + return false; + } + if (!context->present) { + return context->_authority_handle == 0U; + } + HANDLE pipe = (HANDLE)context->_authority_handle; + context->_authority_handle = 0U; + if (!pipe || pipe == INVALID_HANDLE_VALUE) { + launcher_error(error, error_size, "launcher context authority was already consumed"); + return false; + } + uint8_t ready = accepted ? (uint8_t)'R' : (uint8_t)'X'; + DWORD written = 0U; + bool ok = WriteFile(pipe, &ready, 1U, &written, NULL) != 0 && written == 1U; + if (ok && accepted) { + uint8_t result = 0U; + uint64_t now = GetTickCount64(); + uint64_t deadline = UINT64_MAX - now < 30000U ? UINT64_MAX : now + 30000U; + ok = windows_pipe_read_byte_until(pipe, &result, deadline) && result == (uint8_t)'G'; + } + (void)CloseHandle(pipe); + if (!ok) { + launcher_error(error, error_size, + "launcher rejected or timed out completing payload authentication"); + } + return ok; +} + +/* Remaining native transaction/probe helpers are below the launcher-facing + * context code to keep their private Windows structures out of the ABI. */ + +typedef struct { + DWORD Flags; +} cbm_file_disposition_info_ex_t; + +typedef struct { + DWORD Flags; + HANDLE RootDirectory; + DWORD FileNameLength; + WCHAR FileName[1]; +} cbm_file_rename_info_ex_t; + +#define CBM_FILE_DISPOSITION_INFO_EX_CLASS ((FILE_INFO_BY_HANDLE_CLASS)21) +#define CBM_FILE_RENAME_INFO_EX_CLASS ((FILE_INFO_BY_HANDLE_CLASS)22) +#define CBM_FILE_DISPOSITION_DELETE 0x00000001U +#define CBM_FILE_DISPOSITION_POSIX 0x00000002U +#define CBM_FILE_RENAME_REPLACE 0x00000001U +#define CBM_FILE_RENAME_POSIX 0x00000002U + +static bool windows_posix_remove_handle(HANDLE file) { + cbm_file_disposition_info_ex_t disposition = { + .Flags = CBM_FILE_DISPOSITION_DELETE | CBM_FILE_DISPOSITION_POSIX, + }; + return SetFileInformationByHandle(file, CBM_FILE_DISPOSITION_INFO_EX_CLASS, &disposition, + sizeof(disposition)) != 0; +} + +static bool windows_posix_rename_handle(HANDLE file, const wchar_t *target_path) { + size_t chars = wcslen(target_path); + if (chars == 0U || chars > (size_t)UINT32_MAX / sizeof(wchar_t)) { + return false; + } + size_t bytes = chars * sizeof(wchar_t); + size_t allocation = offsetof(cbm_file_rename_info_ex_t, FileName) + bytes; + cbm_file_rename_info_ex_t *rename = calloc(1U, allocation); + if (!rename) { + return false; + } + rename->Flags = CBM_FILE_RENAME_REPLACE | CBM_FILE_RENAME_POSIX; + rename->RootDirectory = NULL; + rename->FileNameLength = (DWORD)bytes; + memcpy(rename->FileName, target_path, bytes); + bool renamed = SetFileInformationByHandle(file, CBM_FILE_RENAME_INFO_EX_CLASS, rename, + (DWORD)allocation) != 0; + free(rename); + return renamed; +} + +static bool windows_parent_path(const wchar_t *path, wchar_t *parent, size_t capacity) { + if (!path || !parent || capacity == 0U) { + return false; + } + size_t length = wcslen(path); + if (length + 1U > capacity) { + return false; + } + memcpy(parent, path, (length + 1U) * sizeof(*parent)); + wchar_t *separator = wcsrchr(parent, L'\\'); + wchar_t *slash = wcsrchr(parent, L'/'); + if (!separator || (slash && slash > separator)) { + separator = slash; + } + if (!separator || separator <= parent + 2) { + return false; + } + *separator = L'\0'; + return true; +} + +static bool windows_copy_flush_private(const wchar_t *candidate, const wchar_t *stage) { + HANDLE source = windows_open_regular_no_reparse(candidate, GENERIC_READ); + if (source == INVALID_HANDLE_VALUE || !windows_path_tree_plain(stage) || + !CopyFileW(candidate, stage, TRUE)) { + if (source != INVALID_HANDLE_VALUE) + (void)CloseHandle(source); + return false; + } + (void)CloseHandle(source); + HANDLE file = + CreateFileW(stage, GENERIC_READ | GENERIC_WRITE | DELETE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH, NULL); + BY_HANDLE_FILE_INFORMATION information; + bool ok = windows_file_identity(file, &information) && windows_owner_is_current(file) && + windows_acl_secure(file) && FlushFileBuffers(file) != 0; + if (file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(file); + } + if (!ok) { + (void)DeleteFileW(stage); + } + return ok; +} + +static bool windows_unique_sibling(const wchar_t *target, const wchar_t *tag, wchar_t *path, + size_t capacity) { + static volatile LONG counter = 0; + LONG sequence = InterlockedIncrement(&counter); + int written = swprintf(path, capacity, L"%ls.cbm-%ls-%lu-%ld.tmp", target, tag, + (unsigned long)GetCurrentProcessId(), (long)sequence); + return written > 0 && (size_t)written < capacity; +} + +bool cbm_windows_launcher_replace_atomic(const wchar_t *target_path, const wchar_t *candidate_path, + char *error, size_t error_size) { + if (error && error_size > 0U) + error[0] = '\0'; + if (!target_path || !candidate_path || !target_path[0] || !candidate_path[0]) { + launcher_error(error, error_size, "invalid launcher replacement paths"); + return false; + } + HANDLE candidate = windows_open_regular_no_reparse(candidate_path, GENERIC_READ); + DWORD target_attributes = GetFileAttributesW(target_path); + DWORD target_error = + target_attributes == INVALID_FILE_ATTRIBUTES ? GetLastError() : ERROR_SUCCESS; + HANDLE target = target_attributes == INVALID_FILE_ATTRIBUTES + ? INVALID_HANDLE_VALUE + : windows_open_regular_no_reparse(target_path, GENERIC_READ); + bool target_absent = + target_attributes == INVALID_FILE_ATTRIBUTES && + (target_error == ERROR_FILE_NOT_FOUND || target_error == ERROR_PATH_NOT_FOUND); + if (candidate == INVALID_HANDLE_VALUE || (!target_absent && target == INVALID_HANDLE_VALUE) || + !windows_path_tree_plain(target_path)) { + if (candidate != INVALID_HANDLE_VALUE) + (void)CloseHandle(candidate); + if (target != INVALID_HANDLE_VALUE) + (void)CloseHandle(target); + launcher_error(error, error_size, "launcher candidate or existing target is unsafe"); + return false; + } + (void)CloseHandle(candidate); + if (target != INVALID_HANDLE_VALUE) + (void)CloseHandle(target); + wchar_t stage[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!windows_unique_sibling(target_path, L"replace", stage, CBM_WINDOWS_LAUNCHER_PATH_CAP) || + !windows_copy_flush_private(candidate_path, stage)) { + launcher_error(error, error_size, "could not stage launcher replacement"); + return false; + } + HANDLE file = + CreateFileW(stage, DELETE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH, NULL); + BY_HANDLE_FILE_INFORMATION information; + bool ok = windows_file_identity(file, &information) && windows_owner_is_current(file) && + windows_acl_secure(file) && windows_posix_rename_handle(file, target_path); + if (file != INVALID_HANDLE_VALUE) + (void)CloseHandle(file); + if (!ok) { + (void)DeleteFileW(stage); + launcher_error(error, error_size, "atomic launcher replacement is unsupported or failed"); + } + return ok; +} + +bool cbm_windows_launcher_remove_posix(const wchar_t *target_path, char *error, size_t error_size) { + if (error && error_size > 0U) + error[0] = '\0'; + if (!target_path || !target_path[0]) { + launcher_error(error, error_size, "invalid launcher removal path"); + return false; + } + HANDLE file = windows_open_regular_no_reparse(target_path, DELETE); + if (file == INVALID_HANDLE_VALUE) { + DWORD attributes = GetFileAttributesW(target_path); + DWORD remove_error = attributes == INVALID_FILE_ATTRIBUTES ? GetLastError() : ERROR_SUCCESS; + if (attributes == INVALID_FILE_ATTRIBUTES && + (remove_error == ERROR_FILE_NOT_FOUND || remove_error == ERROR_PATH_NOT_FOUND)) { + return true; + } + launcher_error(error, error_size, "could not securely open launcher for removal"); + return false; + } + bool removed = windows_posix_remove_handle(file); + (void)CloseHandle(file); + if (!removed) { + launcher_error(error, error_size, "POSIX launcher deletion is unsupported or failed"); + } + return removed; +} + +/* 1 = valid current, 0 = absent, -1 = unsafe/corrupt. */ +static int windows_current_v1_read(const wchar_t *canonical_launcher_path, + cbm_windows_current_v1_t *state_out) { + if (state_out) + memset(state_out, 0, sizeof(*state_out)); + wchar_t directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t state_directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t current[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!canonical_launcher_path || !state_out || + !windows_parent_path(canonical_launcher_path, directory, CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + return -1; + } + int state_written = + swprintf(state_directory, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\.cbm", directory); + int current_written = + swprintf(current, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\current-v1", state_directory); + if (state_written <= 0 || current_written <= 0 || + (size_t)state_written >= CBM_WINDOWS_LAUNCHER_PATH_CAP || + (size_t)current_written >= CBM_WINDOWS_LAUNCHER_PATH_CAP) { + return -1; + } + HANDLE file = windows_open_regular_no_reparse(current, GENERIC_READ); + if (file == INVALID_HANDLE_VALUE) { + DWORD attributes = GetFileAttributesW(current); + DWORD open_error = attributes == INVALID_FILE_ATTRIBUTES ? GetLastError() : ERROR_SUCCESS; + return attributes == INVALID_FILE_ATTRIBUTES && + (open_error == ERROR_FILE_NOT_FOUND || open_error == ERROR_PATH_NOT_FOUND) + ? 0 + : -1; + } + LARGE_INTEGER size; + uint8_t record[CBM_WINDOWS_CURRENT_V1_SIZE]; + uint8_t trailing = 0U; + DWORD trailing_count = 0U; + bool valid = GetFileSizeEx(file, &size) != 0 && size.QuadPart == CBM_WINDOWS_CURRENT_V1_SIZE && + windows_read_exact(file, record, sizeof(record)) && + ReadFile(file, &trailing, 1U, &trailing_count, NULL) != 0 && + trailing_count == 0U && + cbm_windows_current_v1_decode(record, sizeof(record), state_out); + (void)CloseHandle(file); + return valid ? 1 : -1; +} + +static bool windows_generation_directory_path( + const wchar_t *canonical_launcher_path, const char payload_sha256[65], + wchar_t directory_out[CBM_WINDOWS_LAUNCHER_PATH_CAP]) { + wchar_t payload[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + return cbm_windows_generation_payload_path(canonical_launcher_path, payload_sha256, payload, + CBM_WINDOWS_LAUNCHER_PATH_CAP) && + windows_parent_path(payload, directory_out, CBM_WINDOWS_LAUNCHER_PATH_CAP); +} + +bool cbm_windows_launcher_file_secure(const wchar_t *launcher_path, char *error, + size_t error_size) { + if (error && error_size > 0U) + error[0] = '\0'; + HANDLE launcher = launcher_path ? windows_open_regular_no_reparse(launcher_path, GENERIC_READ) + : INVALID_HANDLE_VALUE; + if (launcher == INVALID_HANDLE_VALUE) { + launcher_error(error, error_size, "launcher path, owner, or access policy is unsafe"); + return false; + } + (void)CloseHandle(launcher); + return true; +} + +static bool windows_generation_name_valid(const wchar_t *name) { + if (!name || wcslen(name) != 64U) + return false; + for (size_t index = 0U; index < 64U; index++) { + wchar_t value = name[index]; + if (!((value >= L'0' && value <= L'9') || (value >= L'a' && value <= L'f'))) { + return false; + } + } + return true; +} + +static bool windows_remove_generation_directory(const wchar_t *generation_directory) { + DWORD attributes = GetFileAttributesW(generation_directory); + if (attributes == INVALID_FILE_ATTRIBUTES) { + DWORD missing_error = GetLastError(); + return missing_error == ERROR_FILE_NOT_FOUND || missing_error == ERROR_PATH_NOT_FOUND; + } + HANDLE directory = windows_open_directory_secure(generation_directory); + if (directory == INVALID_HANDLE_VALUE) + return false; + (void)CloseHandle(directory); + + wchar_t pattern[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + int pattern_written = + swprintf(pattern, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\*", generation_directory); + if (pattern_written <= 0 || (size_t)pattern_written >= CBM_WINDOWS_LAUNCHER_PATH_CAP) { + return false; + } + WIN32_FIND_DATAW entry; + HANDLE search = FindFirstFileW(pattern, &entry); + if (search == INVALID_HANDLE_VALUE) + return false; + bool exact = true; + size_t payload_entries = 0U; + do { + if (wcscmp(entry.cFileName, L".") == 0 || wcscmp(entry.cFileName, L"..") == 0) { + continue; + } + bool payload = wcscmp(entry.cFileName, L"codebase-memory-mcp.payload.exe") == 0 && + (entry.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0; + if (!payload) { + exact = false; + break; + } + payload_entries++; + } while (FindNextFileW(search, &entry)); + DWORD find_error = GetLastError(); + (void)FindClose(search); + exact = exact && find_error == ERROR_NO_MORE_FILES && payload_entries == 1U; + if (!exact) + return false; + + wchar_t payload_path[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + int payload_written = swprintf(payload_path, CBM_WINDOWS_LAUNCHER_PATH_CAP, + L"%ls\\codebase-memory-mcp.payload.exe", generation_directory); + HANDLE payload = payload_written > 0 && (size_t)payload_written < CBM_WINDOWS_LAUNCHER_PATH_CAP + ? windows_open_regular_no_reparse(payload_path, DELETE) + : INVALID_HANDLE_VALUE; + bool removed = payload != INVALID_HANDLE_VALUE && windows_posix_remove_handle(payload); + if (payload != INVALID_HANDLE_VALUE) + (void)CloseHandle(payload); + return removed && RemoveDirectoryW(generation_directory) != 0; +} + +bool cbm_windows_generation_rollback_if_unreferenced(const wchar_t *canonical_launcher_path, + const char payload_sha256[65], + bool created_by_activation, char *error, + size_t error_size) { + if (error && error_size > 0U) + error[0] = '\0'; + if (!created_by_activation) + return true; + if (!canonical_launcher_path || !lowercase_sha256_valid(payload_sha256)) { + launcher_error(error, error_size, "invalid generation rollback request"); + return false; + } + cbm_windows_current_v1_t current; + int current_status = windows_current_v1_read(canonical_launcher_path, ¤t); + if (current_status < 0) { + launcher_error(error, error_size, "current-v1 is unsafe during generation rollback"); + return false; + } + if (current_status == 1 && strcmp(current.payload_sha256, payload_sha256) == 0) { + return true; + } + wchar_t generation[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!windows_generation_directory_path(canonical_launcher_path, payload_sha256, generation) || + !windows_remove_generation_directory(generation)) { + launcher_error(error, error_size, + "new unreferenced generation could not be rolled back safely"); + return false; + } + return true; +} + +bool cbm_windows_generations_prune(const wchar_t *canonical_launcher_path, size_t *removed_out, + char *error, size_t error_size) { + if (removed_out) + *removed_out = 0U; + if (error && error_size > 0U) + error[0] = '\0'; + cbm_windows_current_v1_t current; + if (!canonical_launcher_path || + windows_current_v1_read(canonical_launcher_path, ¤t) != 1) { + launcher_error(error, error_size, "current-v1 is unsafe during generation pruning"); + return false; + } + wchar_t current_generation[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t generations_root[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!windows_generation_directory_path(canonical_launcher_path, current.payload_sha256, + current_generation) || + !windows_parent_path(current_generation, generations_root, CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + launcher_error(error, error_size, "generation root path could not be resolved"); + return false; + } + HANDLE root = windows_open_directory_secure(generations_root); + if (root == INVALID_HANDLE_VALUE) { + launcher_error(error, error_size, "generation root is missing or unsafe"); + return false; + } + (void)CloseHandle(root); + + wchar_t pattern[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + int pattern_written = + swprintf(pattern, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\*", generations_root); + if (pattern_written <= 0 || (size_t)pattern_written >= CBM_WINDOWS_LAUNCHER_PATH_CAP) { + launcher_error(error, error_size, "generation enumeration path is too long"); + return false; + } + WIN32_FIND_DATAW entry; + HANDLE search = FindFirstFileW(pattern, &entry); + if (search == INVALID_HANDLE_VALUE) { + launcher_error(error, error_size, "generation root could not be enumerated"); + return false; + } + wchar_t current_name[65]; + for (size_t index = 0U; index < 64U; index++) { + current_name[index] = (wchar_t)(unsigned char)current.payload_sha256[index]; + } + current_name[64] = L'\0'; + bool ok = true; + size_t removed = 0U; + do { + if (wcscmp(entry.cFileName, L".") == 0 || wcscmp(entry.cFileName, L"..") == 0) { + continue; + } + if (_wcsicmp(entry.cFileName, current_name) == 0) { + bool canonical_current = wcscmp(entry.cFileName, current_name) == 0 && + (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; + if (!canonical_current) + ok = false; + continue; + } + if (!windows_generation_name_valid(entry.cFileName) || + (entry.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0 || + (entry.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) != 0) { + ok = false; + continue; + } + wchar_t generation[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + int generation_written = swprintf(generation, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\%ls", + generations_root, entry.cFileName); + if (generation_written <= 0 || + (size_t)generation_written >= CBM_WINDOWS_LAUNCHER_PATH_CAP || + !windows_remove_generation_directory(generation)) { + ok = false; + continue; + } + removed++; + } while (FindNextFileW(search, &entry)); + DWORD find_error = GetLastError(); + (void)FindClose(search); + ok = ok && find_error == ERROR_NO_MORE_FILES; + if (removed_out) + *removed_out = removed; + if (!ok) { + launcher_error(error, error_size, + "one or more non-current generations were unsafe or could not be pruned"); + } + return ok; +} + +bool cbm_windows_current_v1_write_atomic(const wchar_t *canonical_launcher_path, + const cbm_windows_current_v1_t *state, char *error, + size_t error_size) { + if (error && error_size > 0U) + error[0] = '\0'; + uint8_t record[CBM_WINDOWS_CURRENT_V1_SIZE]; + wchar_t directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + HANDLE canonical = canonical_launcher_path + ? windows_open_regular_no_reparse(canonical_launcher_path, GENERIC_READ) + : INVALID_HANDLE_VALUE; + if (!canonical_launcher_path || canonical == INVALID_HANDLE_VALUE || + !cbm_windows_current_v1_encode(state, record) || + !windows_parent_path(canonical_launcher_path, directory, CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + if (canonical != INVALID_HANDLE_VALUE) + (void)CloseHandle(canonical); + launcher_error(error, error_size, "invalid current-v1 write request"); + return false; + } + (void)CloseHandle(canonical); + wchar_t state_directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t current[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + int state_written = + swprintf(state_directory, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\.cbm", directory); + int current_written = + swprintf(current, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\current-v1", state_directory); + if (state_written <= 0 || current_written <= 0 || + (size_t)state_written >= CBM_WINDOWS_LAUNCHER_PATH_CAP || + (size_t)current_written >= CBM_WINDOWS_LAUNCHER_PATH_CAP || + (!CreateDirectoryW(state_directory, NULL) && GetLastError() != ERROR_ALREADY_EXISTS)) { + launcher_error(error, error_size, "could not create launcher state directory"); + return false; + } + HANDLE state_handle = windows_open_directory_secure(state_directory); + DWORD current_attributes = GetFileAttributesW(current); + DWORD current_error = + current_attributes == INVALID_FILE_ATTRIBUTES ? GetLastError() : ERROR_SUCCESS; + HANDLE existing = current_attributes == INVALID_FILE_ATTRIBUTES + ? INVALID_HANDLE_VALUE + : windows_open_regular_no_reparse(current, GENERIC_READ); + bool current_absent = + current_attributes == INVALID_FILE_ATTRIBUTES && + (current_error == ERROR_FILE_NOT_FOUND || current_error == ERROR_PATH_NOT_FOUND); + if (state_handle == INVALID_HANDLE_VALUE || + (!current_absent && existing == INVALID_HANDLE_VALUE)) { + if (state_handle != INVALID_HANDLE_VALUE) + (void)CloseHandle(state_handle); + if (existing != INVALID_HANDLE_VALUE) + (void)CloseHandle(existing); + launcher_error(error, error_size, "launcher state directory or current-v1 is unsafe"); + return false; + } + (void)CloseHandle(state_handle); + if (existing != INVALID_HANDLE_VALUE) + (void)CloseHandle(existing); + wchar_t stage[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!windows_unique_sibling(current, L"current", stage, CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + launcher_error(error, error_size, "current-v1 stage path is too long"); + return false; + } + HANDLE file = CreateFileW( + stage, GENERIC_WRITE | DELETE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_DELETE, NULL, CREATE_NEW, + FILE_ATTRIBUTE_HIDDEN | FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_WRITE_THROUGH, NULL); + DWORD written = 0U; + BY_HANDLE_FILE_INFORMATION information; + bool ok = file != INVALID_HANDLE_VALUE && + WriteFile(file, record, sizeof(record), &written, NULL) != 0 && + written == sizeof(record) && FlushFileBuffers(file) != 0 && + windows_file_identity(file, &information) && windows_owner_is_current(file) && + windows_acl_secure(file) && windows_posix_rename_handle(file, current); + if (file != INVALID_HANDLE_VALUE) + (void)CloseHandle(file); + if (!ok) { + (void)DeleteFileW(stage); + launcher_error(error, error_size, "atomic current-v1 publication is unsupported or failed"); + } + return ok; +} + +#define CBM_RELEASE_DESCRIPTOR_ARG L"__cbm_windows_release_descriptor_v1" + +bool cbm_windows_release_descriptor_probe(const wchar_t *launcher_candidate, + cbm_windows_release_descriptor_v1_t *descriptor_out, + char *error, size_t error_size) { + if (descriptor_out) { + memset(descriptor_out, 0, sizeof(*descriptor_out)); + } + if (error && error_size > 0U) + error[0] = '\0'; + HANDLE candidate = launcher_candidate + ? windows_open_regular_no_reparse(launcher_candidate, GENERIC_READ) + : INVALID_HANDLE_VALUE; + if (!descriptor_out || candidate == INVALID_HANDLE_VALUE) { + if (candidate != INVALID_HANDLE_VALUE) + (void)CloseHandle(candidate); + launcher_error(error, error_size, "launcher descriptor candidate is unsafe"); + return false; + } + (void)CloseHandle(candidate); + + SECURITY_ATTRIBUTES security = { + .nLength = sizeof(security), + .lpSecurityDescriptor = NULL, + .bInheritHandle = TRUE, + }; + HANDLE read_pipe = NULL; + HANDLE write_pipe = NULL; + HANDLE null_input = INVALID_HANDLE_VALUE; + HANDLE null_error = INVALID_HANDLE_VALUE; + HANDLE job = NULL; + PROCESS_INFORMATION child; + memset(&child, 0, sizeof(child)); + bool ready = CreatePipe(&read_pipe, &write_pipe, &security, 4096U) != 0 && + SetHandleInformation(read_pipe, HANDLE_FLAG_INHERIT, 0U) != 0; + null_input = ready ? CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, + &security, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL) + : INVALID_HANDLE_VALUE; + null_error = ready ? CreateFileW(L"NUL", GENERIC_WRITE, FILE_SHARE_READ | FILE_SHARE_WRITE, + &security, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL) + : INVALID_HANDLE_VALUE; + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits; + memset(&limits, 0, sizeof(limits)); + limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + job = ready ? CreateJobObjectW(NULL, NULL) : NULL; + ready = + ready && null_input != INVALID_HANDLE_VALUE && null_error != INVALID_HANDLE_VALUE && job && + SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof(limits)) != + 0; + + HANDLE inherited[3] = {write_pipe, null_input, null_error}; + SIZE_T attribute_size = 0U; + (void)InitializeProcThreadAttributeList(NULL, 2U, 0U, &attribute_size); + LPPROC_THREAD_ATTRIBUTE_LIST attributes = + ready && attribute_size ? malloc(attribute_size) : NULL; + bool initialized = + attributes && InitializeProcThreadAttributeList(attributes, 2U, 0U, &attribute_size) != 0; + ready = ready && initialized && + UpdateProcThreadAttribute(attributes, 0U, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited, + sizeof(inherited), NULL, NULL) != 0 && + UpdateProcThreadAttribute(attributes, 0U, PROC_THREAD_ATTRIBUTE_JOB_LIST, &job, + sizeof(job), NULL, NULL) != 0; + + wchar_t command[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + int command_length = launcher_candidate + ? swprintf(command, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"\"%ls\" %ls", + launcher_candidate, CBM_RELEASE_DESCRIPTOR_ARG) + : -1; + STARTUPINFOEXW startup; + memset(&startup, 0, sizeof(startup)); + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = null_input; + startup.StartupInfo.hStdOutput = write_pipe; + startup.StartupInfo.hStdError = null_error; + startup.lpAttributeList = attributes; + bool spawned = ready && command_length > 0 && + (size_t)command_length < CBM_WINDOWS_LAUNCHER_PATH_CAP && + CreateProcessW(launcher_candidate, command, NULL, NULL, TRUE, + CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT, NULL, NULL, + &startup.StartupInfo, &child) != 0; + if (initialized) + DeleteProcThreadAttributeList(attributes); + free(attributes); + if (write_pipe) { + (void)CloseHandle(write_pipe); + write_pipe = NULL; + } + if (null_input != INVALID_HANDLE_VALUE) { + (void)CloseHandle(null_input); + null_input = INVALID_HANDLE_VALUE; + } + if (null_error != INVALID_HANDLE_VALUE) { + (void)CloseHandle(null_error); + null_error = INVALID_HANDLE_VALUE; + } + + uint8_t record[CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE + 1U]; + size_t received_total = 0U; + bool pipe_closed = false; + uint64_t now = GetTickCount64(); + uint64_t deadline = UINT64_MAX - now < 30000U ? UINT64_MAX : now + 30000U; + while (spawned && GetTickCount64() < deadline && !pipe_closed) { + DWORD available = 0U; + if (!PeekNamedPipe(read_pipe, NULL, 0U, NULL, &available, NULL)) { + pipe_closed = GetLastError() == ERROR_BROKEN_PIPE; + break; + } + if (available > 0U) { + DWORD capacity = (DWORD)(sizeof(record) - received_total); + DWORD request = available < capacity ? available : capacity; + DWORD amount = 0U; + if (request == 0U || + !ReadFile(read_pipe, record + received_total, request, &amount, NULL) || + amount == 0U) { + break; + } + received_total += amount; + if (received_total == sizeof(record)) { + break; + } + continue; + } + Sleep(2U); + } + DWORD wait = spawned ? WaitForSingleObject(child.hProcess, 0U) : WAIT_FAILED; + if (spawned && wait != WAIT_OBJECT_0) { + (void)TerminateJobObject(job, 1U); + (void)WaitForSingleObject(child.hProcess, 5000U); + } + DWORD exit_code = 1U; + bool valid = + spawned && pipe_closed && received_total == CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE && + WaitForSingleObject(child.hProcess, 0U) == WAIT_OBJECT_0 && + GetExitCodeProcess(child.hProcess, &exit_code) != 0 && exit_code == 0U && + cbm_windows_release_descriptor_v1_decode(record, CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE, + descriptor_out); + if (child.hThread) + (void)CloseHandle(child.hThread); + if (child.hProcess) + (void)CloseHandle(child.hProcess); + if (read_pipe) + (void)CloseHandle(read_pipe); + if (job) + (void)CloseHandle(job); + if (!valid) { + memset(descriptor_out, 0, sizeof(*descriptor_out)); + launcher_error(error, error_size, + "launcher release descriptor was missing, malformed, or timed out"); + } + return valid; +} + +/* Probe subprocess protocol. The standalone launcher recognizes this private + * mode, signals ready, and remains mapped until the release event is set. */ +#define CBM_LAUNCHER_PROBE_ARG L"__cbm_launcher_capability_probe_v1" + +static bool windows_probe_volume(const wchar_t *directory, char *error, size_t error_size) { + wchar_t volume[MAX_PATH + 1U]; + wchar_t filesystem[MAX_PATH + 1U]; + if (!GetVolumePathNameW(directory, volume, MAX_PATH + 1U) || + GetDriveTypeW(volume) != DRIVE_FIXED || + !GetVolumeInformationW(volume, NULL, 0U, NULL, NULL, NULL, filesystem, MAX_PATH + 1U) || + _wcsicmp(filesystem, L"NTFS") != 0) { + launcher_error(error, error_size, "managed launcher requires a local fixed NTFS volume"); + return false; + } + return true; +} + +static bool windows_probe_spawn(const wchar_t *image, HANDLE ready, HANDLE release, + PROCESS_INFORMATION *child) { + wchar_t command[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + int written = swprintf(command, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"\"%ls\" %ls %llx %llx", image, + CBM_LAUNCHER_PROBE_ARG, (unsigned long long)(uintptr_t)ready, + (unsigned long long)(uintptr_t)release); + if (written <= 0 || (size_t)written >= CBM_WINDOWS_LAUNCHER_PATH_CAP) { + return false; + } + HANDLE inherited[2] = {ready, release}; + SIZE_T attribute_size = 0U; + (void)InitializeProcThreadAttributeList(NULL, 1U, 0U, &attribute_size); + LPPROC_THREAD_ATTRIBUTE_LIST attributes = attribute_size ? malloc(attribute_size) : NULL; + bool initialized = + attributes && InitializeProcThreadAttributeList(attributes, 1U, 0U, &attribute_size) != 0; + bool ready_attributes = + initialized && UpdateProcThreadAttribute(attributes, 0U, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inherited, sizeof(inherited), NULL, NULL) != 0; + STARTUPINFOEXW startup; + memset(&startup, 0, sizeof(startup)); + memset(child, 0, sizeof(*child)); + startup.StartupInfo.cb = sizeof(startup); + startup.lpAttributeList = attributes; + bool spawned = + ready_attributes && CreateProcessW(image, command, NULL, NULL, TRUE, + CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT, NULL, + NULL, &startup.StartupInfo, child) != 0; + if (initialized) + DeleteProcThreadAttributeList(attributes); + free(attributes); + return spawned; +} + +static void windows_probe_child_stop(PROCESS_INFORMATION *child, HANDLE release) { + if (release) + (void)SetEvent(release); + if (child->hProcess) { + if (WaitForSingleObject(child->hProcess, 5000U) != WAIT_OBJECT_0) { + (void)TerminateProcess(child->hProcess, 1U); + (void)WaitForSingleObject(child->hProcess, 5000U); + } + (void)CloseHandle(child->hProcess); + } + if (child->hThread) + (void)CloseHandle(child->hThread); + memset(child, 0, sizeof(*child)); +} + +typedef struct { + wchar_t **paths; + size_t count; + size_t capacity; +} windows_created_directories_t; + +static void windows_created_directories_close(windows_created_directories_t *created, + bool remove_directories) { + if (!created) + return; + for (size_t index = 0U; index < created->count; index++) { + if (remove_directories) + (void)RemoveDirectoryW(created->paths[index]); + free(created->paths[index]); + } + free(created->paths); + memset(created, 0, sizeof(*created)); +} + +static bool windows_created_directories_push(windows_created_directories_t *created, + const wchar_t *path) { + if (created->count == created->capacity) { + size_t next_capacity = created->capacity == 0U ? 8U : created->capacity * 2U; + if (next_capacity < created->capacity || next_capacity > CBM_WINDOWS_LAUNCHER_PATH_CAP) { + return false; + } + wchar_t **next = realloc(created->paths, next_capacity * sizeof(*next)); + if (!next) + return false; + created->paths = next; + created->capacity = next_capacity; + } + size_t length = wcslen(path); + wchar_t *copy = malloc((length + 1U) * sizeof(*copy)); + if (!copy) + return false; + memcpy(copy, path, (length + 1U) * sizeof(*copy)); + created->paths[created->count++] = copy; + return true; +} + +static bool windows_prepare_probe_directory(const wchar_t *target, + windows_created_directories_t *created) { + memset(created, 0, sizeof(*created)); + size_t length = target ? wcslen(target) : 0U; + if (length < 3U || length >= CBM_WINDOWS_LAUNCHER_PATH_CAP || target[1] != L':' || + (target[2] != L'\\' && target[2] != L'/')) { + return false; + } + wchar_t *cursor = malloc((length + 1U) * sizeof(*cursor)); + if (!cursor) + return false; + memcpy(cursor, target, (length + 1U) * sizeof(*cursor)); + for (size_t index = 0U; index < length; index++) { + if (cursor[index] == L'/') + cursor[index] = L'\\'; + } + while (length > 3U && cursor[length - 1U] == L'\\') { + cursor[--length] = L'\0'; + } + bool valid = true; + for (;;) { + DWORD attributes = GetFileAttributesW(cursor); + if (attributes != INVALID_FILE_ATTRIBUTES) { + HANDLE ancestor = CreateFileW( + cursor, FILE_READ_ATTRIBUTES, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + BY_HANDLE_FILE_INFORMATION information; + valid = ancestor != INVALID_HANDLE_VALUE && GetFileType(ancestor) == FILE_TYPE_DISK && + GetFileInformationByHandle(ancestor, &information) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && + windows_owner_secure(ancestor, false) && windows_acl_secure(ancestor); + if (ancestor != INVALID_HANDLE_VALUE) + (void)CloseHandle(ancestor); + break; + } + DWORD error = GetLastError(); + if ((error != ERROR_FILE_NOT_FOUND && error != ERROR_PATH_NOT_FOUND) || + !windows_created_directories_push(created, cursor)) { + valid = false; + break; + } + wchar_t *separator = wcsrchr(cursor, L'\\'); + if (!separator || separator <= cursor + 2) { + valid = false; + break; + } + *separator = L'\0'; + } + for (size_t remaining = created->count; valid && remaining > 0U; remaining--) { + const wchar_t *directory = created->paths[remaining - 1U]; + if (!CreateDirectoryW(directory, NULL)) { + valid = false; + break; + } + HANDLE handle = windows_open_directory_secure(directory); + valid = handle != INVALID_HANDLE_VALUE; + if (handle != INVALID_HANDLE_VALUE) + (void)CloseHandle(handle); + } + free(cursor); + if (!valid) { + windows_created_directories_close(created, true); + } + return valid; +} + +bool cbm_windows_launcher_capability_probe(const wchar_t *target_directory, + const wchar_t *launcher_candidate, char *error, + size_t error_size) { + if (error && error_size > 0U) + error[0] = '\0'; + if (!target_directory || !target_directory[0] || !launcher_candidate || + !launcher_candidate[0]) { + launcher_error(error, error_size, "invalid launcher capability probe request"); + return false; + } + wchar_t forced[16]; + if (GetEnvironmentVariableW(L"CBM_TEST_WINDOWS_LAUNCHER_CAPABILITY_PROBE", forced, + (DWORD)(sizeof(forced) / sizeof(forced[0]))) > 0U && + _wcsicmp(forced, L"fail") == 0) { + launcher_error(error, error_size, "Windows launcher capability probe was forced to fail"); + return false; + } + windows_created_directories_t created; + if (!windows_prepare_probe_directory(target_directory, &created)) { + launcher_error(error, error_size, "could not safely prepare capability probe directory"); + return false; + } + wchar_t probe_tree_path[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + int tree_written = swprintf(probe_tree_path, CBM_WINDOWS_LAUNCHER_PATH_CAP, + L"%ls\\.cbm-probe-path-check", target_directory); + HANDLE target_handle = windows_open_directory_secure(target_directory); + bool ok = tree_written > 0 && (size_t)tree_written < CBM_WINDOWS_LAUNCHER_PATH_CAP && + windows_path_tree_plain(probe_tree_path) && target_handle != INVALID_HANDLE_VALUE && + windows_probe_volume(target_directory, error, error_size); + if (target_handle != INVALID_HANDLE_VALUE) + (void)CloseHandle(target_handle); + HANDLE mutex = + ok ? CreateMutexW(NULL, FALSE, L"Local\\CBMWindowsLauncherCapabilityProbe-v1") : NULL; + DWORD mutex_wait = mutex ? WaitForSingleObject(mutex, 30000U) : WAIT_FAILED; + ok = ok && mutex_wait == WAIT_OBJECT_0; + wchar_t probe_a[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t probe_b[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + int a_written = + swprintf(probe_a, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\.cbm-launcher-probe-a-%lu.exe", + target_directory, (unsigned long)GetCurrentProcessId()); + int b_written = + swprintf(probe_b, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\.cbm-launcher-probe-b-%lu.exe", + target_directory, (unsigned long)GetCurrentProcessId()); + ok = ok && a_written > 0 && b_written > 0 && + (size_t)a_written < CBM_WINDOWS_LAUNCHER_PATH_CAP && + (size_t)b_written < CBM_WINDOWS_LAUNCHER_PATH_CAP && + windows_copy_flush_private(launcher_candidate, probe_a) && + windows_copy_flush_private(launcher_candidate, probe_b); + + SECURITY_ATTRIBUTES security = { + .nLength = sizeof(security), + .lpSecurityDescriptor = NULL, + .bInheritHandle = TRUE, + }; + HANDLE ready_a = ok ? CreateEventW(&security, TRUE, FALSE, NULL) : NULL; + HANDLE release_a = ok ? CreateEventW(&security, TRUE, FALSE, NULL) : NULL; + PROCESS_INFORMATION child_a; + memset(&child_a, 0, sizeof(child_a)); + ok = ok && ready_a && release_a && windows_probe_spawn(probe_a, ready_a, release_a, &child_a) && + WaitForSingleObject(ready_a, 10000U) == WAIT_OBJECT_0; + + HANDLE source = ok ? CreateFileW(probe_b, DELETE | FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_FLAG_OPEN_REPARSE_POINT, NULL) + : INVALID_HANDLE_VALUE; + BY_HANDLE_FILE_INFORMATION source_info; + ok = ok && windows_file_identity(source, &source_info) && windows_owner_is_current(source) && + windows_posix_rename_handle(source, probe_a) && + GetFileAttributesW(probe_a) != INVALID_FILE_ATTRIBUTES; + if (source != INVALID_HANDLE_VALUE) + (void)CloseHandle(source); + windows_probe_child_stop(&child_a, release_a); + if (ready_a) + (void)CloseHandle(ready_a); + if (release_a) + (void)CloseHandle(release_a); + + /* The replacement at probe_a is now a fresh candidate. Map it and prove + * POSIX deletion removes the visible name while the image remains live. */ + HANDLE ready_b = ok ? CreateEventW(&security, TRUE, FALSE, NULL) : NULL; + HANDLE release_b = ok ? CreateEventW(&security, TRUE, FALSE, NULL) : NULL; + PROCESS_INFORMATION child_b; + memset(&child_b, 0, sizeof(child_b)); + ok = ok && ready_b && release_b && windows_probe_spawn(probe_a, ready_b, release_b, &child_b) && + WaitForSingleObject(ready_b, 10000U) == WAIT_OBJECT_0; + HANDLE mapped = ok ? windows_open_regular_no_reparse(probe_a, DELETE) : INVALID_HANDLE_VALUE; + ok = ok && mapped != INVALID_HANDLE_VALUE && windows_posix_remove_handle(mapped) && + GetFileAttributesW(probe_a) == INVALID_FILE_ATTRIBUTES && + GetLastError() == ERROR_FILE_NOT_FOUND; + if (mapped != INVALID_HANDLE_VALUE) + (void)CloseHandle(mapped); + windows_probe_child_stop(&child_b, release_b); + if (ready_b) + (void)CloseHandle(ready_b); + if (release_b) + (void)CloseHandle(release_b); + + (void)DeleteFileW(probe_a); + (void)DeleteFileW(probe_b); + if (mutex_wait == WAIT_OBJECT_0) + (void)ReleaseMutex(mutex); + if (mutex) + (void)CloseHandle(mutex); + windows_created_directories_close(&created, true); + if (!ok && (!error || error_size == 0U || error[0] == '\0')) { + launcher_error(error, error_size, + "mapped-image POSIX replace/delete capability probe failed"); + } + return ok; +} + +#endif /* _WIN32 */ diff --git a/src/cli/windows_launcher_state.h b/src/cli/windows_launcher_state.h new file mode 100644 index 000000000..ee75ddb67 --- /dev/null +++ b/src/cli/windows_launcher_state.h @@ -0,0 +1,126 @@ +/* + * windows_launcher_state.h -- Stable state shared by the Windows launcher and + * the CBM payload. + * + * The on-disk current-v1 record is deliberately fixed-size and contains no + * native C layout. Keep its codec platform-independent: release compatibility + * is a byte contract, not a compiler/architecture contract. + */ +#ifndef CBM_WINDOWS_LAUNCHER_STATE_H +#define CBM_WINDOWS_LAUNCHER_STATE_H + +#include +#include +#include +#include + +#define CBM_WINDOWS_CURRENT_V1_SIZE 128U +#define CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE 128U +#ifndef CBM_WINDOWS_LAUNCHER_ABI_CURRENT +#define CBM_WINDOWS_LAUNCHER_ABI_CURRENT 1U +#endif +#ifndef CBM_WINDOWS_PAYLOAD_LAUNCHER_ABI_MIN +#define CBM_WINDOWS_PAYLOAD_LAUNCHER_ABI_MIN 1U +#endif +#ifndef CBM_WINDOWS_PAYLOAD_LAUNCHER_ABI_MAX +#define CBM_WINDOWS_PAYLOAD_LAUNCHER_ABI_MAX 1U +#endif +#define CBM_WINDOWS_LAUNCHER_PATH_CAP 32768U + +typedef struct { + uint32_t launcher_abi_min; + uint32_t launcher_abi_max; + uint64_t payload_size; + char payload_sha256[65]; +} cbm_windows_current_v1_t; + +typedef struct { + uint32_t launcher_abi; + uint32_t payload_launcher_abi_min; + uint32_t payload_launcher_abi_max; + uint64_t payload_size; + char payload_sha256[65]; +} cbm_windows_release_descriptor_v1_t; + +typedef enum { + CBM_WINDOWS_TRANSITION_INCOMPATIBLE = 0, + CBM_WINDOWS_TRANSITION_LAUNCHER_FIRST = 1, + CBM_WINDOWS_TRANSITION_CURRENT_FIRST = 2, +} cbm_windows_transition_plan_t; + +typedef enum { + CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY = 0, + CBM_WINDOWS_LAUNCHER_ACTION_UPDATE = 1, + CBM_WINDOWS_LAUNCHER_ACTION_UNINSTALL = 2, +} cbm_windows_launcher_action_t; + +bool cbm_windows_current_v1_encode(const cbm_windows_current_v1_t *state, + uint8_t out[CBM_WINDOWS_CURRENT_V1_SIZE]); +bool cbm_windows_current_v1_decode(const uint8_t *record, size_t record_size, + cbm_windows_current_v1_t *state_out); +bool cbm_windows_current_v1_supports_launcher_abi(const cbm_windows_current_v1_t *state, + uint32_t launcher_abi); + +bool cbm_windows_release_descriptor_v1_encode(const cbm_windows_release_descriptor_v1_t *descriptor, + uint8_t out[CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE]); +bool cbm_windows_release_descriptor_v1_decode(const uint8_t *record, size_t record_size, + cbm_windows_release_descriptor_v1_t *descriptor_out); +cbm_windows_transition_plan_t cbm_windows_transition_plan( + const cbm_windows_current_v1_t *current, const cbm_windows_release_descriptor_v1_t *candidate); + +/* Resolve the immutable payload using the canonical launcher's directory. */ +bool cbm_windows_generation_payload_path(const wchar_t *canonical_launcher_path, + const char payload_sha256[65], wchar_t *path_out, + size_t path_capacity); + +/* Match main's top-level dispatch. Tokens after a mode selector (cli, + * install, config, hook-augment, help/version) are opaque user input. */ +cbm_windows_launcher_action_t cbm_windows_launcher_classify_action(int argc, + const char *const argv[]); +bool cbm_windows_launcher_action_allowed(cbm_windows_launcher_action_t action, bool managed); + +/* Trusted launch data delivered over a launcher-owned inherited named pipe. + * An absent context is valid and returns true with present=false (direct + * portable payload). An advertised but invalid context fails closed. */ +typedef struct { + bool present; + bool managed; + bool private_activation; + cbm_windows_launcher_action_t action; + uint64_t payload_size; + char expected_payload_sha256[65]; + wchar_t canonical_launcher_path[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + /* Opaque one-shot authority retained only until startup validation has + * completed. Callers must use context_complete, never inspect it. */ + uintptr_t _authority_handle; +} cbm_windows_launcher_context_t; + +bool cbm_windows_launcher_context_consume(cbm_windows_launcher_context_t *context_out, char *error, + size_t error_size); +bool cbm_windows_launcher_context_complete(cbm_windows_launcher_context_t *context, bool accepted, + char *error, size_t error_size); + +/* Windows managed-install primitives. They fail closed on other platforms. + * Diagnostics are optional and always NUL-terminated when capacity is nonzero. + */ +bool cbm_windows_launcher_capability_probe(const wchar_t *target_directory, + const wchar_t *launcher_candidate, char *error, + size_t error_size); +bool cbm_windows_launcher_file_secure(const wchar_t *launcher_path, char *error, size_t error_size); +bool cbm_windows_release_descriptor_probe(const wchar_t *launcher_candidate, + cbm_windows_release_descriptor_v1_t *descriptor_out, + char *error, size_t error_size); +bool cbm_windows_current_v1_write_atomic(const wchar_t *canonical_launcher_path, + const cbm_windows_current_v1_t *state, char *error, + size_t error_size); +bool cbm_windows_launcher_replace_atomic(const wchar_t *target_path, const wchar_t *candidate_path, + char *error, size_t error_size); +bool cbm_windows_launcher_remove_posix(const wchar_t *target_path, char *error, size_t error_size); +bool cbm_windows_generation_rollback_if_unreferenced(const wchar_t *canonical_launcher_path, + const char payload_sha256[65], + bool created_by_activation, char *error, + size_t error_size); +bool cbm_windows_generations_prune(const wchar_t *canonical_launcher_path, size_t *removed_out, + char *error, size_t error_size); + +#endif /* CBM_WINDOWS_LAUNCHER_STATE_H */ diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index 200d8d166..78e403591 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -1021,11 +1021,11 @@ static cbm_expr_t *parse_exists_predicate(parser_t *p, bool negated) { } cbm_node_pattern_t anchor = {0}; cbm_rel_pattern_t rel = {0}; - cbm_node_pattern_t far = {0}; - if (parse_node(p, &anchor) < 0 || parse_rel(p, &rel) < 0 || parse_node(p, &far) < 0) { + cbm_node_pattern_t far_node = {0}; + if (parse_node(p, &anchor) < 0 || parse_rel(p, &rel) < 0 || parse_node(p, &far_node) < 0) { free_one_node_pattern(&anchor); free_one_rel_pattern(&rel); - free_one_node_pattern(&far); + free_one_node_pattern(&far_node); snprintf(p->error, sizeof(p->error), "unsupported EXISTS pattern — only the single-hop form " "'(var)-[:TYPE]->()' is supported"); @@ -1043,7 +1043,7 @@ static cbm_expr_t *parse_exists_predicate(parser_t *p, bool negated) { : 0; free_one_node_pattern(&anchor); free_one_rel_pattern(&rel); - free_one_node_pattern(&far); + free_one_node_pattern(&far_node); return expr_leaf(c); } diff --git a/src/daemon/application.c b/src/daemon/application.c index eb554141a..845c5c212 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -11,6 +11,7 @@ #include "foundation/log.h" #include "foundation/mem.h" #include "foundation/platform.h" +#include "foundation/subprocess.h" #include "mcp/index_supervisor.h" #include "mcp/mcp.h" #include "mcp/mcp_internal.h" @@ -20,6 +21,7 @@ #include +#include #include #include #include @@ -52,8 +54,17 @@ enum { APPLICATION_DEFAULT_MAX_RESTARTS = 100, APPLICATION_MARKER_MAX_BYTES = 64 * 1024 * 1024, APPLICATION_MAX_SUSPECTS = 65536, + APPLICATION_UPDATE_POLL_US = 10000, + APPLICATION_UPDATE_TIMEOUT_MS = 7000, + APPLICATION_BACKGROUND_REAP_MS = 10000, + APPLICATION_UPDATE_VERSION_CAP = 128, + APPLICATION_UPDATE_NOTICE_CAP = 1024, + APPLICATION_UPDATE_RESPONSE_MAX = 1024 * 1024, }; +#define APPLICATION_UPDATE_URL \ + "https://api.github.com/repos/DeusData/codebase-memory-mcp/releases/latest" + typedef struct cbm_daemon_application_watch cbm_daemon_application_watch_t; typedef struct cbm_daemon_application_session cbm_daemon_application_session_t; typedef struct cbm_daemon_application_job cbm_daemon_application_job_t; @@ -61,6 +72,15 @@ typedef struct cbm_daemon_application_mutation cbm_daemon_application_mutation_t typedef struct cbm_daemon_application_watch_job_subscription cbm_daemon_application_watch_job_subscription_t; +typedef enum { + APPLICATION_JOB_SUBSCRIBE_OK = 0, + APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT, + APPLICATION_JOB_SUBSCRIBE_BUSY, + APPLICATION_JOB_SUBSCRIBE_CANCELLING, + APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE, + APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED, +} application_job_subscribe_status_t; + struct cbm_daemon_application_watch { char *project; char *root; @@ -84,6 +104,15 @@ struct cbm_daemon_application_session { cbm_daemon_application_watch_t *watch; cbm_daemon_application_job_t *active_job; bool active_job_subscribed; + cbm_daemon_application_job_t *auto_index_job; + bool auto_index_subscribed; + bool auto_index_evaluated; + bool auto_index_retry_pending; + bool background_eligible; + bool update_owner; + bool update_notice_delivered; + bool pending_background_initialize; + bool pending_update_notice; cbm_daemon_application_session_t *next; }; @@ -135,25 +164,64 @@ struct cbm_daemon_application { cbm_daemon_application_watch_job_subscription_t *watch_job_subscriptions; cbm_daemon_application_mutation_t *mutations; cbm_daemon_application_worker_ops_t worker_ops; + cbm_daemon_application_update_ops_t update_ops; cbm_project_lock_manager_t *project_locks; size_t physical_job_limit; size_t worker_memory_budget_bytes; size_t active_mutations; + size_t update_owners; + cbm_daemon_application_update_worker_t update_worker; + cbm_thread_t update_thread; + char update_notice[APPLICATION_UPDATE_NOTICE_CAP]; + bool update_generation_started; + bool update_cancel_requested; + bool update_thread_started; + bool update_thread_done; + bool update_thread_joining; bool stopping; }; -static void application_job_unsubscribe_locked( - cbm_daemon_application_job_t *job); +typedef struct { + cbm_subprocess_t *process; + char output_path[APPLICATION_PATH_CAP]; + char latest_version[APPLICATION_UPDATE_VERSION_CAP]; + bool terminal; +} application_update_worker_t; + +static void application_job_unsubscribe_locked(cbm_daemon_application_job_t *job); static void application_watch_job_unsubscribe_session_locked( cbm_daemon_application_session_t *session); +static bool application_watch_job_subscribe_late_session_locked( + cbm_daemon_application_session_t *session, cbm_daemon_application_watch_t *watch); +static bool application_unique_recovery_file(char out[APPLICATION_PATH_CAP], const char *kind); +static bool application_update_reap(cbm_daemon_application_t *application, bool wait, + uint32_t timeout_ms); +static void *application_job_thread(void *opaque); +static char *application_auto_index_args(const char *root_path); +static cbm_daemon_application_job_t *application_job_subscribe_locked( + cbm_daemon_application_t *application, const char *project_key, const char *root_path, + const char *args_json, application_job_subscribe_status_t *status_out); + +static atomic_bool g_application_fail_next_job_thread_start_for_test = ATOMIC_VAR_INIT(false); + +void cbm_daemon_application_fail_next_job_thread_start_for_test(void) { + atomic_store_explicit(&g_application_fail_next_job_thread_start_for_test, true, + memory_order_release); +} -static bool application_request_cancelled_locked( - const cbm_daemon_application_session_t *session) { +static int application_job_thread_create(cbm_thread_t *thread, void *context) { + if (atomic_exchange_explicit(&g_application_fail_next_job_thread_start_for_test, false, + memory_order_acq_rel)) { + return -1; + } + return cbm_thread_create(thread, APPLICATION_JOB_THREAD_STACK, application_job_thread, context); +} + +static bool application_request_cancelled_locked(const cbm_daemon_application_session_t *session) { return session && (session->session_cancelled || (session->request_active && - session->active_request_token != - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && + session->active_request_token != CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && session->request_cancel_token == session->active_request_token)); } @@ -162,8 +230,7 @@ static uint64_t application_deadline_after(uint32_t timeout_ms) { return now > UINT64_MAX - timeout_ms ? UINT64_MAX : now + timeout_ms; } -static _Noreturn void application_cleanup_force_terminate( - const char *component) { +static _Noreturn void application_cleanup_force_terminate(const char *component) { /* In production this module is daemon-owned and the host log sink flushes * every record synchronously. Continuing would either lose the only retry * handle or falsely make a project mutation appear released. */ @@ -178,10 +245,8 @@ static _Noreturn void application_cleanup_force_terminate( #endif } -static void application_project_lock_release_fully( - cbm_project_lock_lease_t **lease) { - uint64_t deadline = - application_deadline_after(APPLICATION_COORDINATION_CLEANUP_MS); +static void application_project_lock_release_fully(cbm_project_lock_lease_t **lease) { + uint64_t deadline = application_deadline_after(APPLICATION_COORDINATION_CLEANUP_MS); while (lease && *lease) { (void)cbm_project_lock_lease_release(lease); if (!*lease) { @@ -195,8 +260,8 @@ static void application_project_lock_release_fully( } static int application_worker_start_default(void *context, const char *args_json, - size_t memory_budget_bytes, - const char *marker_file, const char *quarantine_file, + size_t memory_budget_bytes, const char *marker_file, + const char *quarantine_file, cbm_daemon_application_worker_t *worker_out) { (void)context; cbm_index_worker_handle_t *worker = NULL; @@ -325,10 +390,12 @@ static void application_release_session_watch_locked(cbm_daemon_application_sess } } -static void application_refresh_watch(cbm_daemon_application_session_t *session) { +/* Caller holds application->mutex. */ +static void application_refresh_watch_locked(cbm_daemon_application_session_t *session) { cbm_daemon_application_t *application = session->application; if (!application->watcher || !session->context_set || - session->tool_profile != CBM_MCP_TOOL_PROFILE_ALL) { + session->tool_profile != CBM_MCP_TOOL_PROFILE_ALL || session->hook_event || + session->hook_dialect || session->auto_index_subscribed) { return; } const char *project = cbm_mcp_server_session_project(session->mcp); @@ -340,14 +407,11 @@ static void application_refresh_watch(cbm_daemon_application_session_t *session) cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true); bool db_exists = application_regular_db_exists(project); - cbm_mutex_lock(&application->mutex); /* Disconnect cancellation is the logical ownership boundary. An * in-flight request may reach this refresh after cancel returned but * before runtime can join it and call session_close; it must never * recreate that session's watch. */ - if (application->stopping || - application_request_cancelled_locked(session)) { - cbm_mutex_unlock(&application->mutex); + if (application->stopping || application_request_cancelled_locked(session)) { return; } cbm_daemon_application_watch_t *watch = application_find_watch_locked(application, project); @@ -357,22 +421,24 @@ static void application_refresh_watch(cbm_daemon_application_session_t *session) if (watch) { application_remove_watch_locked(application, watch); } - cbm_mutex_unlock(&application->mutex); return; } if (session->watch) { - cbm_mutex_unlock(&application->mutex); return; } if (watch) { if (strcmp(watch->root, root) == 0) { + if (!application_watch_job_subscribe_late_session_locked(session, watch)) { + cbm_log_warn("daemon.watch.late_owner_allocation_failed", "project", project, + "action", "retry"); + return; + } watch->subscribers++; session->watch = watch; } else { cbm_log_warn("daemon.watch.project_collision", "project", project, "existing_root", watch->root, "requested_root", root); } - cbm_mutex_unlock(&application->mutex); return; } @@ -387,7 +453,6 @@ static void application_refresh_watch(cbm_daemon_application_session_t *session) free(watch->root); free(watch); } - cbm_mutex_unlock(&application->mutex); return; } watch->subscribers = 1; @@ -395,7 +460,15 @@ static void application_refresh_watch(cbm_daemon_application_session_t *session) application->watches = watch; session->watch = watch; cbm_watcher_watch(application->watcher, project, root); - cbm_mutex_unlock(&application->mutex); +} + +static void application_refresh_watch(cbm_daemon_application_session_t *session) { + if (!session || !session->application) { + return; + } + cbm_mutex_lock(&session->application->mutex); + application_refresh_watch_locked(session); + cbm_mutex_unlock(&session->application->mutex); } static void application_job_free(cbm_daemon_application_job_t *job) { @@ -518,6 +591,132 @@ static bool application_unique_recovery_file(char out[APPLICATION_PATH_CAP], con return true; } +static int application_update_worker_start_default( + void *context, cbm_daemon_application_update_worker_t *worker_out) { + (void)context; + if (!worker_out) { + return -1; + } + *worker_out = NULL; + application_update_worker_t *worker = calloc(1, sizeof(*worker)); + if (!worker || !application_unique_recovery_file(worker->output_path, "update")) { + free(worker); + return -1; + } + const char *argv[] = { + "curl", + "-sf", + "--max-time", + "5", + "--max-filesize", + "1048576", + "-H", + "Accept: application/vnd.github+json", + APPLICATION_UPDATE_URL, + NULL, + }; + cbm_proc_opts_t options = { + .bin = "curl", + .argv = argv, + .log_file = worker->output_path, + .quiet_timeout_ms = APPLICATION_UPDATE_TIMEOUT_MS, + .cancel_grace_ms = CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS, + .delete_log_on_exit = false, + }; + if (cbm_subprocess_spawn(&options, &worker->process) != 0) { + (void)cbm_unlink(worker->output_path); + free(worker); + return -1; + } + *worker_out = worker; + return 0; +} + +static void application_update_worker_read_version(application_update_worker_t *worker) { + int64_t size = cbm_file_size(worker->output_path); + if (size <= 0 || size > APPLICATION_UPDATE_RESPONSE_MAX) { + return; + } + FILE *file = cbm_fopen(worker->output_path, "rb"); + if (!file) { + return; + } + char *bytes = malloc((size_t)size); + size_t read = bytes ? fread(bytes, 1, (size_t)size, file) : 0; + (void)fclose(file); + if (read != (size_t)size) { + free(bytes); + return; + } + yyjson_doc *document = yyjson_read(bytes, read, 0); + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + yyjson_val *tag = yyjson_is_obj(root) ? yyjson_obj_get(root, "tag_name") : NULL; + const char *version = yyjson_is_str(tag) ? yyjson_get_str(tag) : NULL; + if (version && version[0] && strlen(version) < sizeof(worker->latest_version)) { + bool valid = true; + for (const unsigned char *cursor = (const unsigned char *)version; *cursor; cursor++) { + if (!(isalnum(*cursor) || *cursor == '.' || *cursor == '-' || *cursor == '_' || + *cursor == '+')) { + valid = false; + break; + } + } + if (valid) { + (void)snprintf(worker->latest_version, sizeof(worker->latest_version), "%s", version); + } + } + yyjson_doc_free(document); + free(bytes); +} + +static cbm_daemon_application_update_poll_t application_update_worker_poll_default( + void *context, cbm_daemon_application_update_worker_t handle, const char **latest_version_out) { + (void)context; + if (latest_version_out) { + *latest_version_out = NULL; + } + application_update_worker_t *worker = handle; + if (!worker || !worker->process || !latest_version_out) { + return CBM_DAEMON_APPLICATION_UPDATE_POLL_ERROR; + } + if (!worker->terminal) { + cbm_proc_result_t result; + cbm_proc_poll_t status = cbm_subprocess_poll(worker->process, &result); + if (status == CBM_PROC_POLL_RUNNING) { + return CBM_DAEMON_APPLICATION_UPDATE_POLL_RUNNING; + } + if (status != CBM_PROC_POLL_TERMINAL) { + return CBM_DAEMON_APPLICATION_UPDATE_POLL_ERROR; + } + worker->terminal = true; + if (result.outcome == CBM_PROC_CLEAN && result.exit_code == 0 && result.tree_quiesced && + !result.supervision_failed && !result.cancellation_requested) { + application_update_worker_read_version(worker); + } + } + *latest_version_out = worker->latest_version[0] ? worker->latest_version : NULL; + return CBM_DAEMON_APPLICATION_UPDATE_POLL_TERMINAL; +} + +static bool application_update_worker_cancel_default( + void *context, cbm_daemon_application_update_worker_t handle) { + (void)context; + application_update_worker_t *worker = handle; + return worker && worker->process && cbm_subprocess_request_cancel(worker->process); +} + +static void application_update_worker_destroy_default( + void *context, cbm_daemon_application_update_worker_t handle) { + (void)context; + application_update_worker_t *worker = handle; + if (!worker) { + return; + } + cbm_subprocess_destroy(worker->process); + (void)cbm_unlink(worker->output_path); + free(worker); +} + static bool application_recovery_files_create(char marker_path[APPLICATION_PATH_CAP], char quarantine_path[APPLICATION_PATH_CAP]) { if (!application_unique_recovery_file(marker_path, "marker")) { @@ -722,26 +921,24 @@ static bool application_mutation_conflicts_locked(cbm_daemon_application_t *appl static bool application_job_reserves_project_locked(cbm_daemon_application_t *application, const char *project_key) { for (cbm_daemon_application_job_t *job = application->jobs; job; job = job->next) { - if (!job->terminal && - application_project_keys_conflict(job->project_key, project_key)) { + if (!job->terminal && application_project_keys_conflict(job->project_key, project_key)) { return true; } } return false; } -static bool application_mutation_begin_internal( - cbm_daemon_application_t *application, cbm_daemon_application_session_t *session, - const char *project_key, bool wait) { +static bool application_mutation_begin_internal(cbm_daemon_application_t *application, + cbm_daemon_application_session_t *session, + const char *project_key, bool wait) { if (!application || !project_key || !project_key[0]) { return false; } cbm_daemon_application_mutation_t *reserved = NULL; for (;;) { cbm_mutex_lock(&application->mutex); - bool cancelled = application->stopping || - (session && - application_request_cancelled_locked(session)); + bool cancelled = + application->stopping || (session && application_request_cancelled_locked(session)); bool busy = application_mutation_conflicts_locked(application, project_key) || application_job_reserves_project_locked(application, project_key); if (!cancelled && !busy) { @@ -776,20 +973,16 @@ static bool application_mutation_begin_internal( } for (;;) { uint64_t now = cbm_now_ms(); - uint64_t deadline = - now > UINT64_MAX - 100U ? UINT64_MAX : now + 100U; + uint64_t deadline = now > UINT64_MAX - 100U ? UINT64_MAX : now + 100U; cbm_project_lock_lease_t *lease = NULL; cbm_private_file_lock_status_t status = - wait ? cbm_project_lock_acquire(application->project_locks, - project_key, deadline, NULL, + wait ? cbm_project_lock_acquire(application->project_locks, project_key, deadline, NULL, &lease) - : cbm_project_lock_try_acquire(application->project_locks, - project_key, &lease); + : cbm_project_lock_try_acquire(application->project_locks, project_key, &lease); if (status == CBM_PRIVATE_FILE_LOCK_OK && lease) { cbm_mutex_lock(&application->mutex); - bool cancelled = application->stopping || - (session && - application_request_cancelled_locked(session)); + bool cancelled = + application->stopping || (session && application_request_cancelled_locked(session)); cbm_mutex_unlock(&application->mutex); if (cancelled) { application_project_lock_release_fully(&lease); @@ -802,12 +995,10 @@ static bool application_mutation_begin_internal( application_project_lock_release_fully(&lease); cbm_mutex_lock(&application->mutex); - bool cancelled = application->stopping || - (session && - application_request_cancelled_locked(session)); + bool cancelled = + application->stopping || (session && application_request_cancelled_locked(session)); if (status != CBM_PRIVATE_FILE_LOCK_BUSY || cancelled || !wait) { - cbm_daemon_application_mutation_t **cursor = - &application->mutations; + cbm_daemon_application_mutation_t **cursor = &application->mutations; while (*cursor && *cursor != reserved) { cursor = &(*cursor)->next; } @@ -821,8 +1012,8 @@ static bool application_mutation_begin_internal( free(reserved->project_key); free(reserved); if (status != CBM_PRIVATE_FILE_LOCK_BUSY) { - cbm_log_error("daemon.project_lock_failed", "project", - project_key, "action", "refuse_mutation"); + cbm_log_error("daemon.project_lock_failed", "project", project_key, "action", + "refuse_mutation"); } return false; } @@ -830,13 +1021,13 @@ static bool application_mutation_begin_internal( } } -bool cbm_daemon_application_project_mutation_try_begin( - cbm_daemon_application_t *application, const char *project) { +bool cbm_daemon_application_project_mutation_try_begin(cbm_daemon_application_t *application, + const char *project) { return application_mutation_begin_internal(application, NULL, project, false); } -void cbm_daemon_application_project_mutation_end( - cbm_daemon_application_t *application, const char *project) { +void cbm_daemon_application_project_mutation_end(cbm_daemon_application_t *application, + const char *project) { if (!application || !project || !project[0]) { return; } @@ -887,8 +1078,7 @@ static void application_watcher_project_pruned(void *context, const char *projec return; } cbm_mutex_lock(&application->mutex); - cbm_daemon_application_watch_t *watch = - application_find_watch_locked(application, project); + cbm_daemon_application_watch_t *watch = application_find_watch_locked(application, project); if (watch) { /* The watcher already removed its physical entry. Invalidate every * logical subscriber so a later successful index can re-register it. */ @@ -899,8 +1089,8 @@ static void application_watcher_project_pruned(void *context, const char *projec static bool application_session_mutation_begin(void *context, const char *project) { cbm_daemon_application_session_t *session = context; - return session && application_mutation_begin_internal( - session->application, session, project, true); + return session && + application_mutation_begin_internal(session->application, session, project, true); } static void application_session_mutation_end(void *context, const char *project) { @@ -948,8 +1138,8 @@ static application_attempt_status_t application_job_run_attempt(cbm_daemon_appli cbm_daemon_application_worker_t worker = NULL; application_tmp_lock(); int start_result = application->worker_ops.start( - application->worker_ops.context, job->args_json, - application->worker_memory_budget_bytes, marker_path, quarantine_path, &worker); + application->worker_ops.context, job->args_json, application->worker_memory_budget_bytes, + marker_path, quarantine_path, &worker); application_tmp_unlock(); if (start_result != 0 || !worker) { return application_job_cancel_requested(job) ? APPLICATION_ATTEMPT_CANCELLED @@ -963,8 +1153,7 @@ static application_attempt_status_t application_job_run_attempt(cbm_daemon_appli if (cancel_now) { /* The worker thread owns this handle until destroy below. Invoke the * external supervisor without the application mutex held. */ - (void)application->worker_ops.cancel(application->worker_ops.context, - worker); + (void)application->worker_ops.cancel(application->worker_ops.context, worker); } const cbm_index_worker_result_t *borrowed = NULL; @@ -976,8 +1165,7 @@ static application_attempt_status_t application_job_run_attempt(cbm_daemon_appli } cbm_mutex_lock(&application->mutex); bool cancel_pending = - (job->cancel_requested || application->stopping) && - job->worker == worker; + (job->cancel_requested || application->stopping) && job->worker == worker; cbm_mutex_unlock(&application->mutex); if (cancel_pending || state == CBM_INDEX_WORKER_POLL_ERROR) { (void)application->worker_ops.cancel(application->worker_ops.context, worker); @@ -1259,6 +1447,53 @@ static void application_job_recover(cbm_daemon_application_job_t *job, application_recovery_files_remove(marker_path, quarantine_path); } +/* A capacity/cancellation conflict is resolved by the terminal publication + * that freed the project or global slot. Admit waiting session auto-index work + * at that same boundary so an otherwise-idle MCP session does not need to send + * another request merely to make background progress. Caller holds mutex. */ +static void application_auto_index_retry_pending_locked(cbm_daemon_application_t *application) { + if (application->stopping) { + return; + } + for (cbm_daemon_application_session_t *session = application->sessions; session; + session = session->next) { + if (!session->auto_index_retry_pending || session->auto_index_subscribed || + session->session_cancelled || !session->context_set) { + continue; + } + const char *project = cbm_mcp_server_session_project(session->mcp); + const char *root_path = cbm_mcp_server_session_root(session->mcp); + if (!project || !project[0] || !root_path || !root_path[0]) { + session->auto_index_retry_pending = false; + continue; + } + if (application_regular_db_exists(project)) { + session->auto_index_retry_pending = false; + application_refresh_watch_locked(session); + continue; + } + char *args = application_auto_index_args(root_path); + if (!args) { + continue; + } + application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE; + cbm_daemon_application_job_t *retry = application_job_subscribe_locked( + application, project, root_path, args, &subscribe_status); + free(args); + if (retry) { + session->auto_index_job = retry; + session->auto_index_subscribed = true; + session->auto_index_retry_pending = false; + cbm_log_info("daemon.autoindex.admission_retried", "project", project); + continue; + } + if (subscribe_status == APPLICATION_JOB_SUBSCRIBE_BUSY) { + /* No further distinct job can fit until another terminal publish. */ + break; + } + } +} + static void application_job_publish(cbm_daemon_application_job_t *job, application_job_execution_t *execution) { if (!execution->response) { @@ -1276,6 +1511,19 @@ static void application_job_publish(cbm_daemon_application_job_t *job, !execution->last_result.cancellation_requested); job->terminal = true; job->thread_done = true; + for (cbm_daemon_application_session_t *session = application->sessions; session; + session = session->next) { + if (session->auto_index_job != job || !session->auto_index_subscribed) { + continue; + } + session->auto_index_job = NULL; + session->auto_index_subscribed = false; + application_job_unsubscribe_locked(job); + if (job->successful) { + application_refresh_watch_locked(session); + } + } + application_auto_index_retry_pending_locked(application); cbm_mutex_unlock(&application->mutex); } @@ -1339,15 +1587,6 @@ static char *application_index_project_key(const char *root_path, const char *ar return key; } -typedef enum { - APPLICATION_JOB_SUBSCRIBE_OK = 0, - APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT, - APPLICATION_JOB_SUBSCRIBE_BUSY, - APPLICATION_JOB_SUBSCRIBE_CANCELLING, - APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE, - APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED, -} application_job_subscribe_status_t; - static size_t application_active_job_count_locked(cbm_daemon_application_t *application) { size_t count = 0; for (cbm_daemon_application_job_t *job = application->jobs; job; job = job->next) { @@ -1358,6 +1597,50 @@ static size_t application_active_job_count_locked(cbm_daemon_application_t *appl return count; } +/* Compare the effective index request, not its JSON spelling. yyjson's deep + * equality treats object member order as insignificant, while the small + * normalization below removes values that the index handler interprets as + * its defaults. Arrays remain order-sensitive and all non-default options are + * preserved. */ +static bool application_index_args_normalize_defaults(yyjson_mut_val *root) { + if (!root || !yyjson_mut_is_obj(root)) { + return false; + } + yyjson_mut_val *mode = yyjson_mut_obj_get(root, "mode"); + if (mode && (!yyjson_mut_is_str(mode) || yyjson_mut_equals_str(mode, "full"))) { + (void)yyjson_mut_obj_remove_key(root, "mode"); + } + yyjson_mut_val *persistence = yyjson_mut_obj_get(root, "persistence"); + if (persistence && (!yyjson_mut_is_bool(persistence) || !yyjson_mut_get_bool(persistence))) { + (void)yyjson_mut_obj_remove_key(root, "persistence"); + } + yyjson_mut_val *name = yyjson_mut_obj_get(root, "name"); + if (name && (!yyjson_mut_is_str(name) || yyjson_mut_get_len(name) == 0)) { + (void)yyjson_mut_obj_remove_key(root, "name"); + } + return true; +} + +static bool application_index_args_equal(const char *left, const char *right) { + if (!left || !right) { + return false; + } + yyjson_doc *left_source = yyjson_read(left, strlen(left), 0); + yyjson_doc *right_source = yyjson_read(right, strlen(right), 0); + yyjson_mut_doc *left_copy = left_source ? yyjson_doc_mut_copy(left_source, NULL) : NULL; + yyjson_mut_doc *right_copy = right_source ? yyjson_doc_mut_copy(right_source, NULL) : NULL; + yyjson_mut_val *left_root = left_copy ? yyjson_mut_doc_get_root(left_copy) : NULL; + yyjson_mut_val *right_root = right_copy ? yyjson_mut_doc_get_root(right_copy) : NULL; + bool equal = application_index_args_normalize_defaults(left_root) && + application_index_args_normalize_defaults(right_root) && + yyjson_mut_equals(left_root, right_root); + yyjson_mut_doc_free(left_copy); + yyjson_mut_doc_free(right_copy); + yyjson_doc_free(left_source); + yyjson_doc_free(right_source); + return equal; +} + /* Caller holds application->mutex. Keeping watcher ownership validation and * this admission in the same critical section closes the unwatch race. */ static cbm_daemon_application_job_t *application_job_subscribe_locked( @@ -1374,7 +1657,7 @@ static cbm_daemon_application_job_t *application_job_subscribe_locked( *status_out = APPLICATION_JOB_SUBSCRIBE_CANCELLING; return NULL; } - if (strcmp(job->args_json, args_json) != 0) { + if (!application_index_args_equal(job->args_json, args_json)) { *status_out = APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT; return NULL; } @@ -1406,13 +1689,18 @@ static cbm_daemon_application_job_t *application_job_subscribe_locked( job->subscribers = 1; job->next = application->jobs; application->jobs = job; - if (cbm_thread_create(&job->thread, APPLICATION_JOB_THREAD_STACK, application_job_thread, - job) == 0) { + if (application_job_thread_create(&job->thread, job) == 0) { job->thread_started = true; } else { - job->response = cbm_mcp_text_result("failed to create index supervisor thread", true); - job->terminal = true; - job->thread_done = true; + /* The job was linked only so a concurrently started thread could + * observe its reservation. No thread exists on this path, so roll the + * reservation back synchronously and let background callers retry. */ + application->jobs = job->next; + job->next = NULL; + application_job_free(job); + *status_out = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE; + cbm_log_warn("daemon.index.thread_start_failed", "action", "retry"); + return NULL; } *status_out = APPLICATION_JOB_SUBSCRIBE_OK; return job; @@ -1439,9 +1727,354 @@ static void application_job_unsubscribe_locked(cbm_daemon_application_job_t *job } } +/* Transfer a final session's subscription to the disconnect cleanup path so + * the job cannot be reaped before that path has observed terminal containment. + * Non-final subscriptions detach immediately and leave the shared worker live. */ +static cbm_daemon_application_job_t *application_auto_index_release_locked( + cbm_daemon_application_session_t *session) { + if (!session || !session->auto_index_job || !session->auto_index_subscribed) { + return NULL; + } + cbm_daemon_application_job_t *job = session->auto_index_job; + session->auto_index_job = NULL; + session->auto_index_subscribed = false; + if (!job->terminal && job->subscribers == 1) { + job->cancel_requested = true; + return job; + } + application_job_unsubscribe_locked(job); + return NULL; +} + +static void application_auto_index_cancel_join(cbm_daemon_application_t *application, + cbm_daemon_application_job_t *job) { + if (!application || !job) { + return; + } + uint64_t deadline = application_deadline_after(APPLICATION_BACKGROUND_REAP_MS); + for (;;) { + cbm_mutex_lock(&application->mutex); + bool terminal = job->terminal && job->thread_done; + if (terminal) { + application_job_unsubscribe_locked(job); + } + cbm_mutex_unlock(&application->mutex); + if (terminal) { + application_jobs_reap_completed(application); + return; + } + if (cbm_now_ms() >= deadline) { + application_cleanup_force_terminate("auto_index_cleanup"); + } + cbm_usleep(APPLICATION_JOB_POLL_US); + } +} + +static void application_update_cancel_locked(cbm_daemon_application_t *application) { + if (!application->update_generation_started || application->update_thread_done) { + return; + } + application->update_cancel_requested = true; + if (application->update_worker) { + (void)application->update_ops.cancel(application->update_ops.context, + application->update_worker); + } +} + +static bool application_update_owner_release_locked(cbm_daemon_application_session_t *session) { + if (!session || !session->update_owner) { + return false; + } + cbm_daemon_application_t *application = session->application; + session->update_owner = false; + if (application->update_owners > 0) { + application->update_owners--; + } + if (application->update_owners == 0) { + application_update_cancel_locked(application); + return application->update_thread_started; + } + return false; +} + +static bool application_update_version_valid(const char *version) { + if (!version || !version[0] || strlen(version) >= APPLICATION_UPDATE_VERSION_CAP) { + return false; + } + for (const unsigned char *cursor = (const unsigned char *)version; *cursor; cursor++) { + if (!(isalnum(*cursor) || *cursor == '.' || *cursor == '-' || *cursor == '_' || + *cursor == '+')) { + return false; + } + } + return true; +} + +static void application_update_publish_terminal_locked(cbm_daemon_application_t *application, + const char *latest_version, + bool completed_generation) { + if (!application->update_cancel_requested && application_update_version_valid(latest_version) && + cbm_compare_versions(latest_version, cbm_cli_get_version()) > 0) { + (void)snprintf(application->update_notice, sizeof(application->update_notice), + "Update available: %s -> %s -- run: codebase-memory-mcp update | " + "Enjoying codebase-memory-mcp? Please leave a star: " + "https://github.com/DeusData/codebase-memory-mcp", + cbm_cli_get_version(), latest_version); + cbm_log_info("update.available", "current", cbm_cli_get_version(), "latest", + latest_version); + } + for (cbm_daemon_application_session_t *session = application->sessions; session; + session = session->next) { + session->update_owner = false; + } + application->update_owners = 0; + application->update_worker = NULL; + /* A clean/poll-terminal generation is immutable daemon history and is + * replayed to late sessions. Cancellation and worker-start failure did + * not perform a check, so they release the generation slot for retry once + * this thread has been joined. */ + application->update_generation_started = completed_generation; + application->update_thread_done = true; + application_auto_index_retry_pending_locked(application); +} + +static void *application_update_thread(void *opaque) { + cbm_daemon_application_t *application = opaque; + cbm_daemon_application_update_worker_t worker = NULL; + if (application->update_ops.start(application->update_ops.context, &worker) != 0 || !worker) { + cbm_mutex_lock(&application->mutex); + application_update_publish_terminal_locked(application, NULL, false); + cbm_mutex_unlock(&application->mutex); + return NULL; + } + + cbm_mutex_lock(&application->mutex); + application->update_worker = worker; + if (application->update_cancel_requested || application->stopping || + application->update_owners == 0) { + application_update_cancel_locked(application); + } + cbm_mutex_unlock(&application->mutex); + + const char *latest_version = NULL; + for (;;) { + cbm_daemon_application_update_poll_t status = + application->update_ops.poll(application->update_ops.context, worker, &latest_version); + if (status != CBM_DAEMON_APPLICATION_UPDATE_POLL_RUNNING) { + if (status == CBM_DAEMON_APPLICATION_UPDATE_POLL_ERROR) { + latest_version = NULL; + } + break; + } + cbm_mutex_lock(&application->mutex); + if (application->update_cancel_requested || application->stopping || + application->update_owners == 0) { + application_update_cancel_locked(application); + } + cbm_mutex_unlock(&application->mutex); + cbm_usleep(APPLICATION_UPDATE_POLL_US); + } + + char version[APPLICATION_UPDATE_VERSION_CAP] = {0}; + if (latest_version && strlen(latest_version) < sizeof(version)) { + (void)snprintf(version, sizeof(version), "%s", latest_version); + } + cbm_mutex_lock(&application->mutex); + bool completed_generation = !application->update_cancel_requested && !application->stopping && + application->update_owners > 0; + application_update_publish_terminal_locked(application, version[0] ? version : NULL, + completed_generation); + cbm_mutex_unlock(&application->mutex); + application->update_ops.destroy(application->update_ops.context, worker); + return NULL; +} + +static void application_update_subscribe_locked(cbm_daemon_application_session_t *session) { + cbm_daemon_application_t *application = session->application; + if (application->update_generation_started) { + if (application->update_thread_started && !application->update_thread_done && + !application->update_cancel_requested && !session->update_owner) { + session->update_owner = true; + application->update_owners++; + } + return; + } + /* A retryable generation may already be terminal but not yet joined. The + * owning thread handle cannot be overwritten; the next request retries + * after application_update_reap() clears it. */ + if (application->update_thread_started) { + return; + } + application->update_generation_started = true; + application->update_thread_done = false; + application->update_cancel_requested = false; + session->update_owner = true; + application->update_owners = 1; + if (cbm_thread_create(&application->update_thread, APPLICATION_JOB_THREAD_STACK, + application_update_thread, application) == 0) { + application->update_thread_started = true; + return; + } + session->update_owner = false; + application->update_owners = 0; + application->update_generation_started = false; + application->update_thread_done = false; + cbm_log_warn("daemon.update.thread_start_failed", "action", "retry"); +} + +static bool application_update_reap(cbm_daemon_application_t *application, bool wait, + uint32_t timeout_ms) { + if (!application) { + return false; + } + uint64_t deadline = application_deadline_after(timeout_ms); + for (;;) { + bool join = false; + cbm_mutex_lock(&application->mutex); + if (!application->update_thread_started) { + cbm_mutex_unlock(&application->mutex); + return true; + } + if (application->update_thread_done && !application->update_thread_joining) { + application->update_thread_joining = true; + join = true; + } + cbm_mutex_unlock(&application->mutex); + if (join) { + bool joined = cbm_thread_join(&application->update_thread) == 0; + cbm_mutex_lock(&application->mutex); + if (joined) { + application->update_thread_started = false; + } + application->update_thread_joining = false; + cbm_mutex_unlock(&application->mutex); + return joined; + } + if (!wait || cbm_now_ms() >= deadline) { + return false; + } + cbm_usleep(APPLICATION_UPDATE_POLL_US); + } +} + +static char *application_auto_index_args(const char *root_path) { + yyjson_mut_doc *document = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = document ? yyjson_mut_obj(document) : NULL; + if (!document || !root) { + yyjson_mut_doc_free(document); + return NULL; + } + yyjson_mut_doc_set_root(document, root); + char *args = yyjson_mut_obj_add_strcpy(document, root, "repo_path", root_path) + ? yyjson_mut_write(document, 0, NULL) + : NULL; + yyjson_mut_doc_free(document); + return args; +} + +static void application_background_initialize(cbm_daemon_application_session_t *session) { + if (!session || !session->application || !session->context_set || + session->tool_profile != CBM_MCP_TOOL_PROFILE_ALL || session->hook_event || + session->hook_dialect) { + return; + } + cbm_daemon_application_t *application = session->application; + const char *project = cbm_mcp_server_session_project(session->mcp); + const char *root_path = cbm_mcp_server_session_root(session->mcp); + if (!project || !project[0] || !root_path || !root_path[0]) { + return; + } + /* Join a terminal retryable update generation before reusing its single + * thread slot. This is non-blocking unless the thread already published + * terminal state. */ + (void)application_update_reap(application, false, 0); + bool db_exists = application_regular_db_exists(project); + bool auto_index = application->config && + cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_INDEX, false); + int auto_index_limit = + application->config ? cbm_config_get_int(application->config, CBM_CONFIG_AUTO_INDEX_LIMIT, + CBM_MCP_DEFAULT_AUTO_INDEX_LIMIT) + : CBM_MCP_DEFAULT_AUTO_INDEX_LIMIT; + int tracked_files = -1; + bool auto_index_candidate = auto_index && !db_exists; + bool within_auto_index_limit = + !auto_index_candidate || + cbm_mcp_auto_index_within_file_limit(root_path, auto_index_limit, &tracked_files); + if (auto_index_candidate && !within_auto_index_limit) { + char files[32]; + (void)snprintf(files, sizeof(files), "%d", tracked_files); + cbm_log_warn("daemon.autoindex.skipped", "project", project, "reason", + tracked_files >= 0 ? "too_many_files" : "unsafe_or_unavailable_path", "files", + files); + } + bool args_required = auto_index_candidate && within_auto_index_limit; + char *args = args_required ? application_auto_index_args(root_path) : NULL; + application_jobs_reap_completed(application); + cbm_mutex_lock(&application->mutex); + if (application->stopping || application_request_cancelled_locked(session)) { + cbm_mutex_unlock(&application->mutex); + free(args); + return; + } + session->background_eligible = true; + application_update_subscribe_locked(session); + bool attempt_auto_index = !session->auto_index_subscribed && + (!session->auto_index_evaluated || session->auto_index_retry_pending); + if (attempt_auto_index) { + session->auto_index_evaluated = true; + session->auto_index_retry_pending = false; + } + if (attempt_auto_index && args) { + application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE; + cbm_daemon_application_job_t *job = application_job_subscribe_locked( + application, project, root_path, args, &subscribe_status); + if (job) { + session->auto_index_job = job; + session->auto_index_subscribed = true; + } else { + session->auto_index_retry_pending = + subscribe_status == APPLICATION_JOB_SUBSCRIBE_BUSY || + subscribe_status == APPLICATION_JOB_SUBSCRIBE_CANCELLING || + subscribe_status == APPLICATION_JOB_SUBSCRIBE_OPTIONS_CONFLICT || + subscribe_status == APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE || + subscribe_status == APPLICATION_JOB_SUBSCRIBE_ALLOCATION_FAILED; + cbm_log_warn("daemon.autoindex.admission_failed", "project", project, "action", + session->auto_index_retry_pending ? "retry" : "skip"); + } + } else if (attempt_auto_index && args_required && !args) { + /* JSON allocation is a transient resource failure, not a permanent + * policy decision. Retry at the next coordinator opportunity. */ + session->auto_index_retry_pending = true; + } + cbm_mutex_unlock(&application->mutex); + free(args); + application_refresh_watch(session); +} + +static bool application_jsonrpc_success(const char *response) { + yyjson_doc *document = response ? yyjson_read(response, strlen(response), 0) : NULL; + yyjson_val *root = document ? yyjson_doc_get_root(document) : NULL; + bool success = yyjson_is_obj(root) && yyjson_obj_get(root, "result") != NULL && + yyjson_obj_get(root, "error") == NULL; + yyjson_doc_free(document); + return success; +} + +static void application_update_notice_inject(cbm_daemon_application_session_t *session, + char **response_io) { + cbm_daemon_application_t *application = session->application; + cbm_mutex_lock(&application->mutex); + if (session->background_eligible && !session->update_notice_delivered && + !session->pending_update_notice && application->update_thread_done && + application->update_notice[0] && + cbm_mcp_jsonrpc_response_prepend_notice(response_io, application->update_notice)) { + session->pending_update_notice = true; + } + cbm_mutex_unlock(&application->mutex); +} + static bool application_watch_job_subscription_exists_locked( - cbm_daemon_application_t *application, - cbm_daemon_application_session_t *session, + cbm_daemon_application_t *application, cbm_daemon_application_session_t *session, cbm_daemon_application_job_t *job) { for (cbm_daemon_application_watch_job_subscription_t *subscription = application->watch_job_subscriptions; @@ -1453,13 +2086,42 @@ static bool application_watch_job_subscription_exists_locked( return false; } +/* Attach a newly registered logical watch to a watcher callback that was + * already admitted for the same project/root. Without this step, closing the + * pre-existing owners can cancel the physical worker while this late session + * still expects the shared watch to remain live. Caller holds the mutex. */ +static bool application_watch_job_subscribe_late_session_locked( + cbm_daemon_application_session_t *session, cbm_daemon_application_watch_t *watch) { + if (!session || !watch || !session->application) { + return false; + } + cbm_daemon_application_t *application = session->application; + cbm_daemon_application_job_t *job = + application_find_active_job_locked(application, watch->project); + if (!job || job->watcher_waiters == 0 || strcmp(job->root_path, watch->root) != 0 || + application_watch_job_subscription_exists_locked(application, session, job)) { + return true; + } + cbm_daemon_application_watch_job_subscription_t *subscription = + calloc(1, sizeof(*subscription)); + if (!subscription) { + return false; + } + subscription->session = session; + subscription->job = job; + subscription->next = application->watch_job_subscriptions; + application->watch_job_subscriptions = subscription; + job->subscribers++; + return true; +} + /* Caller holds application->mutex. Allocate the complete change before * publishing any node so an allocation failure never leaves only a subset of * the exact live watch owners subscribed. */ -static bool application_watch_job_subscribe_sessions_locked( - cbm_daemon_application_t *application, - cbm_daemon_application_watch_t *watch, - cbm_daemon_application_job_t *job, size_t *matched_out) { +static bool application_watch_job_subscribe_sessions_locked(cbm_daemon_application_t *application, + cbm_daemon_application_watch_t *watch, + cbm_daemon_application_job_t *job, + size_t *matched_out) { *matched_out = 0; if (!watch || !job || strcmp(watch->project, job->project_key) != 0 || strcmp(watch->root, job->root_path) != 0) { @@ -1467,23 +2129,20 @@ static bool application_watch_job_subscribe_sessions_locked( } cbm_daemon_application_watch_job_subscription_t *pending = NULL; - for (cbm_daemon_application_session_t *session = application->sessions; - session; session = session->next) { - if (session->session_cancelled || !session->context_set || - session->watch != watch) { + for (cbm_daemon_application_session_t *session = application->sessions; session; + session = session->next) { + if (session->session_cancelled || !session->context_set || session->watch != watch) { continue; } (*matched_out)++; - if (application_watch_job_subscription_exists_locked( - application, session, job)) { + if (application_watch_job_subscription_exists_locked(application, session, job)) { continue; } cbm_daemon_application_watch_job_subscription_t *subscription = calloc(1, sizeof(*subscription)); if (!subscription) { while (pending) { - cbm_daemon_application_watch_job_subscription_t *next = - pending->next; + cbm_daemon_application_watch_job_subscription_t *next = pending->next; free(pending); pending = next; } @@ -1513,8 +2172,7 @@ static void application_watch_job_unsubscribe_session_locked( cbm_daemon_application_watch_job_subscription_t **cursor = &session->application->watch_job_subscriptions; while (*cursor) { - cbm_daemon_application_watch_job_subscription_t *subscription = - *cursor; + cbm_daemon_application_watch_job_subscription_t *subscription = *cursor; if (subscription->session != session) { cursor = &subscription->next; continue; @@ -1525,14 +2183,12 @@ static void application_watch_job_unsubscribe_session_locked( } } -static void application_watch_job_unsubscribe_job_locked( - cbm_daemon_application_t *application, - cbm_daemon_application_job_t *job) { +static void application_watch_job_unsubscribe_job_locked(cbm_daemon_application_t *application, + cbm_daemon_application_job_t *job) { cbm_daemon_application_watch_job_subscription_t **cursor = &application->watch_job_subscriptions; while (*cursor) { - cbm_daemon_application_watch_job_subscription_t *subscription = - *cursor; + cbm_daemon_application_watch_job_subscription_t *subscription = *cursor; if (subscription->job != job) { cursor = &subscription->next; continue; @@ -1624,8 +2280,7 @@ static cbm_daemon_runtime_application_session_t *application_session_open( return NULL; } session->mcp = cbm_mcp_server_new(NULL); - if (!session->mcp || - !cbm_mcp_server_release_pristine_memory_store(session->mcp)) { + if (!session->mcp || !cbm_mcp_server_release_pristine_memory_store(session->mcp)) { cbm_mcp_server_free(session->mcp); free(session); return NULL; @@ -1633,9 +2288,8 @@ static cbm_daemon_runtime_application_session_t *application_session_open( cbm_mcp_server_set_background_tasks(session->mcp, false); cbm_mcp_server_set_config(session->mcp, application->config); cbm_mcp_server_set_index_executor(session->mcp, application_index_execute, session); - cbm_mcp_server_set_project_mutation_guard( - session->mcp, application_session_mutation_begin, - application_session_mutation_end, session); + cbm_mcp_server_set_project_mutation_guard(session->mcp, application_session_mutation_begin, + application_session_mutation_end, session); session->tool_profile = CBM_MCP_TOOL_PROFILE_ALL; session->application = application; session->client_id = client_id; @@ -1665,9 +2319,8 @@ static cbm_daemon_runtime_application_status_t application_set_context( uint8_t profile_value = request[10]; uint32_t event_length = application_get_u32(request + 11); uint32_t dialect_length = application_get_u32(request + 15); - uint64_t expected = (uint64_t)APPLICATION_CONTEXT_HEADER_SIZE + - root_length + allowed_length + event_length + - dialect_length; + uint64_t expected = (uint64_t)APPLICATION_CONTEXT_HEADER_SIZE + root_length + allowed_length + + event_length + dialect_length; if (request[5] > 1 || root_length == 0 || expected != request_length || (!allowed_present && allowed_length != 0) || profile_value > (uint8_t)CBM_MCP_TOOL_PROFILE_SCOUT || @@ -1675,27 +2328,19 @@ static cbm_daemon_runtime_application_status_t application_set_context( (event_length != 0 || dialect_length != 0))) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; } - cbm_mcp_tool_profile_t tool_profile = - (cbm_mcp_tool_profile_t)profile_value; + cbm_mcp_tool_profile_t tool_profile = (cbm_mcp_tool_profile_t)profile_value; const uint8_t *payload = request + APPLICATION_CONTEXT_HEADER_SIZE; char *root = application_text_copy(request + APPLICATION_CONTEXT_HEADER_SIZE, root_length); char *allowed = - allowed_present - ? application_text_copy(payload + root_length, allowed_length) - : NULL; + allowed_present ? application_text_copy(payload + root_length, allowed_length) : NULL; char *hook_event = - event_length - ? application_text_copy(payload + root_length + allowed_length, - event_length) - : NULL; + event_length ? application_text_copy(payload + root_length + allowed_length, event_length) + : NULL; char *hook_dialect = - dialect_length - ? application_text_copy(payload + root_length + allowed_length + - event_length, - dialect_length) - : NULL; - if (!root || (allowed_present && !allowed) || - (event_length && !hook_event) || + dialect_length ? application_text_copy( + payload + root_length + allowed_length + event_length, dialect_length) + : NULL; + if (!root || (allowed_present && !allowed) || (event_length && !hook_event) || (dialect_length && !hook_dialect) || !cbm_hook_augment_invocation_supported(hook_event, hook_dialect)) { free(root); @@ -1741,8 +2386,24 @@ static cbm_daemon_runtime_application_status_t application_mcp_request( if (!message) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; } + cbm_jsonrpc_request_t parsed = {0}; + bool parsed_ok = cbm_jsonrpc_parse(message, &parsed) == 0; + bool initialize_request = + parsed_ok && parsed.has_id && parsed.method && strcmp(parsed.method, "initialize") == 0; + bool tool_request = + parsed_ok && parsed.has_id && parsed.method && strcmp(parsed.method, "tools/call") == 0; char *response = cbm_mcp_server_handle(session->mcp, message); free(message); + if (initialize_request && application_jsonrpc_success(response)) { + cbm_mutex_lock(&session->application->mutex); + session->pending_background_initialize = true; + cbm_mutex_unlock(&session->application->mutex); + } else if (tool_request && response) { + application_update_notice_inject(session, &response); + } + if (parsed_ok) { + cbm_jsonrpc_request_free(&parsed); + } if (response) { size_t response_length = strlen(response); if (response_length > UINT32_MAX) { @@ -1793,29 +2454,22 @@ static cbm_daemon_runtime_application_status_t application_tool_request( } static cbm_daemon_runtime_application_status_t application_set_ui_config( - cbm_daemon_application_t *application, - cbm_daemon_application_session_t *session, const uint8_t *request, - uint32_t request_length) { + cbm_daemon_application_t *application, cbm_daemon_application_session_t *session, + const uint8_t *request, uint32_t request_length) { const uint8_t valid_mask = - CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED | - CBM_DAEMON_APPLICATION_UI_CONFIG_PORT; - if (!session || !session->context_set || - session->tool_profile != CBM_MCP_TOOL_PROFILE_ALL || + CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED | CBM_DAEMON_APPLICATION_UI_CONFIG_PORT; + if (!session || !session->context_set || session->tool_profile != CBM_MCP_TOOL_PROFILE_ALL || request_length != APPLICATION_UI_CONFIG_REQUEST_SIZE) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; } uint8_t update_mask = request[1]; uint8_t enabled = request[2]; uint32_t port = application_get_u32(request + 3); - bool enabled_present = - (update_mask & CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED) != 0; - bool port_present = - (update_mask & CBM_DAEMON_APPLICATION_UI_CONFIG_PORT) != 0; + bool enabled_present = (update_mask & CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED) != 0; + bool port_present = (update_mask & CBM_DAEMON_APPLICATION_UI_CONFIG_PORT) != 0; if (update_mask == 0 || (update_mask & (uint8_t)~valid_mask) != 0 || (enabled_present ? enabled > 1U : enabled != 0U) || - (port_present - ? port == 0 || request[3] != 0 || request[4] != 0 - : port != 0U)) { + (port_present ? port == 0 || request[3] != 0 || request[4] != 0 : port != 0U)) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; } @@ -1834,15 +2488,12 @@ static cbm_daemon_runtime_application_status_t application_set_ui_config( } bool saved = cbm_ui_config_save(&config); cbm_mutex_unlock(&application->mutex); - return saved ? CBM_DAEMON_RUNTIME_APPLICATION_OK - : CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + return saved ? CBM_DAEMON_RUNTIME_APPLICATION_OK : CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; } -static cbm_daemon_runtime_application_status_t -application_request_dispatch( - cbm_daemon_application_t *application, - cbm_daemon_application_session_t *session, const uint8_t *request, - uint32_t request_length, uint8_t **response_out, +static cbm_daemon_runtime_application_status_t application_request_dispatch( + cbm_daemon_application_t *application, cbm_daemon_application_session_t *session, + const uint8_t *request, uint32_t request_length, uint8_t **response_out, uint32_t *response_length_out) { switch ((cbm_daemon_application_request_kind_t)request[0]) { case CBM_DAEMON_APPLICATION_REQUEST_SET_CONTEXT: @@ -1854,8 +2505,7 @@ application_request_dispatch( return application_tool_request(session, request, request_length, response_out, response_length_out); case CBM_DAEMON_APPLICATION_REQUEST_SET_UI_CONFIG: - return application_set_ui_config(application, session, request, - request_length); + return application_set_ui_config(application, session, request, request_length); case CBM_DAEMON_APPLICATION_REQUEST_HOOK_AUGMENT: { if (!session->context_set || request_length <= 1) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; @@ -1864,9 +2514,8 @@ application_request_dispatch( if (!input) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; } - char *response = cbm_hook_augment_process_for( - session->mcp, input, session->hook_event, - session->hook_dialect); + char *response = cbm_hook_augment_process_for(session->mcp, input, session->hook_event, + session->hook_dialect); free(input); if (response) { size_t response_length = strlen(response); @@ -1886,12 +2535,10 @@ application_request_dispatch( static cbm_daemon_runtime_application_status_t application_request( void *context, cbm_daemon_runtime_application_session_t *opaque_session, - cbm_daemon_runtime_application_token_t request_token, - const uint8_t *request, uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out) { + cbm_daemon_runtime_application_token_t request_token, const uint8_t *request, + uint32_t request_length, uint8_t **response_out, uint32_t *response_length_out) { cbm_daemon_application_t *application = context; - cbm_daemon_application_session_t *session = - (cbm_daemon_application_session_t *)opaque_session; + cbm_daemon_application_session_t *session = (cbm_daemon_application_session_t *)opaque_session; if (response_out) { *response_out = NULL; } @@ -1899,9 +2546,8 @@ static cbm_daemon_runtime_application_status_t application_request( *response_length_out = 0; } if (!application || !session || session->application != application || - request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID || - !request || request_length == 0 || !response_out || - !response_length_out) { + request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID || !request || + request_length == 0 || !response_out || !response_length_out) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; } @@ -1919,35 +2565,49 @@ static cbm_daemon_runtime_application_status_t application_request( return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; } if (session->request_cancel_token != request_token) { - session->request_cancel_token = - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + session->request_cancel_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; } session->request_active = true; session->active_request_token = request_token; - bool cancelled_before_entry = - session->request_cancel_token == request_token; + bool mcp_scope_started = cbm_mcp_server_request_scope_begin(session->mcp); + if (!mcp_scope_started) { + session->request_active = false; + session->active_request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + cbm_mutex_unlock(&application->mutex); + return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + bool cancelled_before_entry = session->request_cancel_token == request_token; cbm_mutex_unlock(&application->mutex); cbm_daemon_runtime_application_status_t status = cancelled_before_entry ? CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED - : application_request_dispatch( - application, session, request, request_length, response_out, - response_length_out); + : application_request_dispatch(application, session, request, request_length, + response_out, response_length_out); /* This mutex boundary is the cancellation/completion linearization point. * A matching cancel published before it wins; a later cancel is stale and * cannot affect the next unique request token. */ cbm_mutex_lock(&application->mutex); - bool cancelled = session->session_cancelled || - session->request_cancel_token == request_token; + bool cancelled = session->session_cancelled || session->request_cancel_token == request_token; + cbm_mcp_server_request_scope_end(session->mcp); session->request_active = false; - session->active_request_token = - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + session->active_request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; if (session->request_cancel_token == request_token) { - session->request_cancel_token = - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; - } + session->request_cancel_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + } + bool activate_background = + !cancelled && status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + (session->pending_background_initialize || + (session->background_eligible && + (session->auto_index_retry_pending || + (!application->update_generation_started && !session->update_owner)))); + if (!cancelled && status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + session->pending_update_notice) { + session->update_notice_delivered = true; + } + session->pending_background_initialize = false; + session->pending_update_notice = false; cbm_mutex_unlock(&application->mutex); if (cancelled) { free(*response_out); @@ -1955,38 +2615,35 @@ static cbm_daemon_runtime_application_status_t application_request( *response_length_out = 0; return CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED; } + if (activate_background) { + application_background_initialize(session); + } return status; } -static void application_cancel_jobs_locked( - cbm_daemon_application_t *application) { - for (cbm_daemon_application_job_t *job = application->jobs; job; - job = job->next) { +static void application_cancel_jobs_locked(cbm_daemon_application_t *application) { + for (cbm_daemon_application_job_t *job = application->jobs; job; job = job->next) { if (!job->terminal) { job->cancel_requested = true; } } } -static void application_request_cancel( - void *context, - cbm_daemon_runtime_application_session_t *opaque_session, - cbm_daemon_runtime_application_token_t request_token) { +static void application_request_cancel(void *context, + cbm_daemon_runtime_application_session_t *opaque_session, + cbm_daemon_runtime_application_token_t request_token) { cbm_daemon_application_t *application = context; - cbm_daemon_application_session_t *session = - (cbm_daemon_application_session_t *)opaque_session; + cbm_daemon_application_session_t *session = (cbm_daemon_application_session_t *)opaque_session; if (!application || !session || session->application != application || request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { return; } cbm_mutex_lock(&application->mutex); if (!session->session_cancelled && - (!session->request_active || - session->active_request_token == request_token)) { + (!session->request_active || session->active_request_token == request_token)) { session->request_cancel_token = request_token; bool active_match = session->request_active; - if (active_match && session->active_job && - session->active_job_subscribed) { + if (active_match && session->active_job && session->active_job_subscribed) { cbm_daemon_application_job_t *job = session->active_job; session->active_job = NULL; session->active_job_subscribed = false; @@ -2007,6 +2664,8 @@ static void application_session_cancel(void *context, cbm_daemon_application_t *application = context; cbm_daemon_application_session_t *session = (cbm_daemon_application_session_t *)opaque_session; if (application && session && session->application == application) { + bool reap_update = false; + cbm_daemon_application_job_t *join_auto_index = NULL; cbm_mutex_lock(&application->mutex); /* Runtime may need to keep the session allocation alive until its * request callback joins. Cancellation, not the later close, is the @@ -2022,6 +2681,11 @@ static void application_session_cancel(void *context, session->active_job_subscribed = false; application_job_unsubscribe_locked(job); } + join_auto_index = application_auto_index_release_locked(session); + reap_update = application_update_owner_release_locked(session); + reap_update = + reap_update || (session->background_eligible && application->update_thread_started && + application->update_owners == 0); application_release_session_watch_locked(session); bool final_live_session = newly_cancelled; for (cbm_daemon_application_session_t *other = application->sessions; @@ -2036,9 +2700,18 @@ static void application_session_cancel(void *context, * watcher/UI jobs that are not owned by session->active_job. */ application->stopping = true; application_cancel_jobs_locked(application); + application_update_cancel_locked(application); + reap_update = reap_update || application->update_thread_started; } cbm_mutex_unlock(&application->mutex); (void)cbm_mcp_server_cancel_active(session->mcp); + application_auto_index_cancel_join(application, join_auto_index); + application_jobs_reap_completed(application); + if (reap_update) { + if (!application_update_reap(application, true, APPLICATION_BACKGROUND_REAP_MS)) { + application_cleanup_force_terminate("update_cleanup"); + } + } } } @@ -2049,6 +2722,8 @@ static void application_session_close(void *context, if (!application || !session || session->application != application) { return; } + bool reap_update = false; + cbm_daemon_application_job_t *join_auto_index = NULL; cbm_mutex_lock(&application->mutex); cbm_daemon_application_session_t **cursor = &application->sessions; while (*cursor && *cursor != session) { @@ -2062,8 +2737,20 @@ static void application_session_close(void *context, session->active_job = NULL; session->active_job_subscribed = false; } + join_auto_index = application_auto_index_release_locked(session); + reap_update = application_update_owner_release_locked(session); + reap_update = + reap_update || (session->background_eligible && application->update_thread_started && + application->update_owners == 0); application_release_session_watch_locked(session); cbm_mutex_unlock(&application->mutex); + application_auto_index_cancel_join(application, join_auto_index); + application_jobs_reap_completed(application); + if (reap_update) { + if (!application_update_reap(application, true, APPLICATION_BACKGROUND_REAP_MS)) { + application_cleanup_force_terminate("update_cleanup"); + } + } cbm_mcp_server_free(session->mcp); free(session->hook_event); free(session->hook_dialect); @@ -2092,6 +2779,9 @@ cbm_daemon_application_t *cbm_daemon_application_new( if (config->worker_ops) { application->worker_ops = *config->worker_ops; } + if (config->update_ops) { + application->update_ops = *config->update_ops; + } } /* Equal fixed slices keep admission deterministic: starting fewer jobs does * not let an early worker claim memory reserved for later concurrent jobs. @@ -2121,11 +2811,25 @@ cbm_daemon_application_t *cbm_daemon_application_new( free(application); return NULL; } + if (!application->update_ops.start) { + application->update_ops = (cbm_daemon_application_update_ops_t){ + .context = NULL, + .start = application_update_worker_start_default, + .poll = application_update_worker_poll_default, + .cancel = application_update_worker_cancel_default, + .destroy = application_update_worker_destroy_default, + }; + } + if (!application->update_ops.poll || !application->update_ops.cancel || + !application->update_ops.destroy) { + cbm_mutex_destroy(&application->mutex); + free(application); + return NULL; + } if (application->watcher) { cbm_watcher_set_project_mutation_guard( application->watcher, application_watcher_mutation_begin, - application_watcher_mutation_end, application_watcher_project_pruned, - application); + application_watcher_mutation_end, application_watcher_project_pruned, application); } return application; } @@ -2140,8 +2844,15 @@ bool cbm_daemon_application_shutdown(cbm_daemon_application_t *application, uint for (cbm_daemon_application_session_t *session = application->sessions; session; session = session->next) { (void)cbm_mcp_server_cancel_active(session->mcp); + if (session->auto_index_job && session->auto_index_subscribed) { + application_job_unsubscribe_locked(session->auto_index_job); + session->auto_index_job = NULL; + session->auto_index_subscribed = false; + } + (void)application_update_owner_release_locked(session); } application_cancel_jobs_locked(application); + application_update_cancel_locked(application); cbm_mutex_unlock(&application->mutex); for (;;) { bool all_done = true; @@ -2149,15 +2860,17 @@ bool cbm_daemon_application_shutdown(cbm_daemon_application_t *application, uint if (application->active_mutations != 0) { all_done = false; } - for (cbm_daemon_application_session_t *session = application->sessions; - all_done && session; session = session->next) { + if (application->update_thread_started && !application->update_thread_done) { + all_done = false; + } + for (cbm_daemon_application_session_t *session = application->sessions; all_done && session; + session = session->next) { if (session->request_active) { all_done = false; } } for (cbm_daemon_application_job_t *job = application->jobs; job; job = job->next) { - if (!job->thread_done || job->subscribers != 0 || - job->watcher_waiters != 0) { + if (!job->thread_done || job->subscribers != 0 || job->watcher_waiters != 0) { all_done = false; break; } @@ -2165,7 +2878,12 @@ bool cbm_daemon_application_shutdown(cbm_daemon_application_t *application, uint cbm_mutex_unlock(&application->mutex); if (all_done) { application_jobs_reap_completed(application); - return true; + uint64_t now = cbm_now_ms(); + uint32_t remaining = + now >= deadline + ? 0 + : (uint32_t)((deadline - now) > UINT32_MAX ? UINT32_MAX : deadline - now); + return application_update_reap(application, true, remaining); } if (cbm_now_ms() >= deadline) { return false; @@ -2174,22 +2892,22 @@ bool cbm_daemon_application_shutdown(cbm_daemon_application_t *application, uint } } -void cbm_daemon_application_free(cbm_daemon_application_t *application) { +bool cbm_daemon_application_free_with_timeout(cbm_daemon_application_t *application, + uint32_t timeout_ms) { if (!application) { - return; + return true; } - if (!cbm_daemon_application_shutdown(application, 3000)) { + if (!cbm_daemon_application_shutdown(application, timeout_ms)) { /* Never detach/free live job threads. The caller must retain the * application and retry shutdown after the containment failure is * resolved. */ cbm_log_error("daemon.application.free_busy", "action", "retain"); - return; + return false; } if (application->watcher) { /* Waits for any in-flight prune callback before application storage is * detached, preventing a borrowed callback context from becoming UAF. */ - cbm_watcher_set_project_mutation_guard(application->watcher, NULL, NULL, - NULL, NULL); + cbm_watcher_set_project_mutation_guard(application->watcher, NULL, NULL, NULL, NULL); } cbm_mutex_lock(&application->mutex); cbm_daemon_application_session_t *sessions = application->sessions; @@ -2221,8 +2939,7 @@ void cbm_daemon_application_free(cbm_daemon_application_t *application) { watches = next; } while (watch_job_subscriptions) { - cbm_daemon_application_watch_job_subscription_t *next = - watch_job_subscriptions->next; + cbm_daemon_application_watch_job_subscription_t *next = watch_job_subscriptions->next; free(watch_job_subscriptions); watch_job_subscriptions = next; } @@ -2242,6 +2959,11 @@ void cbm_daemon_application_free(cbm_daemon_application_t *application) { } cbm_mutex_destroy(&application->mutex); free(application); + return true; +} + +bool cbm_daemon_application_free(cbm_daemon_application_t *application) { + return cbm_daemon_application_free_with_timeout(application, 3000); } cbm_daemon_runtime_application_callbacks_t cbm_daemon_application_runtime_callbacks( @@ -2260,11 +2982,9 @@ cbm_daemon_runtime_application_callbacks_t cbm_daemon_application_runtime_callba return callbacks; } -static cbm_daemon_runtime_application_status_t -application_client_exchange_tagged( - cbm_daemon_runtime_client_t *client, - cbm_daemon_runtime_application_token_t request_token, uint8_t *request, - uint32_t request_length, uint8_t **response_out, +static cbm_daemon_runtime_application_status_t application_client_exchange_tagged( + cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, + uint8_t *request, uint32_t request_length, uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms) { uint8_t *response = NULL; uint32_t response_length = 0; @@ -2276,12 +2996,11 @@ application_client_exchange_tagged( } cbm_daemon_runtime_application_status_t status = request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID - ? cbm_daemon_runtime_client_application_request( - client, request, request_length, &response, - &response_length, timeout_ms) - : cbm_daemon_runtime_client_application_request_tagged( - client, request_token, request, request_length, &response, - &response_length, timeout_ms); + ? cbm_daemon_runtime_client_application_request(client, request, request_length, + &response, &response_length, timeout_ms) + : cbm_daemon_runtime_client_application_request_tagged(client, request_token, request, + request_length, &response, + &response_length, timeout_ms); free(request); if (status != CBM_DAEMON_RUNTIME_APPLICATION_OK) { free(response); @@ -2308,18 +3027,16 @@ application_client_exchange_tagged( } static cbm_daemon_runtime_application_status_t application_client_exchange( - cbm_daemon_runtime_client_t *client, uint8_t *request, - uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms) { - return application_client_exchange_tagged( - client, CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID, request, - request_length, response_out, response_length_out, timeout_ms); + cbm_daemon_runtime_client_t *client, uint8_t *request, uint32_t request_length, + uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms) { + return application_client_exchange_tagged(client, CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID, + request, request_length, response_out, + response_length_out, timeout_ms); } cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_context( - cbm_daemon_runtime_client_t *client, const char *session_root, - const char *allowed_root, cbm_mcp_tool_profile_t tool_profile, - const char *hook_event, const char *hook_dialect, + cbm_daemon_runtime_client_t *client, const char *session_root, const char *allowed_root, + cbm_mcp_tool_profile_t tool_profile, const char *hook_event, const char *hook_dialect, uint32_t timeout_ms) { if (!client || !session_root || !session_root[0]) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; @@ -2328,16 +3045,13 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_contex size_t allowed_length = allowed_root ? strlen(allowed_root) : 0; size_t event_length = hook_event ? strlen(hook_event) : 0; size_t dialect_length = hook_dialect ? strlen(hook_dialect) : 0; - uint64_t total = (uint64_t)APPLICATION_CONTEXT_HEADER_SIZE + root_length + - allowed_length + event_length + dialect_length; - if (tool_profile < CBM_MCP_TOOL_PROFILE_ALL || - tool_profile > CBM_MCP_TOOL_PROFILE_SCOUT || - (tool_profile != CBM_MCP_TOOL_PROFILE_ALL && - (event_length != 0 || dialect_length != 0)) || + uint64_t total = (uint64_t)APPLICATION_CONTEXT_HEADER_SIZE + root_length + allowed_length + + event_length + dialect_length; + if (tool_profile < CBM_MCP_TOOL_PROFILE_ALL || tool_profile > CBM_MCP_TOOL_PROFILE_SCOUT || + (tool_profile != CBM_MCP_TOOL_PROFILE_ALL && (event_length != 0 || dialect_length != 0)) || !cbm_hook_augment_invocation_supported(hook_event, hook_dialect) || - root_length > UINT32_MAX || allowed_length > UINT32_MAX || - event_length > UINT32_MAX || dialect_length > UINT32_MAX || - total > CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX) { + root_length > UINT32_MAX || allowed_length > UINT32_MAX || event_length > UINT32_MAX || + dialect_length > UINT32_MAX || total > CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; } uint8_t *request = calloc(1, (size_t)total); @@ -2357,13 +3071,12 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_contex allowed_length); } if (hook_event) { - memcpy(request + APPLICATION_CONTEXT_HEADER_SIZE + root_length + - allowed_length, - hook_event, event_length); + memcpy(request + APPLICATION_CONTEXT_HEADER_SIZE + root_length + allowed_length, hook_event, + event_length); } if (hook_dialect) { - memcpy(request + APPLICATION_CONTEXT_HEADER_SIZE + root_length + - allowed_length + event_length, + memcpy(request + APPLICATION_CONTEXT_HEADER_SIZE + root_length + allowed_length + + event_length, hook_dialect, dialect_length); } uint8_t *unexpected = NULL; @@ -2378,19 +3091,14 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_contex } cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_ui_config( - cbm_daemon_runtime_client_t *client, uint8_t update_mask, - bool ui_enabled, int ui_port, uint32_t timeout_ms) { + cbm_daemon_runtime_client_t *client, uint8_t update_mask, bool ui_enabled, int ui_port, + uint32_t timeout_ms) { const uint8_t valid_mask = - CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED | - CBM_DAEMON_APPLICATION_UI_CONFIG_PORT; - bool enabled_present = - (update_mask & CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED) != 0; - bool port_present = - (update_mask & CBM_DAEMON_APPLICATION_UI_CONFIG_PORT) != 0; - if (!client || update_mask == 0 || - (update_mask & (uint8_t)~valid_mask) != 0 || - (!enabled_present && ui_enabled) || - (!port_present && ui_port != 0) || + CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED | CBM_DAEMON_APPLICATION_UI_CONFIG_PORT; + bool enabled_present = (update_mask & CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED) != 0; + bool port_present = (update_mask & CBM_DAEMON_APPLICATION_UI_CONFIG_PORT) != 0; + if (!client || update_mask == 0 || (update_mask & (uint8_t)~valid_mask) != 0 || + (!enabled_present && ui_enabled) || (!port_present && ui_port != 0) || (port_present && (ui_port <= 0 || ui_port > 65535))) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; } @@ -2407,24 +3115,19 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_ui_con uint8_t *unexpected = NULL; uint32_t unexpected_length = 0; cbm_daemon_runtime_application_status_t status = - application_client_exchange( - client, request, APPLICATION_UI_CONFIG_REQUEST_SIZE, &unexpected, - &unexpected_length, timeout_ms); - if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK && - (unexpected || unexpected_length != 0)) { + application_client_exchange(client, request, APPLICATION_UI_CONFIG_REQUEST_SIZE, + &unexpected, &unexpected_length, timeout_ms); + if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK && (unexpected || unexpected_length != 0)) { status = CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; } free(unexpected); return status; } -static cbm_daemon_runtime_application_status_t -application_client_text_request_tagged( - cbm_daemon_runtime_client_t *client, - cbm_daemon_runtime_application_token_t request_token, - cbm_daemon_application_request_kind_t kind, const char *text, - uint8_t **response_out, uint32_t *response_length_out, - uint32_t timeout_ms) { +static cbm_daemon_runtime_application_status_t application_client_text_request_tagged( + cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, + cbm_daemon_application_request_kind_t kind, const char *text, uint8_t **response_out, + uint32_t *response_length_out, uint32_t timeout_ms) { if (!client || !text || !text[0]) { return CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; } @@ -2438,19 +3141,17 @@ application_client_text_request_tagged( } request[0] = (uint8_t)kind; memcpy(request + 1, text, text_length); - return application_client_exchange_tagged( - client, request_token, request, (uint32_t)text_length + 1U, - response_out, response_length_out, timeout_ms); + return application_client_exchange_tagged(client, request_token, request, + (uint32_t)text_length + 1U, response_out, + response_length_out, timeout_ms); } static cbm_daemon_runtime_application_status_t application_client_text_request( - cbm_daemon_runtime_client_t *client, - cbm_daemon_application_request_kind_t kind, const char *text, - uint8_t **response_out, uint32_t *response_length_out, - uint32_t timeout_ms) { + cbm_daemon_runtime_client_t *client, cbm_daemon_application_request_kind_t kind, + const char *text, uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms) { return application_client_text_request_tagged( - client, CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID, kind, text, - response_out, response_length_out, timeout_ms); + client, CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID, kind, text, response_out, + response_length_out, timeout_ms); } cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp( @@ -2460,15 +3161,13 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp( response_out, response_length_out, timeout_ms); } -cbm_daemon_runtime_application_status_t -cbm_daemon_application_client_mcp_tagged( - cbm_daemon_runtime_client_t *client, - cbm_daemon_runtime_application_token_t request_token, - const char *message, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms) { - return application_client_text_request_tagged( - client, request_token, CBM_DAEMON_APPLICATION_REQUEST_MCP, message, - response_out, response_length_out, timeout_ms); +cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp_tagged( + cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, + const char *message, uint8_t **response_out, uint32_t *response_length_out, + uint32_t timeout_ms) { + return application_client_text_request_tagged(client, request_token, + CBM_DAEMON_APPLICATION_REQUEST_MCP, message, + response_out, response_length_out, timeout_ms); } cbm_daemon_runtime_application_status_t cbm_daemon_application_client_tool( @@ -2504,8 +3203,7 @@ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_hook_augme } static int application_background_index(cbm_daemon_application_t *application, - const char *project_name, - const char *root_path, + const char *project_name, const char *root_path, bool require_live_watch) { if (!application || !project_name || !root_path) { return -1; @@ -2542,28 +3240,22 @@ static int application_background_index(cbm_daemon_application_t *application, return -1; } - application_job_subscribe_status_t subscribe_status = - APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE; + application_job_subscribe_status_t subscribe_status = APPLICATION_JOB_SUBSCRIBE_UNAVAILABLE; application_jobs_reap_completed(application); cbm_mutex_lock(&application->mutex); cbm_daemon_application_watch_t *watch = - require_live_watch - ? application_find_watch_locked(application, project_name) - : NULL; + require_live_watch ? application_find_watch_locked(application, project_name) : NULL; bool watch_live = !require_live_watch || - (watch && watch->subscribers > 0 && - strcmp(watch->root, canonical_root) == 0); + (watch && watch->subscribers > 0 && strcmp(watch->root, canonical_root) == 0); size_t watch_owner_count = 0; bool watch_subscriptions_ok = true; cbm_daemon_application_job_t *job = - watch_live ? application_job_subscribe_locked( - application, project_key, canonical_root, args, - &subscribe_status) + watch_live ? application_job_subscribe_locked(application, project_key, canonical_root, + args, &subscribe_status) : NULL; if (job && require_live_watch) { - watch_subscriptions_ok = - application_watch_job_subscribe_sessions_locked( - application, watch, job, &watch_owner_count); + watch_subscriptions_ok = application_watch_job_subscribe_sessions_locked( + application, watch, job, &watch_owner_count); if (watch_subscriptions_ok && watch_owner_count > 0) { job->watcher_waiters++; } else if (!watch_subscriptions_ok) { @@ -2619,11 +3311,9 @@ static int application_background_index(cbm_daemon_application_t *application, return successful ? 0 : (cancelled ? 1 : -1); } -int cbm_daemon_application_index(cbm_daemon_application_t *application, - const char *project_name, +int cbm_daemon_application_index(cbm_daemon_application_t *application, const char *project_name, const char *root_path) { - return application_background_index(application, project_name, root_path, - false); + return application_background_index(application, project_name, root_path, false); } int cbm_daemon_application_watcher_index(const char *project_name, const char *root_path, @@ -2658,6 +3348,26 @@ size_t cbm_daemon_application_job_subscribers(cbm_daemon_application_t *applicat return subscribers; } +size_t cbm_daemon_application_physical_job_limit(cbm_daemon_application_t *application) { + if (!application) { + return 0; + } + cbm_mutex_lock(&application->mutex); + size_t limit = application->physical_job_limit; + cbm_mutex_unlock(&application->mutex); + return limit; +} + +size_t cbm_daemon_application_worker_memory_budget_bytes(cbm_daemon_application_t *application) { + if (!application) { + return 0; + } + cbm_mutex_lock(&application->mutex); + size_t budget = application->worker_memory_budget_bytes; + cbm_mutex_unlock(&application->mutex); + return budget; +} + bool cbm_daemon_application_session_retains_store_for_test( const cbm_daemon_runtime_application_session_t *opaque_session) { const cbm_daemon_application_session_t *session = diff --git a/src/daemon/application.h b/src/daemon/application.h index 24728c7d3..f23dc38b6 100644 --- a/src/daemon/application.h +++ b/src/daemon/application.h @@ -3,8 +3,9 @@ * * The runtime authenticates a local process and owns connection lifetime. This * layer owns everything above that boundary: one isolated MCP session per - * connection, explicit workspace context, shared watcher subscriptions, and - * daemon-owned index jobs. Frontends never construct stores or watchers. + * connection, explicit workspace context, shared watcher subscriptions, + * daemon-owned index jobs, and one update-check generation. Frontends never + * construct stores or watchers. */ #ifndef CBM_DAEMON_APPLICATION_H #define CBM_DAEMON_APPLICATION_H @@ -23,6 +24,7 @@ struct cbm_watcher; typedef struct cbm_daemon_application cbm_daemon_application_t; typedef void *cbm_daemon_application_worker_t; +typedef void *cbm_daemon_application_update_worker_t; /* Injectable physical-worker boundary. Production uses index_supervisor; * tests use this to deterministically hold/release one shared job. */ @@ -38,10 +40,33 @@ typedef struct { void (*destroy)(void *context, cbm_daemon_application_worker_t worker); } cbm_daemon_application_worker_ops_t; +typedef enum { + CBM_DAEMON_APPLICATION_UPDATE_POLL_ERROR = -1, + CBM_DAEMON_APPLICATION_UPDATE_POLL_RUNNING = 0, + CBM_DAEMON_APPLICATION_UPDATE_POLL_TERMINAL = 1, +} cbm_daemon_application_update_poll_t; + +/* Injectable update-check boundary. Production supervises one curl process; + * tests provide an offline worker. latest_version_out is borrowed from the + * worker and is only meaningful for a clean terminal result. RUNNING is the + * only non-terminal poll result; ERROR is a contained terminal failure. cancel + * must be safe concurrently with poll, and destroy receives only a terminal + * worker. */ +typedef struct { + void *context; + int (*start)(void *context, cbm_daemon_application_update_worker_t *worker_out); + cbm_daemon_application_update_poll_t (*poll)(void *context, + cbm_daemon_application_update_worker_t worker, + const char **latest_version_out); + bool (*cancel)(void *context, cbm_daemon_application_update_worker_t worker); + void (*destroy)(void *context, cbm_daemon_application_update_worker_t worker); +} cbm_daemon_application_update_ops_t; + typedef struct { struct cbm_watcher *watcher; /* borrowed; daemon lifetime */ struct cbm_config *config; /* borrowed; daemon lifetime */ const cbm_daemon_application_worker_ops_t *worker_ops; /* NULL = production */ + const cbm_daemon_application_update_ops_t *update_ops; /* NULL = production */ /* Maximum distinct, non-terminal physical index jobs. Zero selects the * conservative daemon default (4). Identical requests still coalesce even * while the limit is full. */ @@ -74,7 +99,13 @@ cbm_daemon_application_t *cbm_daemon_application_new(const cbm_daemon_applicatio /* Cancel and reap all daemon-owned operations within timeout_ms. Normal final * client shutdown calls this before watcher/store teardown. Idempotent. */ bool cbm_daemon_application_shutdown(cbm_daemon_application_t *application, uint32_t timeout_ms); -void cbm_daemon_application_free(cbm_daemon_application_t *application); +/* Destroy application storage only after every borrowed callback and physical + * operation is quiescent. Returns false without freeing anything when the + * timeout expires; the caller must retain both the application and every + * borrowed dependency, then retry or fail-stop the owning process. */ +bool cbm_daemon_application_free_with_timeout(cbm_daemon_application_t *application, + uint32_t timeout_ms); +bool cbm_daemon_application_free(cbm_daemon_application_t *application); /* Callbacks are borrowed from application and remain valid until free. */ cbm_daemon_runtime_application_callbacks_t cbm_daemon_application_runtime_callbacks( @@ -85,28 +116,26 @@ cbm_daemon_runtime_application_callbacks_t cbm_daemon_application_runtime_callba * response bytes are malloc-owned and include one trailing NUL for text use; * response_length excludes that terminator. */ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_context( - cbm_daemon_runtime_client_t *client, const char *session_root, - const char *allowed_root, cbm_mcp_tool_profile_t tool_profile, - const char *hook_event, const char *hook_dialect, uint32_t timeout_ms); + cbm_daemon_runtime_client_t *client, const char *session_root, const char *allowed_root, + cbm_mcp_tool_profile_t tool_profile, const char *hook_event, const char *hook_dialect, + uint32_t timeout_ms); /* Persist a masked UI configuration mutation in the daemon. A zero/unknown * mask, an invalid port, or a non-canonical unused field is rejected before * transport. */ cbm_daemon_runtime_application_status_t cbm_daemon_application_client_set_ui_config( - cbm_daemon_runtime_client_t *client, uint8_t update_mask, bool ui_enabled, - int ui_port, uint32_t timeout_ms); + cbm_daemon_runtime_client_t *client, uint8_t update_mask, bool ui_enabled, int ui_port, + uint32_t timeout_ms); cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp( cbm_daemon_runtime_client_t *client, const char *message, uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms); /* Cancellable frontend variant using a token reserved on the runtime client. */ -cbm_daemon_runtime_application_status_t -cbm_daemon_application_client_mcp_tagged( - cbm_daemon_runtime_client_t *client, - cbm_daemon_runtime_application_token_t request_token, - const char *message, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms); +cbm_daemon_runtime_application_status_t cbm_daemon_application_client_mcp_tagged( + cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, + const char *message, uint8_t **response_out, uint32_t *response_length_out, + uint32_t timeout_ms); cbm_daemon_runtime_application_status_t cbm_daemon_application_client_tool( cbm_daemon_runtime_client_t *client, const char *tool_name, const char *args_json, @@ -132,14 +161,16 @@ int cbm_daemon_application_index(cbm_daemon_application_t *application, const ch /* Non-blocking mutation lease for daemon-owned UI endpoints. A successful * begin must be paired with end. Returns false while the project (or global * project set "*") is reserved by indexing, another mutation, or shutdown. */ -bool cbm_daemon_application_project_mutation_try_begin( - cbm_daemon_application_t *application, const char *project); -void cbm_daemon_application_project_mutation_end( - cbm_daemon_application_t *application, const char *project); +bool cbm_daemon_application_project_mutation_try_begin(cbm_daemon_application_t *application, + const char *project); +void cbm_daemon_application_project_mutation_end(cbm_daemon_application_t *application, + const char *project); /* Read-only coordination metrics used by daemon diagnostics/tests. */ size_t cbm_daemon_application_active_jobs(cbm_daemon_application_t *application); size_t cbm_daemon_application_job_subscribers(cbm_daemon_application_t *application, const char *project_key); +size_t cbm_daemon_application_physical_job_limit(cbm_daemon_application_t *application); +size_t cbm_daemon_application_worker_memory_budget_bytes(cbm_daemon_application_t *application); #endif /* CBM_DAEMON_APPLICATION_H */ diff --git a/src/daemon/application_internal.h b/src/daemon/application_internal.h index 22c65f378..81f283161 100644 --- a/src/daemon/application_internal.h +++ b/src/daemon/application_internal.h @@ -13,4 +13,10 @@ bool cbm_daemon_application_session_retains_store_for_test( const cbm_daemon_runtime_application_session_t *session); +/* Fail exactly one physical index supervisor-thread admission before any + * worker starts. This keeps the otherwise OS-dependent thread-create failure + * path deterministic and verifies that its linked job reservation is rolled + * back rather than retained as terminal background state. */ +void cbm_daemon_application_fail_next_job_thread_start_for_test(void); + #endif /* CBM_DAEMON_APPLICATION_INTERNAL_H */ diff --git a/src/daemon/bootstrap.c b/src/daemon/bootstrap.c index c2399ec65..794f0ebd2 100644 --- a/src/daemon/bootstrap.c +++ b/src/daemon/bootstrap.c @@ -103,8 +103,7 @@ static bool bootstrap_worker_argv_exact(int argc, char *const argv[]) { return false; } int next = 9; - if (next < argc && - bootstrap_arg_is(argv[next], "--index-worker-memory-budget-bytes")) { + if (next < argc && bootstrap_arg_is(argv[next], "--index-worker-memory-budget-bytes")) { if (next + 1 >= argc || !bootstrap_worker_budget_valid(argv[next + 1])) { return false; } @@ -164,18 +163,15 @@ cbm_daemon_process_role_t cbm_daemon_process_role(int argc, char *const argv[]) return CBM_DAEMON_PROCESS_HOOK_CLIENT; } if (bootstrap_arg_is(argv[arg], "config")) { - return bootstrap_has_help_after(argc, argv, arg + 1) - ? CBM_DAEMON_PROCESS_STATELESS - : CBM_DAEMON_PROCESS_LOCAL_CLI; + return bootstrap_has_help_after(argc, argv, arg + 1) ? CBM_DAEMON_PROCESS_STATELESS + : CBM_DAEMON_PROCESS_LOCAL_CLI; } - if (bootstrap_arg_is(argv[arg], "--version") || - bootstrap_arg_is(argv[arg], "--help") || + if (bootstrap_arg_is(argv[arg], "--version") || bootstrap_arg_is(argv[arg], "--help") || bootstrap_arg_is(argv[arg], "-h")) { return CBM_DAEMON_PROCESS_STATELESS; } for (size_t command = 0; - command < sizeof(stateless_commands) / sizeof(stateless_commands[0]); - command++) { + command < sizeof(stateless_commands) / sizeof(stateless_commands[0]); command++) { if (bootstrap_arg_is(argv[arg], stateless_commands[command])) { return CBM_DAEMON_PROCESS_STATELESS; } @@ -239,18 +235,14 @@ static void bootstrap_pause(uint64_t deadline) { uint64_t remaining_ms = deadline - now; struct timespec pause = { .tv_sec = 0, - .tv_nsec = remaining_ms > 1 - ? BOOTSTRAP_RETRY_NS - : (long)(remaining_ms * 1000000ULL), + .tv_nsec = remaining_ms > 1 ? BOOTSTRAP_RETRY_NS : (long)(remaining_ms * 1000000ULL), }; (void)cbm_nanosleep(&pause, NULL); } -static void bootstrap_startup_lock_release_complete( - const cbm_daemon_bootstrap_ops_t *ops, - cbm_daemon_bootstrap_lock_t *lock_io) { - uint64_t deadline = - bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS); +static void bootstrap_startup_lock_release_complete(const cbm_daemon_bootstrap_ops_t *ops, + cbm_daemon_bootstrap_lock_t *lock_io) { + uint64_t deadline = bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS); while (ops && ops->startup_lock_release && lock_io && *lock_io) { (void)ops->startup_lock_release(ops->context, lock_io); if (!*lock_io) { @@ -288,7 +280,8 @@ static cbm_daemon_bootstrap_status_t bootstrap_finish_probe( (void)snprintf(result->message, sizeof(result->message), "%s", connect_result && connect_result->message[0] ? connect_result->message - : "CBM could not start because a conflicting CBM process is active; close all CBM sessions and commands, then retry"); + : "CBM could not start because a conflicting CBM process is active; " + "close all CBM sessions and commands, then retry"); if (ops->visible_diagnostic) { ops->visible_diagnostic(ops->context, result->message); } @@ -321,10 +314,9 @@ static bool bootstrap_config_valid(const cbm_daemon_bootstrap_config_t *config, const cbm_daemon_bootstrap_ops_t *ops) { return config && ops && config->endpoint && config->identity && config->executable_path && config->executable_path[0] && config->connect_timeout_ms > 0 && - config->startup_timeout_ms > 0 && ops->cohort_acquire && - ops->cohort_release && ops->probe && ops->startup_lock_try_acquire && - ops->startup_lock_prepare_handoff && ops->startup_lock_release && - ops->spawn_daemon; + config->startup_timeout_ms > 0 && ops->cohort_acquire && ops->cohort_release && + ops->probe && ops->startup_lock_try_acquire && ops->startup_lock_prepare_handoff && + ops->startup_lock_release && ops->spawn_daemon; } cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops( @@ -347,21 +339,18 @@ cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops( cbm_daemon_bootstrap_cohort_t cohort = NULL; cbm_daemon_conflict_t cohort_conflict; cbm_version_cohort_status_t cohort_status = ops->cohort_acquire( - ops->context, config->endpoint, config->identity, deadline, &cohort, - &cohort_conflict); + ops->context, config->endpoint, config->identity, deadline, &cohort, &cohort_conflict); if (cohort_status != CBM_VERSION_COHORT_OK) { result_out->status = cohort_status == CBM_VERSION_COHORT_CONFLICT ? CBM_DAEMON_BOOTSTRAP_CONFLICT : CBM_DAEMON_BOOTSTRAP_FAILED; bool formatted = cohort_status == CBM_VERSION_COHORT_CONFLICT && - cbm_daemon_conflict_format( - &cohort_conflict, result_out->message, - sizeof(result_out->message)); + cbm_daemon_conflict_format(&cohort_conflict, result_out->message, + sizeof(result_out->message)); if (!formatted) { - const char *reason = - cohort_status == CBM_VERSION_COHORT_BUSY - ? "another CBM activation is in progress" - : "exact-build admission could not be verified"; + const char *reason = cohort_status == CBM_VERSION_COHORT_BUSY + ? "another CBM activation is in progress" + : "exact-build admission could not be verified"; (void)snprintf(result_out->message, sizeof(result_out->message), "CBM daemon could not start: %s", reason); } @@ -380,8 +369,7 @@ cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops( bootstrap_probe(config, ops, &client, &connect_result); if (bootstrap_probe_is_finishable(probe)) { cbm_daemon_bootstrap_status_t status = - bootstrap_finish_probe(probe, client, &connect_result, ops, - result_out); + bootstrap_finish_probe(probe, client, &connect_result, ops, result_out); ops->cohort_release(ops->context, cohort); return status; } @@ -391,9 +379,8 @@ cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops( cbm_daemon_bootstrap_lock_t startup_lock = NULL; bool lock_acquired = false; - bool generation_observed = - probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED || - probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL; + bool generation_observed = probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED || + probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL; while (cbm_now_ms() < deadline) { if (!bootstrap_probe_is_waitable(probe)) { break; @@ -436,8 +423,7 @@ cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops( lock_acquired = false; continue; } - if (bootstrap_probe_is_finishable(probe) || - probe == CBM_DAEMON_BOOTSTRAP_PROBE_ERROR) { + if (bootstrap_probe_is_finishable(probe) || probe == CBM_DAEMON_BOOTSTRAP_PROBE_ERROR) { break; } if (probe != CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE) { @@ -472,8 +458,7 @@ cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops( } if (bootstrap_probe_is_finishable(probe)) { cbm_daemon_bootstrap_status_t status = - bootstrap_finish_probe(probe, client, &connect_result, ops, - result_out); + bootstrap_finish_probe(probe, client, &connect_result, ops, result_out); ops->cohort_release(ops->context, cohort); return status; } @@ -537,13 +522,11 @@ static cbm_daemon_bootstrap_probe_status_t bootstrap_production_probe( const cbm_daemon_build_identity_t *identity, uint32_t timeout_ms, cbm_daemon_runtime_client_t **client_out, cbm_daemon_runtime_connect_result_t *result_out) { bootstrap_production_context_t *production = context; - if (!production || !production->cohort || - !production->cohort->manager) { + if (!production || !production->cohort || !production->cohort->manager) { return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; } cbm_version_cohort_daemon_presence_t claim = - cbm_version_cohort_daemon_claim_presence( - production->cohort->manager); + cbm_version_cohort_daemon_claim_presence(production->cohort->manager); if (claim == CBM_VERSION_COHORT_DAEMON_ABSENT) { int lifetime = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); if (lifetime == 0) { @@ -559,8 +542,7 @@ static cbm_daemon_bootstrap_probe_status_t bootstrap_production_probe( return CBM_DAEMON_BOOTSTRAP_PROBE_ERROR; } - *client_out = cbm_daemon_runtime_client_connect(endpoint, identity, - timeout_ms, result_out); + *client_out = cbm_daemon_runtime_client_connect(endpoint, identity, timeout_ms, result_out); if (*client_out) { return CBM_DAEMON_BOOTSTRAP_PROBE_CONNECTED; } @@ -568,8 +550,7 @@ static cbm_daemon_bootstrap_probe_status_t bootstrap_production_probe( /* Ownership may turn over while the connection attempt is in flight. * Re-observe both signals so disappearance is not sticky and a live or * cleaning-up generation is never mistaken for absence. */ - claim = cbm_version_cohort_daemon_claim_presence( - production->cohort->manager); + claim = cbm_version_cohort_daemon_claim_presence(production->cohort->manager); if (claim == CBM_VERSION_COHORT_DAEMON_COORDINATED) { return cbm_daemon_bootstrap_classify_failed_connect(result_out, 1); } @@ -583,8 +564,7 @@ static cbm_daemon_bootstrap_probe_status_t bootstrap_production_probe( static cbm_version_cohort_status_t bootstrap_production_cohort_acquire( void *context, const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms, - cbm_daemon_bootstrap_cohort_t *cohort_out, - cbm_daemon_conflict_t *conflict_out) { + cbm_daemon_bootstrap_cohort_t *cohort_out, cbm_daemon_conflict_t *conflict_out) { *cohort_out = NULL; bootstrap_production_cohort_t *cohort = calloc(1, sizeof(*cohort)); if (cohort) { @@ -607,8 +587,7 @@ static cbm_version_cohort_status_t bootstrap_production_cohort_acquire( } return status; } - uint64_t cleanup_deadline = - bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS); + uint64_t cleanup_deadline = bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS); cbm_private_file_lock_status_t cleanup = CBM_PRIVATE_FILE_LOCK_OK; while (cohort->manager) { cleanup = cbm_version_cohort_manager_free(&cohort->manager); @@ -621,19 +600,17 @@ static cbm_version_cohort_status_t bootstrap_production_cohort_acquire( cbm_usleep(1000); } free(cohort); - return cleanup == CBM_PRIVATE_FILE_LOCK_OK ? status - : CBM_VERSION_COHORT_IO; + return cleanup == CBM_PRIVATE_FILE_LOCK_OK ? status : CBM_VERSION_COHORT_IO; } -static void bootstrap_production_cohort_release( - void *context, cbm_daemon_bootstrap_cohort_t opaque) { +static void bootstrap_production_cohort_release(void *context, + cbm_daemon_bootstrap_cohort_t opaque) { bootstrap_production_context_t *production = context; bootstrap_production_cohort_t *cohort = opaque; if (!cohort) { return; } - uint64_t cleanup_deadline = - bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS); + uint64_t cleanup_deadline = bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS); while (cohort->lease) { (void)cbm_version_cohort_lease_release(&cohort->lease); if (!cohort->lease) { @@ -644,8 +621,7 @@ static void bootstrap_production_cohort_release( } cbm_usleep(1000); } - cleanup_deadline = - bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS); + cleanup_deadline = bootstrap_deadline_after(BOOTSTRAP_COORDINATION_CLEANUP_MS); while (cohort->manager) { (void)cbm_version_cohort_manager_free(&cohort->manager); if (!cohort->manager) { @@ -671,8 +647,7 @@ static int bootstrap_production_lock(void *context, const cbm_daemon_ipc_endpoin return status; } -static bool bootstrap_production_unlock( - void *context, cbm_daemon_bootstrap_lock_t *lock_io) { +static bool bootstrap_production_unlock(void *context, cbm_daemon_bootstrap_lock_t *lock_io) { (void)context; if (!lock_io) { return false; @@ -683,11 +658,9 @@ static bool bootstrap_production_unlock( return released; } -static bool bootstrap_production_handoff(void *context, - cbm_daemon_bootstrap_lock_t lock) { +static bool bootstrap_production_handoff(void *context, cbm_daemon_bootstrap_lock_t lock) { (void)context; - return cbm_daemon_ipc_startup_lock_prepare_handoff( - (cbm_daemon_ipc_startup_lock_t *)lock); + return cbm_daemon_ipc_startup_lock_prepare_handoff((cbm_daemon_ipc_startup_lock_t *)lock); } #ifdef _WIN32 @@ -714,6 +687,21 @@ static bool bootstrap_production_spawn(void *context, ZeroMemory(&child, sizeof(child)); startup.cb = sizeof(startup); DWORD flags = DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW; + /* A managed frontend payload is intentionally contained in the permanent + * launcher's kill-on-close job. The account daemon outlives that one + * frontend and therefore uses breakaway when the containing job explicitly + * permits it. Do not request breakaway from an unrelated restrictive job: + * CreateProcess would fail and regress portable/package-manager payloads. */ + BOOL in_job = FALSE; + JOBOBJECT_EXTENDED_LIMIT_INFORMATION job_limits; + memset(&job_limits, 0, sizeof(job_limits)); + if (IsProcessInJob(GetCurrentProcess(), NULL, &in_job) && in_job && + QueryInformationJobObject(NULL, JobObjectExtendedLimitInformation, &job_limits, + sizeof(job_limits), NULL) && + (job_limits.BasicLimitInformation.LimitFlags & + (JOB_OBJECT_LIMIT_BREAKAWAY_OK | JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK)) != 0) { + flags |= CREATE_BREAKAWAY_FROM_JOB; + } BOOL created = CreateProcessW(application, command, NULL, NULL, FALSE, flags, NULL, NULL, &startup, &child); free(application); diff --git a/src/daemon/bootstrap.h b/src/daemon/bootstrap.h index 3094dc76b..17cdc7dec 100644 --- a/src/daemon/bootstrap.h +++ b/src/daemon/bootstrap.h @@ -114,13 +114,13 @@ typedef void *cbm_daemon_bootstrap_cohort_t; * 0 held by another starter, -1 error. */ typedef struct { void *context; - cbm_version_cohort_status_t (*cohort_acquire)( - void *context, const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms, - cbm_daemon_bootstrap_cohort_t *cohort_out, - cbm_daemon_conflict_t *conflict_out); - void (*cohort_release)(void *context, - cbm_daemon_bootstrap_cohort_t cohort); + cbm_version_cohort_status_t (*cohort_acquire)(void *context, + const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, + uint64_t deadline_ms, + cbm_daemon_bootstrap_cohort_t *cohort_out, + cbm_daemon_conflict_t *conflict_out); + void (*cohort_release)(void *context, cbm_daemon_bootstrap_cohort_t cohort); cbm_daemon_bootstrap_probe_status_t (*probe)(void *context, const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_build_identity_t *identity, @@ -132,11 +132,9 @@ typedef struct { /* Called with startup serialization still held immediately before spawn. * It releases only migration-era compatibility ownership that the child * must reacquire for its lifetime; the ordinary startup lock stays held. */ - bool (*startup_lock_prepare_handoff)( - void *context, cbm_daemon_bootstrap_lock_t lock); + bool (*startup_lock_prepare_handoff)(void *context, cbm_daemon_bootstrap_lock_t lock); /* Retry-safe: success consumes and clears *lock_io; false retains it. */ - bool (*startup_lock_release)( - void *context, cbm_daemon_bootstrap_lock_t *lock_io); + bool (*startup_lock_release)(void *context, cbm_daemon_bootstrap_lock_t *lock_io); bool (*spawn_daemon)(void *context, const cbm_daemon_bootstrap_launch_spec_t *spec); void (*visible_diagnostic)(void *context, const char *message); } cbm_daemon_bootstrap_ops_t; diff --git a/src/daemon/daemon.c b/src/daemon/daemon.c index 3bb01d10f..e228e84e0 100644 --- a/src/daemon/daemon.c +++ b/src/daemon/daemon.c @@ -123,8 +123,8 @@ static cbm_daemon_client_id_t issue_client_id_locked(cbm_daemon_coordinator_t *c return coordinator->last_client_id; } -static cbm_daemon_subscription_id_t -issue_subscription_id_locked(cbm_daemon_coordinator_t *coordinator) { +static cbm_daemon_subscription_id_t issue_subscription_id_locked( + cbm_daemon_coordinator_t *coordinator) { if (coordinator->last_subscription_id == UINT64_MAX) { return CBM_DAEMON_SUBSCRIPTION_ID_INVALID; } @@ -163,8 +163,7 @@ static cbm_daemon_watch_t *find_watch_locked(cbm_daemon_coordinator_t *coordinat } static bool remove_subscription_locked(cbm_daemon_subscription_t **subscriptions, - size_t *subscription_count, - cbm_daemon_client_id_t client_id, + size_t *subscription_count, cbm_daemon_client_id_t client_id, cbm_daemon_subscription_id_t subscription_id) { cbm_daemon_subscription_t **cursor = subscriptions; while (*cursor) { @@ -204,8 +203,7 @@ static void callback_batch_init_locked(cbm_daemon_coordinator_t *coordinator, batch->context = coordinator->hooks.context; } -static void request_job_cancel_locked(cbm_daemon_coordinator_t *coordinator, - cbm_daemon_job_t *job, +static void request_job_cancel_locked(cbm_daemon_coordinator_t *coordinator, cbm_daemon_job_t *job, cbm_daemon_callback_batch_t *batch) { if (job->subscription_count != 0 || job->state != CBM_DAEMON_JOB_RUNNING) { return; @@ -287,8 +285,7 @@ static void release_client_resources_locked(cbm_daemon_coordinator_t *coordinato } static void release_client_locked(cbm_daemon_coordinator_t *coordinator, - cbm_daemon_client_t *client, - cbm_daemon_callback_batch_t *batch) { + cbm_daemon_client_t *client, cbm_daemon_callback_batch_t *batch) { release_client_resources_locked(coordinator, client->id, batch); free(client); coordinator->client_count--; @@ -297,9 +294,8 @@ static void release_client_locked(cbm_daemon_coordinator_t *coordinator, } } -static bool terminal_job_locked(cbm_daemon_coordinator_t *coordinator, - const char *project_key, bool require_cancellation, - cbm_daemon_job_t **free_after_unlock) { +static bool terminal_job_locked(cbm_daemon_coordinator_t *coordinator, const char *project_key, + bool require_cancellation, cbm_daemon_job_t **free_after_unlock) { cbm_daemon_job_t **cursor = &coordinator->jobs; while (*cursor && strcmp((*cursor)->project_key, project_key) != 0) { cursor = &(*cursor)->next; @@ -373,8 +369,7 @@ bool cbm_daemon_coordinator_set_hooks(cbm_daemon_coordinator_t *coordinator, return true; } -cbm_daemon_coordinator_state_t -cbm_daemon_coordinator_state(cbm_daemon_coordinator_t *coordinator) { +cbm_daemon_coordinator_state_t cbm_daemon_coordinator_state(cbm_daemon_coordinator_t *coordinator) { if (!coordinator) { return CBM_DAEMON_COORDINATOR_STOPPING; } @@ -496,10 +491,9 @@ size_t cbm_daemon_active_clients(cbm_daemon_coordinator_t *coordinator) { return count; } -cbm_daemon_subscription_result_t -cbm_daemon_job_subscribe(cbm_daemon_coordinator_t *coordinator, - cbm_daemon_client_id_t client_id, const char *project_key, - cbm_daemon_subscription_id_t *subscription_id) { +cbm_daemon_subscription_result_t cbm_daemon_job_subscribe( + cbm_daemon_coordinator_t *coordinator, cbm_daemon_client_id_t client_id, + const char *project_key, cbm_daemon_subscription_id_t *subscription_id) { if (subscription_id) { *subscription_id = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; } @@ -562,10 +556,9 @@ cbm_daemon_job_subscribe(cbm_daemon_coordinator_t *coordinator, return started ? CBM_DAEMON_SUBSCRIPTION_STARTED : CBM_DAEMON_SUBSCRIPTION_JOINED; } -cbm_daemon_subscription_result_t -cbm_daemon_watch_subscribe(cbm_daemon_coordinator_t *coordinator, - cbm_daemon_client_id_t client_id, const char *project_key, - cbm_daemon_subscription_id_t *subscription_id) { +cbm_daemon_subscription_result_t cbm_daemon_watch_subscribe( + cbm_daemon_coordinator_t *coordinator, cbm_daemon_client_id_t client_id, + const char *project_key, cbm_daemon_subscription_id_t *subscription_id) { if (subscription_id) { *subscription_id = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; } @@ -689,8 +682,7 @@ bool cbm_daemon_watch_unsubscribe(cbm_daemon_coordinator_t *coordinator, return removed; } -size_t cbm_daemon_job_subscribers(cbm_daemon_coordinator_t *coordinator, - const char *project_key) { +size_t cbm_daemon_job_subscribers(cbm_daemon_coordinator_t *coordinator, const char *project_key) { if (!coordinator || !project_key) { return 0; } @@ -745,8 +737,7 @@ cbm_daemon_job_state_t cbm_daemon_job_state(cbm_daemon_coordinator_t *coordinato return state; } -bool cbm_daemon_job_reaping(cbm_daemon_coordinator_t *coordinator, - const char *project_key) { +bool cbm_daemon_job_reaping(cbm_daemon_coordinator_t *coordinator, const char *project_key) { if (!coordinator || !project_key) { return false; } @@ -760,8 +751,8 @@ bool cbm_daemon_job_reaping(cbm_daemon_coordinator_t *coordinator, return transitioned; } -bool cbm_daemon_job_reaped(cbm_daemon_coordinator_t *coordinator, - const char *project_key, uint64_t now_ms) { +bool cbm_daemon_job_reaped(cbm_daemon_coordinator_t *coordinator, const char *project_key, + uint64_t now_ms) { (void)now_ms; if (!coordinator || !project_key) { return false; @@ -774,8 +765,8 @@ bool cbm_daemon_job_reaped(cbm_daemon_coordinator_t *coordinator, return removed; } -bool cbm_daemon_job_completed(cbm_daemon_coordinator_t *coordinator, - const char *project_key, uint64_t now_ms) { +bool cbm_daemon_job_completed(cbm_daemon_coordinator_t *coordinator, const char *project_key, + uint64_t now_ms) { (void)now_ms; if (!coordinator || !project_key) { return false; diff --git a/src/daemon/daemon.h b/src/daemon/daemon.h index 6a363a1d4..cf1f0074e 100644 --- a/src/daemon/daemon.h +++ b/src/daemon/daemon.h @@ -76,8 +76,7 @@ void cbm_daemon_coordinator_free(cbm_daemon_coordinator_t *coordinator); * quiescent. Hooks are always invoked after releasing the coordinator mutex. */ bool cbm_daemon_coordinator_set_hooks(cbm_daemon_coordinator_t *coordinator, const cbm_daemon_coordinator_hooks_t *hooks); -cbm_daemon_coordinator_state_t -cbm_daemon_coordinator_state(cbm_daemon_coordinator_t *coordinator); +cbm_daemon_coordinator_state_t cbm_daemon_coordinator_state(cbm_daemon_coordinator_t *coordinator); /* Client IDs are daemon-issued, nonzero, monotonic, and never recycled. */ cbm_daemon_client_id_t cbm_daemon_client_connected(cbm_daemon_coordinator_t *coordinator, @@ -91,14 +90,12 @@ size_t cbm_daemon_active_clients(cbm_daemon_coordinator_t *coordinator); /* Every accepted subscription receives a unique daemon-issued handle. The * first subscriber starts the physical resource; later subscribers join it. */ -cbm_daemon_subscription_result_t -cbm_daemon_job_subscribe(cbm_daemon_coordinator_t *coordinator, - cbm_daemon_client_id_t client_id, const char *project_key, - cbm_daemon_subscription_id_t *subscription_id); -cbm_daemon_subscription_result_t -cbm_daemon_watch_subscribe(cbm_daemon_coordinator_t *coordinator, - cbm_daemon_client_id_t client_id, const char *project_key, - cbm_daemon_subscription_id_t *subscription_id); +cbm_daemon_subscription_result_t cbm_daemon_job_subscribe( + cbm_daemon_coordinator_t *coordinator, cbm_daemon_client_id_t client_id, + const char *project_key, cbm_daemon_subscription_id_t *subscription_id); +cbm_daemon_subscription_result_t cbm_daemon_watch_subscribe( + cbm_daemon_coordinator_t *coordinator, cbm_daemon_client_id_t client_id, + const char *project_key, cbm_daemon_subscription_id_t *subscription_id); bool cbm_daemon_job_unsubscribe(cbm_daemon_coordinator_t *coordinator, cbm_daemon_client_id_t client_id, cbm_daemon_subscription_id_t subscription_id); @@ -106,10 +103,8 @@ bool cbm_daemon_watch_unsubscribe(cbm_daemon_coordinator_t *coordinator, cbm_daemon_client_id_t client_id, cbm_daemon_subscription_id_t subscription_id); -size_t cbm_daemon_job_subscribers(cbm_daemon_coordinator_t *coordinator, - const char *project_key); -size_t cbm_daemon_watch_subscribers(cbm_daemon_coordinator_t *coordinator, - const char *project_key); +size_t cbm_daemon_job_subscribers(cbm_daemon_coordinator_t *coordinator, const char *project_key); +size_t cbm_daemon_watch_subscribers(cbm_daemon_coordinator_t *coordinator, const char *project_key); size_t cbm_daemon_active_jobs(cbm_daemon_coordinator_t *coordinator); size_t cbm_daemon_active_watches(cbm_daemon_coordinator_t *coordinator); cbm_daemon_job_state_t cbm_daemon_job_state(cbm_daemon_coordinator_t *coordinator, @@ -117,12 +112,11 @@ cbm_daemon_job_state_t cbm_daemon_job_state(cbm_daemon_coordinator_t *coordinato /* Cancellation is two phase. Losing the final subscriber requests cancel; * the job remains active until its supervisor reports completion/reaping. */ -bool cbm_daemon_job_reaping(cbm_daemon_coordinator_t *coordinator, - const char *project_key); -bool cbm_daemon_job_reaped(cbm_daemon_coordinator_t *coordinator, - const char *project_key, uint64_t now_ms); -bool cbm_daemon_job_completed(cbm_daemon_coordinator_t *coordinator, - const char *project_key, uint64_t now_ms); +bool cbm_daemon_job_reaping(cbm_daemon_coordinator_t *coordinator, const char *project_key); +bool cbm_daemon_job_reaped(cbm_daemon_coordinator_t *coordinator, const char *project_key, + uint64_t now_ms); +bool cbm_daemon_job_completed(cbm_daemon_coordinator_t *coordinator, const char *project_key, + uint64_t now_ms); /* STOPPING is terminal. Exit is ready only after every job/watch is gone. */ bool cbm_daemon_should_exit(cbm_daemon_coordinator_t *coordinator, uint64_t now_ms); diff --git a/src/daemon/frontend.c b/src/daemon/frontend.c index 82cfd429f..146a1f8b3 100644 --- a/src/daemon/frontend.c +++ b/src/daemon/frontend.c @@ -6,7 +6,6 @@ #include "daemon/application.h" #include "foundation/compat.h" #include "foundation/compat_thread.h" -#include "foundation/log.h" #include "foundation/platform.h" #include "foundation/subprocess.h" #include "mcp/mcp.h" @@ -23,14 +22,23 @@ enum { FRONTEND_QUEUE_BYTES_MAX = 12 * 1024 * 1024, FRONTEND_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000, FRONTEND_CLOSE_TIMEOUT_MS = 5000, + FRONTEND_JOIN_WATCHDOG_MS = FRONTEND_CLOSE_TIMEOUT_MS, + /* A regular-file/buffered client can write several complete requests and + * close stdin before the worker is scheduled. Give already-accepted input + * a short FIFO drain window so EOF cannot silently discard it. A genuinely + * active long operation is still cancelled at this deadline. */ + FRONTEND_EOF_DRAIN_MS = 1000, FRONTEND_WAIT_US = 1000, + /* An idle thin frontend owns no work and only needs to notice the next + * queue item promptly. Ten milliseconds avoids a 1 kHz wake-up loop per + * connected coding-agent session without perceptible request latency. */ + FRONTEND_IDLE_WAIT_US = 10 * 1000, FRONTEND_MAINTENANCE_POLL_MS = 10, /* The owner thread may be draining a supervised process tree. Preserve the * supervisor's complete graceful + forced-settle window before the monitor * fail-stops the process, plus scheduling/teardown margin. */ FRONTEND_MAINTENANCE_GRACE_MS = - CBM_SUBPROCESS_MAX_CANCEL_GRACE_MS + - CBM_SUBPROCESS_FORCE_SETTLE_MS + 1000, + CBM_SUBPROCESS_MAX_CANCEL_GRACE_MS + CBM_SUBPROCESS_FORCE_SETTLE_MS + 1000, FRONTEND_PARTICIPANT_NAME_CAP = 64, }; @@ -54,7 +62,9 @@ typedef struct { size_t head; size_t count; size_t queued_bytes; + bool input_closed; bool stopping; + bool worker_done; bool in_request; bool active_has_id; int64_t active_id; @@ -63,6 +73,10 @@ typedef struct { bool failed; } frontend_state_t; +typedef struct { + atomic_bool complete; +} frontend_join_watchdog_t; + struct cbm_daemon_maintenance_monitor { cbm_thread_t thread; cbm_version_cohort_manager_t *manager; @@ -73,96 +87,55 @@ struct cbm_daemon_maintenance_monitor { char participant[FRONTEND_PARTICIPANT_NAME_CAP]; }; -static void frontend_flush_process_outputs(FILE *out) { - if (out) { - (void)fflush(out); - } - (void)fflush(stderr); -} - -static const char *frontend_maintenance_reason( - cbm_version_cohort_maintenance_presence_t presence) { - switch (presence) { - case CBM_VERSION_COHORT_MAINTENANCE_REQUESTED: - return "requested"; - case CBM_VERSION_COHORT_MAINTENANCE_UNSAFE: - return "unsafe"; - case CBM_VERSION_COHORT_MAINTENANCE_IO: - return "io"; - case CBM_VERSION_COHORT_MAINTENANCE_ABSENT: - default: - return "absent"; - } -} - static void *frontend_maintenance_monitor_worker(void *opaque) { cbm_daemon_maintenance_monitor_t *monitor = opaque; - while (!atomic_load_explicit(&monitor->stopping, - memory_order_acquire)) { + while (!atomic_load_explicit(&monitor->stopping, memory_order_acquire)) { cbm_version_cohort_maintenance_presence_t presence = - cbm_version_cohort_maintenance_presence(monitor->manager); + cbm_version_cohort_maintenance_presence_terminal(monitor->manager); if (presence == CBM_VERSION_COHORT_MAINTENANCE_ABSENT) { cbm_usleep(FRONTEND_MAINTENANCE_POLL_MS * 1000U); continue; } if (presence == CBM_VERSION_COHORT_MAINTENANCE_REQUESTED) { - bool cancellation_requested = - monitor->cancel && monitor->cancel(monitor->cancel_context); - cbm_log_info("participant.maintenance_requested", "participant", - monitor->participant, "cooperative_cancel", - cancellation_requested ? "requested" : "not_active"); - (void)fprintf( - stderr, - "codebase-memory-mcp: active %s is stopping for install/update/uninstall\n", - monitor->participant); - frontend_flush_process_outputs(stdout); + if (monitor->cancel) { + (void)monitor->cancel(monitor->cancel_context); + } + /* Never log, write to, or flush agent stdio from this monitor. + * Structured logging itself writes to stderr, and this thread's + * reason for existing is to remain runnable when another frontend + * thread is blocked on a full stdout/stderr pipe. The activation + * owner records the maintenance event durably. */ uint64_t now = cbm_now_ms(); - uint64_t deadline = - now > UINT64_MAX - FRONTEND_MAINTENANCE_GRACE_MS - ? UINT64_MAX - : now + FRONTEND_MAINTENANCE_GRACE_MS; - while (!atomic_load_explicit(&monitor->stopping, - memory_order_acquire) && + uint64_t deadline = now > UINT64_MAX - FRONTEND_MAINTENANCE_GRACE_MS + ? UINT64_MAX + : now + FRONTEND_MAINTENANCE_GRACE_MS; + while (!atomic_load_explicit(&monitor->stopping, memory_order_acquire) && cbm_now_ms() < deadline) { cbm_usleep(FRONTEND_MAINTENANCE_POLL_MS * 1000U); } - if (atomic_load_explicit(&monitor->stopping, - memory_order_acquire)) { + if (atomic_load_explicit(&monitor->stopping, memory_order_acquire)) { return NULL; } - cbm_log_info("participant.maintenance_forced_exit", "participant", - monitor->participant, "action", "process_exit"); - frontend_flush_process_outputs(stdout); _Exit(monitor->exit_code); } /* An observer that cannot prove absence must not let local work * survive into a binary mutation window. Fail closed and let native * process teardown release SQLite and cohort ownership. */ - cbm_log_error("participant.maintenance_observation_failed", - "participant", monitor->participant, "reason", - frontend_maintenance_reason(presence)); - (void)fprintf( - stderr, - "codebase-memory-mcp: %s stopped because maintenance coordination could not be verified\n", - monitor->participant); - frontend_flush_process_outputs(stdout); _Exit(EXIT_FAILURE); } return NULL; } cbm_daemon_maintenance_monitor_t *cbm_daemon_maintenance_monitor_start( - cbm_version_cohort_manager_t *manager, - cbm_daemon_maintenance_cancel_fn cancel, void *cancel_context, - int exit_code, const char *participant) { + cbm_version_cohort_manager_t *manager, cbm_daemon_maintenance_cancel_fn cancel, + void *cancel_context, int exit_code, const char *participant) { if (!manager || exit_code < 0 || !participant || !participant[0]) { return NULL; } - cbm_daemon_maintenance_monitor_t *monitor = - calloc(1, sizeof(*monitor)); + cbm_daemon_maintenance_monitor_t *monitor = calloc(1, sizeof(*monitor)); if (!monitor) { return NULL; } @@ -171,20 +144,16 @@ cbm_daemon_maintenance_monitor_t *cbm_daemon_maintenance_monitor_start( monitor->cancel_context = cancel_context; monitor->exit_code = exit_code; atomic_init(&monitor->stopping, false); - int written = snprintf(monitor->participant, - sizeof(monitor->participant), "%s", participant); + int written = snprintf(monitor->participant, sizeof(monitor->participant), "%s", participant); if (written <= 0 || written >= (int)sizeof(monitor->participant) || - cbm_thread_create(&monitor->thread, 0, - frontend_maintenance_monitor_worker, - monitor) != 0) { + cbm_thread_create(&monitor->thread, 0, frontend_maintenance_monitor_worker, monitor) != 0) { free(monitor); return NULL; } return monitor; } -bool cbm_daemon_maintenance_monitor_stop( - cbm_daemon_maintenance_monitor_t **monitor_io) { +bool cbm_daemon_maintenance_monitor_stop(cbm_daemon_maintenance_monitor_t **monitor_io) { if (!monitor_io || !*monitor_io) { return false; } @@ -200,27 +169,15 @@ bool cbm_daemon_maintenance_monitor_stop( static void frontend_exit_for_maintenance(frontend_state_t *state) { cbm_version_cohort_maintenance_presence_t presence = - cbm_version_cohort_maintenance_presence(state->cohort_manager); + cbm_version_cohort_maintenance_presence_terminal(state->cohort_manager); if (presence == CBM_VERSION_COHORT_MAINTENANCE_ABSENT) { return; } - bool requested = - presence == CBM_VERSION_COHORT_MAINTENANCE_REQUESTED; - if (requested) { - cbm_log_info("daemon.frontend.maintenance_requested", "action", - "exit_thin_frontend"); - (void)fputs( - "codebase-memory-mcp: MCP session is stopping for install/update/uninstall; restart the coding-agent session afterwards\n", - stderr); - } else { - cbm_log_error("daemon.frontend.maintenance_observation_failed", - "reason", frontend_maintenance_reason(presence), - "action", "exit_thin_frontend"); - } /* Do not fclose stdin across threads. Process exit closes the authenticated - * kernel IPC handle, and daemon ownership then cancels only this session. */ - frontend_flush_process_outputs(state->out); - _Exit(requested ? EXIT_SUCCESS : EXIT_FAILURE); + * kernel IPC handle, and daemon ownership then cancels only this session. + * Agent stdout/stderr may both be backpressured, so terminal paths must not + * log, write, or flush before fail-stop. */ + _Exit(presence == CBM_VERSION_COHORT_MAINTENANCE_REQUESTED ? EXIT_SUCCESS : EXIT_FAILURE); } static void frontend_item_free(frontend_item_t *item) { @@ -233,25 +190,61 @@ static void frontend_item_free(frontend_item_t *item) { static bool frontend_should_stop(frontend_state_t *state) { cbm_mutex_lock(&state->mutex); - bool stopping = state->stopping; + bool stopping = state->stopping || (state->input_closed && state->count == 0); cbm_mutex_unlock(&state->mutex); return stopping; } +static void frontend_worker_mark_done(frontend_state_t *state) { + cbm_mutex_lock(&state->mutex); + state->worker_done = true; + cbm_mutex_unlock(&state->mutex); +} + +static bool frontend_worker_is_done(frontend_state_t *state) { + cbm_mutex_lock(&state->mutex); + bool done = state->worker_done; + cbm_mutex_unlock(&state->mutex); + return done; +} + +static void frontend_input_closed(frontend_state_t *state) { + cbm_mutex_lock(&state->mutex); + state->input_closed = true; + cbm_mutex_unlock(&state->mutex); +} + +static void *frontend_join_watchdog(void *opaque) { + frontend_join_watchdog_t *watchdog = opaque; + uint64_t now = cbm_now_ms(); + uint64_t deadline = + now > UINT64_MAX - FRONTEND_JOIN_WATCHDOG_MS ? UINT64_MAX : now + FRONTEND_JOIN_WATCHDOG_MS; + while (!atomic_load_explicit(&watchdog->complete, memory_order_acquire) && + cbm_now_ms() < deadline) { + cbm_usleep(FRONTEND_WAIT_US); + } + if (!atomic_load_explicit(&watchdog->complete, memory_order_acquire)) { + /* A thin frontend owns no durable state. If stdout is backpressured, + * fclose/IPC cancellation cannot portably wake its worker on every + * platform. Fail-stop releases the authenticated connection and lets + * the daemon cancel this exact session instead of hanging forever. */ + _Exit(EXIT_FAILURE); + } + return NULL; +} + /* Pop and publish the active request identity under one mutex acquisition, so * a cancellation reader can never observe the item as neither queued nor * active. */ -static bool frontend_pop_begin(frontend_state_t *state, - frontend_item_t *item) { +static bool frontend_pop_begin(frontend_state_t *state, frontend_item_t *item) { bool popped = false; cbm_mutex_lock(&state->mutex); if (!state->stopping && state->count > 0) { frontend_item_t *queued = &state->queue[state->head]; cbm_daemon_runtime_application_token_t request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; - bool token_ready = queued->cancelled || - cbm_daemon_runtime_client_application_token_reserve( - state->client, &request_token); + bool token_ready = queued->cancelled || cbm_daemon_runtime_client_application_token_reserve( + state->client, &request_token); if (!token_ready) { state->failed = true; state->stopping = true; @@ -281,31 +274,26 @@ static void frontend_end_request(frontend_state_t *state, bool failed) { state->active_has_id = false; state->active_id = 0; state->active_id_str = NULL; - state->active_request_token = - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + state->active_request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; state->failed = state->failed || failed; cbm_mutex_unlock(&state->mutex); } -static bool frontend_write_response(FILE *out, const uint8_t *response, - uint32_t response_length, +static bool frontend_write_response(FILE *out, const uint8_t *response, uint32_t response_length, bool content_length_framed) { bool written = false; if (content_length_framed) { - written = fprintf(out, "Content-Length: %u\r\n\r\n", - response_length) >= 0 && + written = fprintf(out, "Content-Length: %u\r\n\r\n", response_length) >= 0 && fwrite(response, 1, response_length, out) == response_length; } else { - written = fwrite(response, 1, response_length, out) == response_length && - fputc('\n', out) != EOF; + written = + fwrite(response, 1, response_length, out) == response_length && fputc('\n', out) != EOF; } return fflush(out) == 0 && written; } -static bool frontend_write_cancelled_response(FILE *out, - const frontend_item_t *item) { - static const char cancelled_error[] = - "{\"code\":-32800,\"message\":\"Request cancelled\"}"; +static bool frontend_write_cancelled_response(FILE *out, const frontend_item_t *item) { + static const char cancelled_error[] = "{\"code\":-32800,\"message\":\"Request cancelled\"}"; cbm_jsonrpc_response_t response = { .id = item->id, .id_str = item->id_str, @@ -313,17 +301,14 @@ static bool frontend_write_cancelled_response(FILE *out, .error_code = -32800, }; char *encoded = cbm_jsonrpc_format_response(&response); - bool written = encoded && - frontend_write_response( - out, (const uint8_t *)encoded, - (uint32_t)strlen(encoded), - item->content_length_framed); + bool written = + encoded && frontend_write_response(out, (const uint8_t *)encoded, (uint32_t)strlen(encoded), + item->content_length_framed); free(encoded); return written; } -static bool frontend_parse_cancellation(const char *message, - cbm_jsonrpc_request_t *request_out) { +static bool frontend_parse_cancellation(const char *message, cbm_jsonrpc_request_t *request_out) { if (!message || !request_out) { return false; } @@ -332,8 +317,7 @@ static bool frontend_parse_cancellation(const char *message, return false; } bool cancellation = !request_out->has_id && request_out->method && - strcmp(request_out->method, - "notifications/cancelled") == 0; + strcmp(request_out->method, "notifications/cancelled") == 0; if (!cancellation) { cbm_jsonrpc_request_free(request_out); } @@ -349,13 +333,12 @@ bool cbm_daemon_frontend_is_cancellation_notification(const char *message) { return cancellation; } -bool cbm_daemon_frontend_cancellation_matches_request( - const char *message, int64_t active_id, const char *active_id_str) { +bool cbm_daemon_frontend_cancellation_matches_request(const char *message, int64_t active_id, + const char *active_id_str) { cbm_jsonrpc_request_t request = {0}; bool cancellation = frontend_parse_cancellation(message, &request); bool matches = cancellation && - cbm_mcp_cancel_request_matches( - request.params_raw, active_id, active_id_str); + cbm_mcp_cancel_request_matches(request.params_raw, active_id, active_id_str); if (cancellation) { cbm_jsonrpc_request_free(&request); } @@ -387,22 +370,19 @@ static frontend_cancellation_route_t frontend_route_cancellation( frontend_cancellation_route_t route = FRONTEND_CANCELLATION_STALE; cbm_mutex_lock(&state->mutex); if (state->in_request && state->active_has_id && - state->active_request_token != - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && - cbm_mcp_cancel_request_matches( - request.params_raw, state->active_id, state->active_id_str)) { + state->active_request_token != CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && + cbm_mcp_cancel_request_matches(request.params_raw, state->active_id, + state->active_id_str)) { if (request_token_out) { *request_token_out = state->active_request_token; } route = FRONTEND_CANCELLATION_ACTIVE; } else { for (size_t offset = 0; offset < state->count; offset++) { - size_t index = - (state->head + offset) % FRONTEND_QUEUE_CAPACITY; + size_t index = (state->head + offset) % FRONTEND_QUEUE_CAPACITY; frontend_item_t *item = &state->queue[index]; if (item->has_id && - cbm_mcp_cancel_request_matches( - request.params_raw, item->id, item->id_str)) { + cbm_mcp_cancel_request_matches(request.params_raw, item->id, item->id_str)) { item->cancelled = true; route = FRONTEND_CANCELLATION_QUEUED; break; @@ -421,45 +401,37 @@ static void *frontend_worker(void *opaque) { uint64_t now_ms = cbm_now_ms(); if (now_ms >= next_maintenance_check_ms) { frontend_exit_for_maintenance(state); - next_maintenance_check_ms = - now_ms > UINT64_MAX - FRONTEND_MAINTENANCE_POLL_MS - ? UINT64_MAX - : now_ms + FRONTEND_MAINTENANCE_POLL_MS; + next_maintenance_check_ms = now_ms > UINT64_MAX - FRONTEND_MAINTENANCE_POLL_MS + ? UINT64_MAX + : now_ms + FRONTEND_MAINTENANCE_POLL_MS; } frontend_item_t item = {0}; if (!frontend_pop_begin(state, &item)) { if (frontend_should_stop(state)) { break; } - cbm_usleep(FRONTEND_WAIT_US); + cbm_usleep(FRONTEND_IDLE_WAIT_US); continue; } uint8_t *response = NULL; uint32_t response_length = 0; - bool output_failed = false; bool failed = false; if (item.cancelled) { - output_failed = - !frontend_write_cancelled_response(state->out, &item); - failed = output_failed; + failed = !frontend_write_cancelled_response(state->out, &item); } else { cbm_daemon_runtime_application_status_t status = - cbm_daemon_application_client_mcp_tagged( - state->client, item.request_token, item.message, &response, - &response_length, FRONTEND_REQUEST_TIMEOUT_MS); + cbm_daemon_application_client_mcp_tagged(state->client, item.request_token, + item.message, &response, &response_length, + FRONTEND_REQUEST_TIMEOUT_MS); if (status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED) { - output_failed = - !frontend_write_cancelled_response(state->out, &item); - failed = output_failed; + failed = !frontend_write_cancelled_response(state->out, &item); } else { failed = status != CBM_DAEMON_RUNTIME_APPLICATION_OK; } - if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK && !failed && - response && response_length > 0) { - output_failed = !frontend_write_response( - state->out, response, response_length, - item.content_length_framed); - failed = output_failed; + if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK && !failed && response && + response_length > 0) { + failed = !frontend_write_response(state->out, response, response_length, + item.content_length_framed); } } free(response); @@ -472,21 +444,18 @@ static void *frontend_worker(void *opaque) { * stdio portably. This frontend owns no state: immediate * process exit closes the kernel IPC handle, which cancels * daemon session ownership without a detached reader or an - * unsafe cross-thread fclose. */ - cbm_log_error("daemon.frontend.request_failed", "reason", - output_failed ? "output" : "daemon_transport", - "action", "exit_thin_frontend"); - frontend_flush_process_outputs(state->out); + * unsafe cross-thread fclose. Logging or flushing here could + * itself block on agent-owned stdout/stderr. */ _Exit(EXIT_FAILURE); } break; } } + frontend_worker_mark_done(state); return NULL; } -static bool frontend_enqueue(frontend_state_t *state, char *message, - bool content_length_framed) { +static bool frontend_enqueue(frontend_state_t *state, char *message, bool content_length_framed) { size_t length = strlen(message); bool has_id = false; int64_t id = 0; @@ -504,34 +473,33 @@ static bool frontend_enqueue(frontend_state_t *state, char *message, return false; } } - for (;;) { - cbm_mutex_lock(&state->mutex); - bool stopped = state->stopping || state->failed; - bool capacity = state->count < FRONTEND_QUEUE_CAPACITY && - (state->count == 0 || - state->queued_bytes + length <= FRONTEND_QUEUE_BYTES_MAX); - if (!stopped && capacity) { - size_t tail = (state->head + state->count) % FRONTEND_QUEUE_CAPACITY; - state->queue[tail] = (frontend_item_t){ - .message = message, - .length = length, - .content_length_framed = content_length_framed, - .has_id = has_id, - .id = id, - .id_str = id_str, - }; - state->count++; - state->queued_bytes += length; - cbm_mutex_unlock(&state->mutex); - return true; - } + cbm_mutex_lock(&state->mutex); + bool stopped = state->stopping || state->failed; + bool capacity = state->count < FRONTEND_QUEUE_CAPACITY && + state->queued_bytes <= FRONTEND_QUEUE_BYTES_MAX && + length <= FRONTEND_QUEUE_BYTES_MAX - state->queued_bytes; + if (!stopped && capacity) { + size_t tail = (state->head + state->count) % FRONTEND_QUEUE_CAPACITY; + state->queue[tail] = (frontend_item_t){ + .message = message, + .length = length, + .content_length_framed = content_length_framed, + .has_id = has_id, + .id = id, + .id_str = id_str, + }; + state->count++; + state->queued_bytes += length; cbm_mutex_unlock(&state->mutex); - if (stopped) { - free(id_str); - return false; - } - cbm_usleep(FRONTEND_WAIT_US); + return true; + } + if (!stopped) { + state->failed = true; + state->stopping = true; } + cbm_mutex_unlock(&state->mutex); + free(id_str); + return false; } static bool frontend_stop_begin(frontend_state_t *state) { @@ -544,9 +512,25 @@ static bool frontend_stop_begin(frontend_state_t *state) { return cbm_daemon_runtime_client_close_begin(state->client); } -int cbm_daemon_frontend_mcp_run( - cbm_daemon_runtime_client_t *client, - cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out) { +static bool frontend_cancel_for_maintenance(void *opaque) { + frontend_state_t *state = opaque; + cbm_daemon_runtime_application_token_t request_token = + CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + cbm_mutex_lock(&state->mutex); + state->stopping = true; + if (state->in_request) { + request_token = state->active_request_token; + } + cbm_mutex_unlock(&state->mutex); + if (request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { + return false; + } + return cbm_daemon_runtime_client_application_cancel(state->client, request_token) == + CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED; +} + +int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, + cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out) { if (!client || !cohort_manager || !in || !out) { return -1; } @@ -556,23 +540,33 @@ int cbm_daemon_frontend_mcp_run( .out = out, }; cbm_mutex_init(&state.mutex); + cbm_daemon_maintenance_monitor_t *maintenance_monitor = cbm_daemon_maintenance_monitor_start( + cohort_manager, frontend_cancel_for_maintenance, &state, EXIT_SUCCESS, "MCP frontend"); + if (!maintenance_monitor) { + cbm_mutex_destroy(&state.mutex); + (void)cbm_daemon_runtime_client_close(client, FRONTEND_CLOSE_TIMEOUT_MS); + return -1; + } cbm_thread_t worker; if (cbm_thread_create(&worker, 0, frontend_worker, &state) != 0) { + if (!cbm_daemon_maintenance_monitor_stop(&maintenance_monitor)) { + _Exit(EXIT_FAILURE); + } cbm_mutex_destroy(&state.mutex); - (void)cbm_daemon_runtime_client_close(client, - FRONTEND_CLOSE_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(client, FRONTEND_CLOSE_TIMEOUT_MS); return -1; } int result = 0; bool close_begun = false; + bool clean_eof = false; for (;;) { char *message = NULL; bool content_length_framed = false; - int read_status = - cbm_mcp_read_message(in, &message, &content_length_framed); + int read_status = cbm_mcp_read_message(in, &message, &content_length_framed); if (read_status <= 0) { result = read_status < 0 ? -1 : 0; + clean_eof = read_status == 0; free(message); break; } @@ -584,8 +578,7 @@ int cbm_daemon_frontend_mcp_run( free(message); if (cancellation == FRONTEND_CANCELLATION_ACTIVE) { cbm_daemon_runtime_cancel_result_t cancelled = - cbm_daemon_runtime_client_application_cancel( - state.client, cancel_token); + cbm_daemon_runtime_client_application_cancel(state.client, cancel_token); if (cancelled == CBM_DAEMON_RUNTIME_CANCEL_ERROR) { result = -1; close_begun = frontend_stop_begin(&state); @@ -603,13 +596,43 @@ int cbm_daemon_frontend_mcp_run( } } + if (clean_eof && !close_begun) { + frontend_input_closed(&state); + uint64_t now = cbm_now_ms(); + uint64_t drain_deadline = + now > UINT64_MAX - FRONTEND_EOF_DRAIN_MS ? UINT64_MAX : now + FRONTEND_EOF_DRAIN_MS; + while (!frontend_worker_is_done(&state) && cbm_now_ms() < drain_deadline) { + cbm_usleep(FRONTEND_WAIT_US); + } + } if (!close_begun) { close_begun = frontend_stop_begin(&state); } - (void)cbm_thread_join(&worker); + frontend_join_watchdog_t watchdog; + cbm_thread_t watchdog_thread; + bool watchdog_started = false; + if (!frontend_worker_is_done(&state)) { + atomic_init(&watchdog.complete, false); + if (cbm_thread_create(&watchdog_thread, 0, frontend_join_watchdog, &watchdog) != 0) { + _Exit(EXIT_FAILURE); + } + watchdog_started = true; + } + if (cbm_thread_join(&worker) != 0) { + /* Preserve every object the worker may still reference. */ + _Exit(EXIT_FAILURE); + } + if (watchdog_started) { + atomic_store_explicit(&watchdog.complete, true, memory_order_release); + if (cbm_thread_join(&watchdog_thread) != 0) { + _Exit(EXIT_FAILURE); + } + } + if (!cbm_daemon_maintenance_monitor_stop(&maintenance_monitor)) { + _Exit(EXIT_FAILURE); + } if (close_begun) { - (void)cbm_daemon_runtime_client_close_finish( - state.client, FRONTEND_CLOSE_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close_finish(state.client, FRONTEND_CLOSE_TIMEOUT_MS); } else { result = -1; } diff --git a/src/daemon/frontend.h b/src/daemon/frontend.h index 86a4d6d28..d114c8f9c 100644 --- a/src/daemon/frontend.h +++ b/src/daemon/frontend.h @@ -10,8 +10,7 @@ #include #include -typedef struct cbm_daemon_maintenance_monitor - cbm_daemon_maintenance_monitor_t; +typedef struct cbm_daemon_maintenance_monitor cbm_daemon_maintenance_monitor_t; /* Called once when install/update/uninstall requests an active local command * to stop cooperatively. Returning false does not authorize the command to @@ -27,8 +26,8 @@ bool cbm_daemon_frontend_is_cancellation_notification(const char *message); * params.requestId has the same JSON type and value as the active request. * Empty, stale, and numeric-vs-string targets never authorize request * cancellation. */ -bool cbm_daemon_frontend_cancellation_matches_request( - const char *message, int64_t active_id, const char *active_id_str); +bool cbm_daemon_frontend_cancellation_matches_request(const char *message, int64_t active_id, + const char *active_id_str); /* Start a temporary observer for a one-shot local CLI command or physical * supervised worker. manager and cancel_context are borrowed until stop. On @@ -39,22 +38,19 @@ bool cbm_daemon_frontend_cancellation_matches_request( * result means process-level exit is the only safe alternative to freeing * memory still visible to the observer. */ cbm_daemon_maintenance_monitor_t *cbm_daemon_maintenance_monitor_start( - cbm_version_cohort_manager_t *manager, - cbm_daemon_maintenance_cancel_fn cancel, void *cancel_context, - int exit_code, const char *participant); -bool cbm_daemon_maintenance_monitor_stop( - cbm_daemon_maintenance_monitor_t **monitor_io); + cbm_version_cohort_manager_t *manager, cbm_daemon_maintenance_cancel_fn cancel, + void *cancel_context, int exit_code, const char *participant); +bool cbm_daemon_maintenance_monitor_stop(cbm_daemon_maintenance_monitor_t **monitor_io); /* Takes ownership of client and borrows cohort_manager for the complete call. * A dedicated reader keeps observing stdin while one joinable worker performs - * daemon requests. The existing worker also observes maintenance while idle, - * so install/update/uninstall terminates this stateless process even when the - * reader remains blocked in stdio. Kernel IPC close then cancels only this - * session's daemon work. EOF/parse failure closes the authenticated session. - * An unexpected daemon transport failure likewise terminates the process so - * an agent waiting with stdin still open observes server EOF promptly. */ -int cbm_daemon_frontend_mcp_run( - cbm_daemon_runtime_client_t *client, - cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out); + * daemon requests. An independent maintenance monitor remains runnable when + * either thread is blocked in stdio, requests cooperative cancellation for the + * exact active request, and then bounds process exit. Kernel IPC close cancels + * only this session's daemon work. EOF/parse failure closes the authenticated + * session. An unexpected daemon transport failure likewise terminates the + * process so an agent waiting with stdin still open observes server EOF. */ +int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client, + cbm_version_cohort_manager_t *cohort_manager, FILE *in, FILE *out); #endif /* CBM_DAEMON_FRONTEND_H */ diff --git a/src/daemon/host.c b/src/daemon/host.c index fb75898d5..7c96f518d 100644 --- a/src/daemon/host.c +++ b/src/daemon/host.c @@ -38,6 +38,9 @@ enum { HOST_COORDINATION_CLEANUP_MS = 500, HOST_INITIAL_CLIENT_TIMEOUT_MS = 10000, HOST_WAIT_TICK_MS = 100, + HOST_HTTP_CONFIG_POLL_MS = 1000, + HOST_HTTP_RETRY_INITIAL_MS = 1000, + HOST_HTTP_RETRY_MAX_MS = 30000, HOST_WATCH_INTERVAL_MS = 5000, HOST_CONFLICT_LOG_CAP = 1024 * 1024, HOST_OPERATION_LOG_CAP = 5 * 1024 * 1024, @@ -51,7 +54,21 @@ enum { #endif }; +typedef struct host_state host_state_t; + typedef struct { + void *context; + void (*config_load)(void *context, cbm_ui_config_t *config_out); + cbm_http_server_t *(*server_new)(void *context, int port); + void (*server_configure)(void *context, cbm_http_server_t *server, host_state_t *host); + void (*server_stop)(void *context, cbm_http_server_t *server); + void (*server_free)(void *context, cbm_http_server_t *server); + int (*thread_start)(void *context, cbm_thread_t *thread, void *(*entry)(void *), + void *entry_context); + int (*thread_join)(void *context, cbm_thread_t *thread); +} host_http_ops_t; + +struct host_state { cbm_daemon_application_t *application; cbm_watcher_t *watcher; cbm_store_t *watch_store; @@ -62,9 +79,16 @@ typedef struct { cbm_thread_t http_thread; bool watcher_started; bool http_started; + bool http_config_loaded; bool http_config_enabled; + bool http_assets_available; int http_config_port; -} host_state_t; + uint64_t http_next_config_load_ms; + uint64_t http_retry_at_ms; + uint32_t http_retry_delay_ms; + uint32_t http_largest_scheduled_retry_ms; + const host_http_ops_t *http_ops; +}; static FILE *g_host_log_file = NULL; static cbm_mutex_t g_host_log_mutex; @@ -93,13 +117,12 @@ static bool host_log_open(char conflict_log_out[HOST_PATH_CAP]) { if (written <= 0 || written >= (int)sizeof(logs)) { return false; } - written = snprintf(conflict_log_out, HOST_PATH_CAP, - "%s/daemon-conflicts.ndjson", logs); + written = snprintf(conflict_log_out, HOST_PATH_CAP, "%s/daemon-conflicts.ndjson", logs); if (written <= 0 || written >= HOST_PATH_CAP) { return false; } - g_host_log_file = cbm_daemon_ipc_private_log_open( - logs, "cbm-daemon.log", HOST_OPERATION_LOG_CAP); + g_host_log_file = + cbm_daemon_ipc_private_log_open(logs, "cbm-daemon.log", HOST_OPERATION_LOG_CAP); if (!g_host_log_file) { conflict_log_out[0] = '\0'; return false; @@ -132,6 +155,14 @@ static uint64_t host_deadline_after(uint32_t timeout_ms) { return now > UINT64_MAX - timeout_ms ? UINT64_MAX : now + timeout_ms; } +static uint64_t host_current_process_id(void) { +#ifdef _WIN32 + return (uint64_t)GetCurrentProcessId(); +#else + return (uint64_t)getpid(); +#endif +} + static _Noreturn void host_cleanup_force_terminate(const char *component) { /* Early startup failures can precede the ordinary operation-log setup. * Make one best-effort attempt to establish that same owner-only durable @@ -144,9 +175,8 @@ static _Noreturn void host_cleanup_force_terminate(const char *component) { host_force_terminate(component); } -static void host_cleanup_release_until_complete( - cbm_daemon_host_cleanup_release_for_test_fn release, void *context, - const char *component) { +static void host_cleanup_release_until_complete(cbm_daemon_host_cleanup_release_for_test_fn release, + void *context, const char *component) { if (!release) { return; } @@ -161,8 +191,7 @@ static void host_cleanup_release_until_complete( void cbm_daemon_host_cleanup_release_until_complete_for_test( cbm_daemon_host_cleanup_release_for_test_fn release, void *context) { - host_cleanup_release_until_complete(release, context, - "coordination_cleanup"); + host_cleanup_release_until_complete(release, context, "coordination_cleanup"); } static bool host_cohort_lease_release_once(void *context) { @@ -200,10 +229,9 @@ static bool host_daemon_claim_release_once(void *context) { return *claim == NULL; } -static void host_daemon_claim_close( - cbm_version_cohort_daemon_claim_t **claim) { - host_cleanup_release_until_complete(host_daemon_claim_release_once, - claim, "daemon_claim_cleanup"); +static void host_daemon_claim_close(cbm_version_cohort_daemon_claim_t **claim) { + host_cleanup_release_until_complete(host_daemon_claim_release_once, claim, + "daemon_claim_cleanup"); } static bool host_participant_guard_release_once(void *context) { @@ -215,10 +243,9 @@ static bool host_participant_guard_release_once(void *context) { return *guard == NULL; } -static void host_participant_guard_close( - cbm_daemon_ipc_participant_guard_t **guard) { - host_cleanup_release_until_complete(host_participant_guard_release_once, - guard, "participant_guard_cleanup"); +static void host_participant_guard_close(cbm_daemon_ipc_participant_guard_t **guard) { + host_cleanup_release_until_complete(host_participant_guard_release_once, guard, + "participant_guard_cleanup"); } static int host_lifetime_reservation_acquire( @@ -226,13 +253,11 @@ static int host_lifetime_reservation_acquire( cbm_daemon_ipc_lifetime_reservation_t **reservation_out) { #ifdef _WIN32 uint64_t now = cbm_now_ms(); - uint64_t deadline = - now > UINT64_MAX - HOST_WINDOWS_LIFETIME_ACQUIRE_MS - ? UINT64_MAX - : now + HOST_WINDOWS_LIFETIME_ACQUIRE_MS; + uint64_t deadline = now > UINT64_MAX - HOST_WINDOWS_LIFETIME_ACQUIRE_MS + ? UINT64_MAX + : now + HOST_WINDOWS_LIFETIME_ACQUIRE_MS; do { - int status = cbm_daemon_ipc_lifetime_reservation_try_acquire( - endpoint, reservation_out); + int status = cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, reservation_out); if (status != 0) { return status; } @@ -240,8 +265,7 @@ static int host_lifetime_reservation_acquire( } while (cbm_now_ms() < deadline); return 0; #else - return cbm_daemon_ipc_lifetime_reservation_try_acquire( - endpoint, reservation_out); + return cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, reservation_out); #endif } @@ -255,21 +279,17 @@ static void *host_http_thread(void *opaque) { return NULL; } -static int host_watcher_index(const char *project_name, const char *root_path, - void *opaque) { +static int host_watcher_index(const char *project_name, const char *root_path, void *opaque) { host_state_t *host = opaque; return host && host->application - ? cbm_daemon_application_watcher_index(project_name, root_path, - host->application) + ? cbm_daemon_application_watcher_index(project_name, root_path, host->application) : -1; } -static int host_ui_index(void *opaque, const char *root_path, - const char *project_name) { +static int host_ui_index(void *opaque, const char *root_path, const char *project_name) { host_state_t *host = opaque; return host && host->application - ? cbm_daemon_application_index(host->application, - project_name ? project_name : "", + ? cbm_daemon_application_index(host->application, project_name ? project_name : "", root_path) : -1; } @@ -277,8 +297,7 @@ static int host_ui_index(void *opaque, const char *root_path, static bool host_ui_mutation_begin(void *opaque, const char *project) { host_state_t *host = opaque; return host && host->application && - cbm_daemon_application_project_mutation_try_begin(host->application, - project); + cbm_daemon_application_project_mutation_try_begin(host->application, project); } static void host_ui_mutation_end(void *opaque, const char *project) { @@ -288,56 +307,148 @@ static void host_ui_mutation_end(void *opaque, const char *project) { } } +static void host_http_config_load_default(void *context, cbm_ui_config_t *config_out) { + (void)context; + cbm_ui_config_load(config_out); +} + +static cbm_http_server_t *host_http_server_new_default(void *context, int port) { + (void)context; + return cbm_http_server_new(port); +} + +static void host_http_server_configure_default(void *context, cbm_http_server_t *server, + host_state_t *host) { + (void)context; + cbm_http_server_set_watcher(server, host->watcher); + cbm_http_server_set_index_executor(server, host_ui_index, host); + cbm_http_server_set_project_mutation_guard(server, host_ui_mutation_begin, host_ui_mutation_end, + host); +} + +static void host_http_server_stop_default(void *context, cbm_http_server_t *server) { + (void)context; + cbm_http_server_stop(server); +} + +static void host_http_server_free_default(void *context, cbm_http_server_t *server) { + (void)context; + cbm_http_server_free(server); +} + +static int host_http_thread_start_default(void *context, cbm_thread_t *thread, + void *(*entry)(void *), void *entry_context) { + (void)context; + return cbm_thread_create(thread, 0, entry, entry_context); +} + +static int host_http_thread_join_default(void *context, cbm_thread_t *thread) { + (void)context; + return cbm_thread_join(thread); +} + +static const host_http_ops_t g_host_http_default_ops = { + .context = NULL, + .config_load = host_http_config_load_default, + .server_new = host_http_server_new_default, + .server_configure = host_http_server_configure_default, + .server_stop = host_http_server_stop_default, + .server_free = host_http_server_free_default, + .thread_start = host_http_thread_start_default, + .thread_join = host_http_thread_join_default, +}; + static void host_http_stop_join_free(host_state_t *host) { + const host_http_ops_t *ops = host->http_ops; if (host->http) { - cbm_http_server_stop(host->http); + ops->server_stop(ops->context, host->http); } if (host->http_started) { - (void)cbm_thread_join(&host->http_thread); + (void)ops->thread_join(ops->context, &host->http_thread); host->http_started = false; } - cbm_http_server_free(host->http); + ops->server_free(ops->context, host->http); host->http = NULL; } -static void host_http_reconcile(host_state_t *host) { - cbm_ui_config_t desired; - cbm_ui_config_load(&desired); - if (desired.ui_enabled == host->http_config_enabled && - desired.ui_port == host->http_config_port) { +static uint64_t host_deadline_from(uint64_t now_ms, uint32_t delay_ms) { + return now_ms > UINT64_MAX - delay_ms ? UINT64_MAX : now_ms + delay_ms; +} + +static void host_http_schedule_retry(host_state_t *host, uint64_t now_ms, const char *reason) { + uint32_t delay = + host->http_retry_delay_ms ? host->http_retry_delay_ms : HOST_HTTP_RETRY_INITIAL_MS; + host->http_retry_at_ms = host_deadline_from(now_ms, delay); + if (delay > host->http_largest_scheduled_retry_ms) { + host->http_largest_scheduled_retry_ms = delay; + } + host->http_retry_delay_ms = + delay >= HOST_HTTP_RETRY_MAX_MS / 2U ? HOST_HTTP_RETRY_MAX_MS : delay * 2U; + char delay_text[32]; + (void)snprintf(delay_text, sizeof(delay_text), "%u", delay); + cbm_log_warn("ui.retry_scheduled", "reason", reason, "delay_ms", delay_text); +} + +static void host_http_reconcile_at(host_state_t *host, uint64_t now_ms, bool force_config_load) { + if (!host || !host->http_ops) { return; } - host_http_stop_join_free(host); - host->http_config_enabled = desired.ui_enabled; - host->http_config_port = desired.ui_port; + if (!force_config_load && host->http_config_loaded && now_ms < host->http_next_config_load_ms) { + return; + } + host->http_next_config_load_ms = host_deadline_from(now_ms, HOST_HTTP_CONFIG_POLL_MS); + + cbm_ui_config_t desired; + host->http_ops->config_load(host->http_ops->context, &desired); + bool config_changed = !host->http_config_loaded || + desired.ui_enabled != host->http_config_enabled || + desired.ui_port != host->http_config_port; + if (config_changed) { + host_http_stop_join_free(host); + host->http_config_loaded = true; + host->http_config_enabled = desired.ui_enabled; + host->http_config_port = desired.ui_port; + host->http_retry_at_ms = now_ms; + host->http_retry_delay_ms = HOST_HTTP_RETRY_INITIAL_MS; + } if (!desired.ui_enabled) { return; } - if (CBM_EMBEDDED_FILE_COUNT == 0) { - cbm_log_warn("ui.no_assets", "hint", - "rebuild with: make -f Makefile.cbm cbm-with-ui"); + if (host->http_started || host->http) { return; } - host->http = cbm_http_server_new(desired.ui_port); + if (!host->http_assets_available) { + if (config_changed) { + cbm_log_warn("ui.no_assets", "hint", "rebuild with: make -f Makefile.cbm cbm-with-ui"); + } + host->http_retry_at_ms = UINT64_MAX; + return; + } + if (now_ms < host->http_retry_at_ms) { + return; + } + + const host_http_ops_t *ops = host->http_ops; + host->http = ops->server_new(ops->context, desired.ui_port); if (!host->http) { + host_http_schedule_retry(host, now_ms, "server_create"); return; } - cbm_http_server_set_watcher(host->http, host->watcher); - cbm_http_server_set_index_executor(host->http, host_ui_index, host); - cbm_http_server_set_project_mutation_guard( - host->http, host_ui_mutation_begin, host_ui_mutation_end, host); - if (cbm_thread_create(&host->http_thread, 0, host_http_thread, - host->http) == 0) { + ops->server_configure(ops->context, host->http, host); + if (ops->thread_start(ops->context, &host->http_thread, host_http_thread, host->http) == 0) { host->http_started = true; + host->http_retry_at_ms = 0; + host->http_retry_delay_ms = HOST_HTTP_RETRY_INITIAL_MS; } else { - cbm_http_server_free(host->http); + ops->server_free(ops->context, host->http); host->http = NULL; + host_http_schedule_retry(host, now_ms, "thread_start"); } } static void host_background_stop(host_state_t *host) { if (host->http) { - cbm_http_server_stop(host->http); + host->http_ops->server_stop(host->http_ops->context, host->http); } if (host->watcher) { cbm_watcher_stop(host->watcher); @@ -346,7 +457,7 @@ static void host_background_stop(host_state_t *host) { static void host_background_join(host_state_t *host) { if (host->http_started) { - (void)cbm_thread_join(&host->http_thread); + (void)host->http_ops->thread_join(host->http_ops->context, &host->http_thread); host->http_started = false; } if (host->watcher_started) { @@ -366,8 +477,15 @@ static bool host_project_lock_manager_free_once(void *context) { static void host_state_free(host_state_t *host) { host_http_stop_join_free(host); - cbm_daemon_application_free(host->application); - host->application = NULL; + if (host->application) { + if (!cbm_daemon_application_free(host->application)) { + /* The application still owns work or a borrowed callback context. + * Freeing any dependency below would turn the retained application + * into UAF. The daemon is the ultimate containment boundary. */ + host_cleanup_force_terminate("application_cleanup"); + } + host->application = NULL; + } cbm_watcher_free(host->watcher); host->watcher = NULL; cbm_store_close(host->watch_store); @@ -375,17 +493,25 @@ static void host_state_free(host_state_t *host) { cbm_config_close(host->runtime_config); host->runtime_config = NULL; if (host->project_locks) { - host_cleanup_release_until_complete( - host_project_lock_manager_free_once, &host->project_locks, - "project_lock_manager_cleanup"); + host_cleanup_release_until_complete(host_project_lock_manager_free_once, + &host->project_locks, "project_lock_manager_cleanup"); } } -static bool host_state_prepare(host_state_t *host, - const cbm_daemon_ipc_endpoint_t *endpoint) { +static bool host_state_prepare(host_state_t *host, const cbm_daemon_ipc_endpoint_t *endpoint) { + host->http_ops = &g_host_http_default_ops; + host->http_assets_available = CBM_EMBEDDED_FILE_COUNT > 0; size_t aggregate_memory_budget_bytes = cbm_mem_budget(); const char *cache = cbm_resolve_cache_dir(); - host->runtime_config = cache ? cbm_config_open(cache) : NULL; + if (!cache || !cache[0]) { + cbm_log_error("daemon.runtime_config_open_failed", "reason", "cache_dir_unavailable"); + return false; + } + host->runtime_config = cbm_config_open(cache); + if (!host->runtime_config) { + cbm_log_error("daemon.runtime_config_open_failed", "reason", "config_db_unavailable"); + return false; + } host->watch_store = cbm_store_open_memory(); host->project_locks = cbm_project_lock_manager_new(endpoint); host->watcher = cbm_watcher_new(host->watch_store, host_watcher_index, host); @@ -396,48 +522,172 @@ static bool host_state_prepare(host_state_t *host, .project_locks = host->project_locks, }; host->application = cbm_daemon_application_new(&application_config); - if (!host->watch_store || !host->watcher || !host->project_locks || - !host->application) { + if (!host->watch_store || !host->watcher || !host->project_locks || !host->application) { return false; } return true; } +bool cbm_daemon_host_state_prepare_for_test(const cbm_daemon_ipc_endpoint_t *endpoint) { + if (!endpoint) { + return false; + } + host_state_t host = {0}; + bool prepared = host_state_prepare(&host, endpoint); + host_state_free(&host); + return prepared; +} + +typedef struct { + size_t create_failures; + size_t thread_start_failures; + size_t config_loads; + size_t server_create_attempts; + size_t thread_start_attempts; + size_t server_stops; + size_t server_frees; + size_t thread_joins; +} host_http_reconcile_test_context_t; + +static void host_http_test_config_load(void *opaque, cbm_ui_config_t *config_out) { + host_http_reconcile_test_context_t *context = opaque; + context->config_loads++; + config_out->ui_enabled = true; + config_out->ui_port = CBM_UI_DEFAULT_PORT; +} + +static cbm_http_server_t *host_http_test_server_new(void *opaque, int port) { + (void)port; + host_http_reconcile_test_context_t *context = opaque; + context->server_create_attempts++; + return context->server_create_attempts <= context->create_failures + ? NULL + : (cbm_http_server_t *)context; +} + +static void host_http_test_server_configure(void *opaque, cbm_http_server_t *server, + host_state_t *host) { + (void)opaque; + (void)server; + (void)host; +} + +/* WHY: host_http_ops_t is also implemented by production callbacks that mutate + * the server; this test double must retain that callback ABI even though it + * only observes whether the opaque fake server is present. */ +// cppcheck-suppress constParameterCallback +static void host_http_test_server_stop(void *opaque, cbm_http_server_t *server) { + if (server) { + ((host_http_reconcile_test_context_t *)opaque)->server_stops++; + } +} + +/* WHY: See host_http_test_server_stop; changing only this implementation to a + * pointer-to-const parameter would make it incompatible with host_http_ops_t. */ +// cppcheck-suppress constParameterCallback +static void host_http_test_server_free(void *opaque, cbm_http_server_t *server) { + if (server) { + ((host_http_reconcile_test_context_t *)opaque)->server_frees++; + } +} + +static int host_http_test_thread_start(void *opaque, cbm_thread_t *thread, void *(*entry)(void *), + void *entry_context) { + (void)thread; + (void)entry; + (void)entry_context; + host_http_reconcile_test_context_t *context = opaque; + context->thread_start_attempts++; + return context->thread_start_attempts <= context->thread_start_failures ? -1 : 0; +} + +static int host_http_test_thread_join(void *opaque, cbm_thread_t *thread) { + (void)thread; + host_http_reconcile_test_context_t *context = opaque; + context->thread_joins++; + return 0; +} + +bool cbm_daemon_host_http_reconcile_sequence_for_test( + const uint64_t *timestamps_ms, size_t timestamp_count, size_t create_failures, + size_t thread_start_failures, cbm_daemon_host_http_reconcile_test_result_t *result_out) { + if (!timestamps_ms || timestamp_count == 0 || !result_out) { + return false; + } + for (size_t i = 1; i < timestamp_count; i++) { + if (timestamps_ms[i] < timestamps_ms[i - 1]) { + return false; + } + } + + host_http_reconcile_test_context_t context = { + .create_failures = create_failures, + .thread_start_failures = thread_start_failures, + }; + const host_http_ops_t ops = { + .context = &context, + .config_load = host_http_test_config_load, + .server_new = host_http_test_server_new, + .server_configure = host_http_test_server_configure, + .server_stop = host_http_test_server_stop, + .server_free = host_http_test_server_free, + .thread_start = host_http_test_thread_start, + .thread_join = host_http_test_thread_join, + }; + host_state_t host = { + .http_assets_available = true, + .http_ops = &ops, + }; + for (size_t i = 0; i < timestamp_count; i++) { + host_http_reconcile_at(&host, timestamps_ms[i], i == 0); + } + bool active = host.http_started; + uint32_t largest_retry = host.http_largest_scheduled_retry_ms; + uint64_t next_retry = host.http_retry_at_ms; + host_http_stop_join_free(&host); + + *result_out = (cbm_daemon_host_http_reconcile_test_result_t){ + .config_loads = context.config_loads, + .server_create_attempts = context.server_create_attempts, + .thread_start_attempts = context.thread_start_attempts, + .server_stops = context.server_stops, + .server_frees = context.server_frees, + .thread_joins = context.thread_joins, + .largest_scheduled_retry_ms = largest_retry, + .next_retry_ms = next_retry, + .active_after_sequence = active, + }; + return true; +} + static bool host_background_start(host_state_t *host) { - if (cbm_thread_create(&host->watcher_thread, 0, host_watcher_thread, - host->watcher) != 0) { + if (cbm_thread_create(&host->watcher_thread, 0, host_watcher_thread, host->watcher) != 0) { return false; } host->watcher_started = true; - /* Force the first reconciliation even when defaults are false/9749. */ - host->http_config_port = -1; - host_http_reconcile(host); + host_http_reconcile_at(host, cbm_now_ms(), true); return true; } static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service, - atomic_int *stop_requested, - host_state_t *host) { + atomic_int *stop_requested, host_state_t *host) { uint64_t initial_deadline = cbm_now_ms() + HOST_INITIAL_CLIENT_TIMEOUT_MS; bool admitted = false; uint64_t stopping_deadline = 0; for (;;) { - cbm_daemon_runtime_service_state_t state = - cbm_daemon_runtime_service_state(service); + cbm_daemon_runtime_service_state_t state = cbm_daemon_runtime_service_state(service); if (state == CBM_DAEMON_RUNTIME_SERVICE_EXITED) { return true; } if (state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING) { if (stopping_deadline == 0) { - stopping_deadline = - cbm_now_ms() + HOST_RUNTIME_SHUTDOWN_MS; + stopping_deadline = cbm_now_ms() + HOST_RUNTIME_SHUTDOWN_MS; } if (cbm_now_ms() >= stopping_deadline) { return false; } - (void)cbm_daemon_runtime_service_wait_exited( - service, HOST_WAIT_TICK_MS); + (void)cbm_daemon_runtime_service_wait_exited(service, HOST_WAIT_TICK_MS); continue; } if (cbm_daemon_runtime_service_active_clients(service) > 0) { @@ -445,18 +695,15 @@ static bool host_wait_for_lifetime(cbm_daemon_runtime_service_t *service, } bool stop = stop_requested && atomic_load(stop_requested); if (stop || (!admitted && cbm_now_ms() >= initial_deadline)) { - return cbm_daemon_runtime_service_stop( - service, HOST_RUNTIME_SHUTDOWN_MS); + return cbm_daemon_runtime_service_stop(service, HOST_RUNTIME_SHUTDOWN_MS); } - host_http_reconcile(host); - (void)cbm_daemon_runtime_service_wait_exited(service, - HOST_WAIT_TICK_MS); + host_http_reconcile_at(host, cbm_now_ms(), false); + (void)cbm_daemon_runtime_service_wait_exited(service, HOST_WAIT_TICK_MS); } } static bool host_application_shutdown(host_state_t *host) { - if (cbm_daemon_application_shutdown(host->application, - HOST_APPLICATION_SHUTDOWN_MS)) { + if (cbm_daemon_application_shutdown(host->application, HOST_APPLICATION_SHUTDOWN_MS)) { return true; } cbm_log_error("daemon.shutdown_timeout", "component", "operations"); @@ -464,10 +711,8 @@ static bool host_application_shutdown(host_state_t *host) { } static bool host_runtime_stop_free(cbm_daemon_runtime_service_t *service) { - if (cbm_daemon_runtime_service_state(service) != - CBM_DAEMON_RUNTIME_SERVICE_EXITED) { - if (!cbm_daemon_runtime_service_stop( - service, HOST_RUNTIME_SHUTDOWN_MS)) { + if (cbm_daemon_runtime_service_state(service) != CBM_DAEMON_RUNTIME_SERVICE_EXITED) { + if (!cbm_daemon_runtime_service_stop(service, HOST_RUNTIME_SHUTDOWN_MS)) { cbm_log_error("daemon.shutdown_timeout", "component", "runtime"); return false; } @@ -493,8 +738,7 @@ static _Noreturn void host_force_terminate(const char *component) { #endif } -_Noreturn void cbm_daemon_host_force_terminate_for_test( - const char *component) { +_Noreturn void cbm_daemon_host_force_terminate_for_test(const char *component) { char conflict_log[HOST_PATH_CAP]; cbm_ui_log_init(); if (host_log_open(conflict_log)) { @@ -510,58 +754,49 @@ _Noreturn void cbm_daemon_host_force_terminate_for_test( int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { if (!config || !config->endpoint || !config->executable_path || - !config->identity.semantic_version || - !config->identity.build_fingerprint) { + !config->identity.semantic_version || !config->identity.build_fingerprint) { return -1; } - cbm_version_cohort_manager_t *cohort_manager = - cbm_version_cohort_manager_new(config->endpoint); + cbm_version_cohort_manager_t *cohort_manager = cbm_version_cohort_manager_new(config->endpoint); cbm_version_cohort_lease_t *cohort_lease = NULL; cbm_daemon_conflict_t cohort_conflict; uint64_t now_ms = cbm_now_ms(); - uint64_t cohort_deadline = - now_ms > UINT64_MAX - HOST_INITIAL_CLIENT_TIMEOUT_MS - ? UINT64_MAX - : now_ms + HOST_INITIAL_CLIENT_TIMEOUT_MS; + uint64_t cohort_deadline = now_ms > UINT64_MAX - HOST_INITIAL_CLIENT_TIMEOUT_MS + ? UINT64_MAX + : now_ms + HOST_INITIAL_CLIENT_TIMEOUT_MS; cbm_version_cohort_status_t cohort_status = cohort_manager - ? cbm_version_cohort_acquire( - cohort_manager, &config->identity, cohort_deadline, - &cohort_lease, &cohort_conflict) + ? cbm_version_cohort_acquire(cohort_manager, &config->identity, cohort_deadline, + &cohort_lease, &cohort_conflict) : CBM_VERSION_COHORT_IO; if (cohort_status != CBM_VERSION_COHORT_OK) { char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; bool formatted = cohort_status == CBM_VERSION_COHORT_CONFLICT && - cbm_daemon_conflict_format( - &cohort_conflict, message, sizeof(message)); + cbm_daemon_conflict_format(&cohort_conflict, message, sizeof(message)); if (cohort_status == CBM_VERSION_COHORT_CONFLICT) { (void)cbm_version_cohort_log_conflict(&cohort_conflict); } (void)fprintf(stderr, "codebase-memory: %s\n", - formatted ? message - : "daemon exact-build admission failed"); + formatted ? message : "daemon exact-build admission failed"); host_cohort_close(&cohort_lease, &cohort_manager); return -1; } cbm_daemon_ipc_participant_guard_t *participant_guard = NULL; - if (cbm_daemon_ipc_participant_guard_try_join( - config->endpoint, &participant_guard) != 1) { - (void)fprintf(stderr, - "codebase-memory: daemon participant admission failed\n"); + if (cbm_daemon_ipc_participant_guard_try_join(config->endpoint, &participant_guard) != 1) { + (void)fprintf(stderr, "codebase-memory: daemon participant admission failed\n"); host_participant_guard_close(&participant_guard); host_cohort_close(&cohort_lease, &cohort_manager); return -1; } cbm_daemon_ipc_lifetime_reservation_t *lifetime_reservation = NULL; - if (host_lifetime_reservation_acquire( - config->endpoint, &lifetime_reservation) != 1) { + if (host_lifetime_reservation_acquire(config->endpoint, &lifetime_reservation) != 1) { host_participant_guard_close(&participant_guard); host_cohort_close(&cohort_lease, &cohort_manager); return -1; } cbm_version_cohort_daemon_claim_t *daemon_claim = NULL; - if (cbm_version_cohort_daemon_claim_acquire( - cohort_manager, &daemon_claim) != CBM_VERSION_COHORT_OK) { + if (cbm_version_cohort_daemon_claim_acquire(cohort_manager, &daemon_claim) != + CBM_VERSION_COHORT_OK) { cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation); host_daemon_claim_close(&daemon_claim); host_participant_guard_close(&participant_guard); @@ -572,8 +807,7 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { cbm_ui_log_init(); char conflict_log[HOST_PATH_CAP]; if (!host_log_open(conflict_log)) { - (void)fprintf(stderr, - "codebase-memory: daemon log path is not private or safe\n"); + (void)fprintf(stderr, "codebase-memory: daemon log path is not private or safe\n"); cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation); host_daemon_claim_close(&daemon_claim); host_participant_guard_close(&participant_guard); @@ -608,8 +842,7 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { .application = callbacks, }; cbm_daemon_runtime_service_t *service = - cbm_daemon_runtime_service_start_reserved( - &runtime_config, &lifetime_reservation); + cbm_daemon_runtime_service_start_reserved(&runtime_config, &lifetime_reservation); cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation); lifetime_reservation = NULL; if (!service) { @@ -640,7 +873,23 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { } cbm_diag_start(); - cbm_log_info("daemon.start", "version", config->identity.semantic_version); + char process_id[32]; + char memory_budget[32]; + char physical_job_limit[32]; + char worker_memory_budget[32]; + (void)snprintf(process_id, sizeof(process_id), "%llu", + (unsigned long long)host_current_process_id()); + (void)snprintf(memory_budget, sizeof(memory_budget), "%zu", cbm_mem_budget()); + (void)snprintf(physical_job_limit, sizeof(physical_job_limit), "%zu", + cbm_daemon_application_physical_job_limit(host.application)); + (void)snprintf(worker_memory_budget, sizeof(worker_memory_budget), "%zu", + cbm_daemon_application_worker_memory_budget_bytes(host.application)); + cbm_log_info("daemon.start", "version", config->identity.semantic_version, "pid", process_id, + "cache_fingerprint", + config->identity.cache_fingerprint ? config->identity.cache_fingerprint + : "unavailable", + "memory_budget_bytes", memory_budget, "physical_job_limit", physical_job_limit, + "worker_memory_budget_bytes", worker_memory_budget); if (!host_wait_for_lifetime(service, config->stop_requested, &host)) { host_force_terminate("runtime"); } diff --git a/src/daemon/host_internal.h b/src/daemon/host_internal.h index 24570af47..dfe4cb9ff 100644 --- a/src/daemon/host_internal.h +++ b/src/daemon/host_internal.h @@ -5,6 +5,10 @@ #define CBM_DAEMON_HOST_INTERNAL_H #include +#include +#include + +struct cbm_daemon_ipc_endpoint; typedef bool (*cbm_daemon_host_cleanup_release_for_test_fn)(void *context); @@ -17,7 +21,29 @@ void cbm_daemon_host_cleanup_release_until_complete_for_test( /* Opens the ordinary private daemon operation log, records the same forced * shutdown event used by production, flushes it, and terminates the process. * This exists only so an isolated child can verify the hard escape path. */ -_Noreturn void cbm_daemon_host_force_terminate_for_test( - const char *component); +_Noreturn void cbm_daemon_host_force_terminate_for_test(const char *component); + +/* Focused host-state probe. It performs no listener publication and starts no + * background thread; the production preparation and cleanup paths are used + * verbatim so tests can make the runtime config database unavailable. */ +bool cbm_daemon_host_state_prepare_for_test(const struct cbm_daemon_ipc_endpoint *endpoint); + +typedef struct { + size_t config_loads; + size_t server_create_attempts; + size_t thread_start_attempts; + size_t server_stops; + size_t server_frees; + size_t thread_joins; + uint32_t largest_scheduled_retry_ms; + uint64_t next_retry_ms; + bool active_after_sequence; +} cbm_daemon_host_http_reconcile_test_result_t; + +/* Drive the production HTTP reconciliation state machine at explicit + * monotonic timestamps with deterministic transient failures. */ +bool cbm_daemon_host_http_reconcile_sequence_for_test( + const uint64_t *timestamps_ms, size_t timestamp_count, size_t create_failures, + size_t thread_start_failures, cbm_daemon_host_http_reconcile_test_result_t *result_out); #endif /* CBM_DAEMON_HOST_INTERNAL_H */ diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index 46f4d064f..a0cbe6e9d 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -75,8 +75,7 @@ static char *string_format(const char *format, ...) { return result; } -static cbm_daemon_ipc_posix_publication_hook_fn - g_posix_publication_hook_for_test; +static cbm_daemon_ipc_posix_publication_hook_fn g_posix_publication_hook_for_test; static void *g_posix_publication_hook_context_for_test; static atomic_uint g_windows_legacy_guard_release_failures_for_test; @@ -86,41 +85,33 @@ void cbm_daemon_ipc_posix_publication_hook_set_for_test( g_posix_publication_hook_for_test = hook; } -void cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test( - unsigned int count) { - atomic_store_explicit( - &g_windows_legacy_guard_release_failures_for_test, count, - memory_order_release); +void cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test(unsigned int count) { + atomic_store_explicit(&g_windows_legacy_guard_release_failures_for_test, count, + memory_order_release); } -bool cbm_daemon_ipc_windows_legacy_names( - const char *canonical_runtime_parent, const char *instance_key, - char pipe_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP], - char startup_mutex_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]) { +bool cbm_daemon_ipc_windows_legacy_names(const char *canonical_runtime_parent, + const char *instance_key, + char pipe_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP], + char startup_mutex_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]) { if (!canonical_runtime_parent || !canonical_runtime_parent[0] || - !instance_key_valid(instance_key) || !pipe_out || - !startup_mutex_out) { + !instance_key_valid(instance_key) || !pipe_out || !startup_mutex_out) { return false; } uint64_t hash = UINT64_C(14695981039346656037); - const unsigned char *cursor = - (const unsigned char *)canonical_runtime_parent; + const unsigned char *cursor = (const unsigned char *)canonical_runtime_parent; while (*cursor) { hash ^= *cursor++; hash *= UINT64_C(1099511628211); } - int pipe_length = snprintf( - pipe_out, CBM_DAEMON_IPC_WINDOWS_NAME_CAP, - "\\\\.\\pipe\\cbm-daemon-%016llx-%s", (unsigned long long)hash, - instance_key); - int mutex_length = snprintf( - startup_mutex_out, CBM_DAEMON_IPC_WINDOWS_NAME_CAP, - "Local\\cbm-daemon-%016llx-%s-startup", (unsigned long long)hash, - instance_key); - return pipe_length > 0 && - (size_t)pipe_length < CBM_DAEMON_IPC_WINDOWS_NAME_CAP && - mutex_length > 0 && - (size_t)mutex_length < CBM_DAEMON_IPC_WINDOWS_NAME_CAP; + int pipe_length = + snprintf(pipe_out, CBM_DAEMON_IPC_WINDOWS_NAME_CAP, "\\\\.\\pipe\\cbm-daemon-%016llx-%s", + (unsigned long long)hash, instance_key); + int mutex_length = + snprintf(startup_mutex_out, CBM_DAEMON_IPC_WINDOWS_NAME_CAP, + "Local\\cbm-daemon-%016llx-%s-startup", (unsigned long long)hash, instance_key); + return pipe_length > 0 && (size_t)pipe_length < CBM_DAEMON_IPC_WINDOWS_NAME_CAP && + mutex_length > 0 && (size_t)mutex_length < CBM_DAEMON_IPC_WINDOWS_NAME_CAP; } static bool windows_sid_valid(const uint8_t *sid, size_t sid_length) { @@ -131,9 +122,7 @@ static bool windows_sid_valid(const uint8_t *sid, size_t sid_length) { }; return sid && sid_length >= WINDOWS_SID_HEADER_SIZE && sid[0] == 1 && sid[1] <= WINDOWS_SID_SUBAUTHORITY_MAX && - sid_length == - WINDOWS_SID_HEADER_SIZE + - (size_t)sid[1] * WINDOWS_SID_SUBAUTHORITY_SIZE; + sid_length == WINDOWS_SID_HEADER_SIZE + (size_t)sid[1] * WINDOWS_SID_SUBAUTHORITY_SIZE; } static bool windows_pipe_address_valid(const char *address) { @@ -144,8 +133,7 @@ static bool windows_pipe_address_valid(const char *address) { const char *digest = address + sizeof(prefix) - 1U; for (size_t index = 0; index < CBM_SHA256_HEX_LEN; index++) { char character = digest[index]; - if (!((character >= '0' && character <= '9') || - (character >= 'a' && character <= 'f'))) { + if (!((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'))) { return false; } } @@ -157,8 +145,8 @@ bool cbm_daemon_ipc_windows_generation_address( const uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE], char address_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]) { static const uint8_t domain[] = "cbm-daemon-win-pipe-v1"; - if (!windows_sid_valid(sid, sid_length) || - !instance_key_valid(instance_key) || !nonce || !address_out) { + if (!windows_sid_valid(sid, sid_length) || !instance_key_valid(instance_key) || !nonce || + !address_out) { return false; } cbm_sha256_ctx context; @@ -166,8 +154,7 @@ bool cbm_daemon_ipc_windows_generation_address( cbm_sha256_update(&context, domain, sizeof(domain) - 1U); cbm_sha256_update(&context, sid, sid_length); cbm_sha256_update(&context, instance_key, 16U); - cbm_sha256_update(&context, nonce, - CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE); + cbm_sha256_update(&context, nonce, CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE); uint8_t digest[CBM_SHA256_DIGEST_LEN]; cbm_sha256_final(&context, digest); @@ -180,13 +167,11 @@ bool cbm_daemon_ipc_windows_generation_address( digest_hex[CBM_SHA256_HEX_LEN] = '\0'; int written = snprintf(address_out, CBM_DAEMON_IPC_WINDOWS_NAME_CAP, "\\\\.\\pipe\\cbm-daemon-%s", digest_hex); - return written > 0 && - (size_t)written < CBM_DAEMON_IPC_WINDOWS_NAME_CAP; + return written > 0 && (size_t)written < CBM_DAEMON_IPC_WINDOWS_NAME_CAP; } bool cbm_daemon_ipc_windows_rendezvous_record_encode( - const uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE], - const char *address, + const uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE], const char *address, uint8_t record_out[CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE]) { static const uint8_t magic[8] = {'C', 'B', 'M', 'R', 'D', 'V', '1', 0}; if (!nonce || !windows_pipe_address_valid(address) || !record_out) { @@ -194,11 +179,10 @@ bool cbm_daemon_ipc_windows_rendezvous_record_encode( } memset(record_out, 0, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE); memcpy(record_out, magic, sizeof(magic)); - memcpy(record_out + sizeof(magic), nonce, - CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE); + memcpy(record_out + sizeof(magic), nonce, CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE); size_t address_length = strlen(address); - memcpy(record_out + sizeof(magic) + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE, - address, address_length + 1U); + memcpy(record_out + sizeof(magic) + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE, address, + address_length + 1U); return true; } @@ -207,15 +191,12 @@ bool cbm_daemon_ipc_windows_rendezvous_record_decode( uint8_t nonce_out[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE], char address_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]) { static const uint8_t magic[8] = {'C', 'B', 'M', 'R', 'D', 'V', '1', 0}; - if (!record || record_length != - CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE || - !nonce_out || !address_out || memcmp(record, magic, sizeof(magic)) != 0) { + if (!record || record_length != CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE || !nonce_out || + !address_out || memcmp(record, magic, sizeof(magic)) != 0) { return false; } - const uint8_t *encoded_address = - record + sizeof(magic) + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE; - const uint8_t *terminator = - memchr(encoded_address, 0, CBM_DAEMON_IPC_WINDOWS_NAME_CAP); + const uint8_t *encoded_address = record + sizeof(magic) + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE; + const uint8_t *terminator = memchr(encoded_address, 0, CBM_DAEMON_IPC_WINDOWS_NAME_CAP); if (!terminator) { return false; } @@ -223,8 +204,7 @@ bool cbm_daemon_ipc_windows_rendezvous_record_decode( if (address_length + 1U > CBM_DAEMON_IPC_WINDOWS_NAME_CAP) { return false; } - for (size_t index = address_length + 1U; - index < CBM_DAEMON_IPC_WINDOWS_NAME_CAP; index++) { + for (size_t index = address_length + 1U; index < CBM_DAEMON_IPC_WINDOWS_NAME_CAP; index++) { if (encoded_address[index] != 0) { return false; } @@ -234,8 +214,7 @@ bool cbm_daemon_ipc_windows_rendezvous_record_decode( memset(address_out, 0, CBM_DAEMON_IPC_WINDOWS_NAME_CAP); return false; } - memcpy(nonce_out, record + sizeof(magic), - CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE); + memcpy(nonce_out, record + sizeof(magic), CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE); return true; } @@ -297,28 +276,20 @@ int cbm_daemon_ipc_wait_pending(const cbm_ipc_pending_ops_t *ops, uint32_t timeo enum { POSIX_SOCKET_RECORD_MAGIC_SIZE = 8, POSIX_SOCKET_IDENTITY_DEVICE_OFFSET = POSIX_SOCKET_RECORD_MAGIC_SIZE, - POSIX_SOCKET_IDENTITY_INODE_OFFSET = - POSIX_SOCKET_IDENTITY_DEVICE_OFFSET + 8, - POSIX_SOCKET_IDENTITY_CTIME_SECONDS_OFFSET = - POSIX_SOCKET_IDENTITY_INODE_OFFSET + 8, - POSIX_SOCKET_IDENTITY_CTIME_NANOSECONDS_OFFSET = - POSIX_SOCKET_IDENTITY_CTIME_SECONDS_OFFSET + 8, - POSIX_SOCKET_IDENTITY_RESERVED_OFFSET = - POSIX_SOCKET_IDENTITY_CTIME_NANOSECONDS_OFFSET + 4, - POSIX_SOCKET_ANCHOR_NAME_OFFSET = - POSIX_SOCKET_IDENTITY_RESERVED_OFFSET + 4, + POSIX_SOCKET_IDENTITY_INODE_OFFSET = POSIX_SOCKET_IDENTITY_DEVICE_OFFSET + 8, + POSIX_SOCKET_IDENTITY_CTIME_SECONDS_OFFSET = POSIX_SOCKET_IDENTITY_INODE_OFFSET + 8, + POSIX_SOCKET_IDENTITY_CTIME_NANOSECONDS_OFFSET = POSIX_SOCKET_IDENTITY_CTIME_SECONDS_OFFSET + 8, + POSIX_SOCKET_IDENTITY_RESERVED_OFFSET = POSIX_SOCKET_IDENTITY_CTIME_NANOSECONDS_OFFSET + 4, + POSIX_SOCKET_ANCHOR_NAME_OFFSET = POSIX_SOCKET_IDENTITY_RESERVED_OFFSET + 4, POSIX_SOCKET_ANCHOR_NAME_CAP = 64, - POSIX_SOCKET_RECORD_SIZE = - POSIX_SOCKET_ANCHOR_NAME_OFFSET + POSIX_SOCKET_ANCHOR_NAME_CAP, + POSIX_SOCKET_RECORD_SIZE = POSIX_SOCKET_ANCHOR_NAME_OFFSET + POSIX_SOCKET_ANCHOR_NAME_CAP, }; -static const uint8_t POSIX_SOCKET_MARKER_MAGIC[ - POSIX_SOCKET_RECORD_MAGIC_SIZE] = { +static const uint8_t POSIX_SOCKET_MARKER_MAGIC[POSIX_SOCKET_RECORD_MAGIC_SIZE] = { 'C', 'B', 'M', 'S', 'O', 'C', 2, 0, }; -static const uint8_t POSIX_SOCKET_PENDING_MAGIC[ - POSIX_SOCKET_RECORD_MAGIC_SIZE] = { +static const uint8_t POSIX_SOCKET_PENDING_MAGIC[POSIX_SOCKET_RECORD_MAGIC_SIZE] = { 'C', 'B', 'M', 'P', 'E', 'N', 1, 0, }; @@ -334,10 +305,8 @@ typedef struct { char anchor_name[POSIX_SOCKET_ANCHOR_NAME_CAP]; } posix_socket_record_t; -static void posix_publication_stage_reached( - cbm_daemon_ipc_posix_publication_stage_t stage) { - cbm_daemon_ipc_posix_publication_hook_fn hook = - g_posix_publication_hook_for_test; +static void posix_publication_stage_reached(cbm_daemon_ipc_posix_publication_stage_t stage) { + cbm_daemon_ipc_posix_publication_hook_fn hook = g_posix_publication_hook_for_test; if (hook) { hook(stage, g_posix_publication_hook_context_for_test); } @@ -346,16 +315,12 @@ static void posix_publication_stage_reached( static void posix_record_publication_stage_reached( const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], bool linked) { cbm_daemon_ipc_posix_publication_stage_t stage; - if (memcmp(magic, POSIX_SOCKET_PENDING_MAGIC, - POSIX_SOCKET_RECORD_MAGIC_SIZE) == 0) { - stage = linked - ? CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_RECORD_LINKED - : CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_TEMP_SYNCED; - } else if (memcmp(magic, POSIX_SOCKET_MARKER_MAGIC, - POSIX_SOCKET_RECORD_MAGIC_SIZE) == 0) { - stage = linked - ? CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_RECORD_LINKED - : CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_TEMP_SYNCED; + if (memcmp(magic, POSIX_SOCKET_PENDING_MAGIC, POSIX_SOCKET_RECORD_MAGIC_SIZE) == 0) { + stage = linked ? CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_RECORD_LINKED + : CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_TEMP_SYNCED; + } else if (memcmp(magic, POSIX_SOCKET_MARKER_MAGIC, POSIX_SOCKET_RECORD_MAGIC_SIZE) == 0) { + stage = linked ? CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_RECORD_LINKED + : CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_TEMP_SYNCED; } else { return; } @@ -446,12 +411,10 @@ struct cbm_daemon_ipc_participant_guard { static pthread_mutex_t process_locks_mutex = PTHREAD_MUTEX_INITIALIZER; static process_lock_entry_t *process_locks; -static bool posix_socket_status_secure_transport( - const struct stat *status); +static bool posix_socket_status_secure_transport(const struct stat *status); -static int process_lock_claim_mode( - const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, - bool shared, process_lock_entry_t **entry_out) { +static int process_lock_claim_mode(const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, + bool shared, process_lock_entry_t **entry_out) { *entry_out = NULL; if (!endpoint || !lock_name || pthread_mutex_lock(&process_locks_mutex) != 0) { return -1; @@ -459,8 +422,7 @@ static int process_lock_claim_mode( for (process_lock_entry_t *entry = process_locks; entry; entry = entry->next) { if (entry->directory_device == endpoint->dir_device && entry->directory_inode == endpoint->dir_inode && - strcmp(entry->lock_name, lock_name) == 0 && - (!shared || !entry->shared)) { + strcmp(entry->lock_name, lock_name) == 0 && (!shared || !entry->shared)) { (void)pthread_mutex_unlock(&process_locks_mutex); return 0; } @@ -484,8 +446,7 @@ static int process_lock_claim_mode( return 1; } -static int process_lock_claim(const cbm_daemon_ipc_endpoint_t *endpoint, - const char *lock_name, +static int process_lock_claim(const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, process_lock_entry_t **entry_out) { return process_lock_claim_mode(endpoint, lock_name, false, entry_out); } @@ -539,19 +500,16 @@ static uint64_t ipc_deadline_after(uint32_t timeout_ms) { return ipc_now_ms() + (uint64_t)timeout_ms; } -static _Noreturn void ipc_coordination_cleanup_fail_stop( - const char *component) { - cbm_log_error("daemon.forced_shutdown", "component", component, - "action", "coordination_cleanup"); +static _Noreturn void ipc_coordination_cleanup_fail_stop(const char *component) { + cbm_log_error("daemon.forced_shutdown", "component", component, "action", + "coordination_cleanup"); (void)fflush(stdout); (void)fflush(stderr); _exit(EXIT_FAILURE); } -static void ipc_startup_lock_release_complete( - cbm_daemon_ipc_startup_lock_t **lock_io) { - uint64_t deadline = - ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); +static void ipc_startup_lock_release_complete(cbm_daemon_ipc_startup_lock_t **lock_io) { + uint64_t deadline = ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); while (lock_io && *lock_io) { (void)cbm_daemon_ipc_startup_lock_release(lock_io); if (!*lock_io) { @@ -564,10 +522,8 @@ static void ipc_startup_lock_release_complete( } } -static void ipc_participant_guard_release_complete( - cbm_daemon_ipc_participant_guard_t **guard_io) { - uint64_t deadline = - ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); +static void ipc_participant_guard_release_complete(cbm_daemon_ipc_participant_guard_t **guard_io) { + uint64_t deadline = ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); while (guard_io && *guard_io) { (void)cbm_daemon_ipc_participant_guard_release(guard_io); if (!*guard_io) { @@ -669,65 +625,45 @@ static bool runtime_parent_valid(const char *path) { return path && path[0] != '\0' && lstat(path, &status) == 0 && S_ISDIR(status.st_mode); } -static bool private_runtime_snapshot(int fd, const char *path, - bool require_empty_acl, +static bool private_runtime_snapshot(int fd, const char *path, bool require_empty_acl, struct stat *status_out) { struct stat by_handle; struct stat by_path; - bool valid = fd >= 0 && path && path[0] != '\0' && - fstat(fd, &by_handle) == 0 && lstat(path, &by_path) == 0 && - S_ISDIR(by_handle.st_mode) && S_ISDIR(by_path.st_mode) && - by_handle.st_uid == geteuid() && by_path.st_uid == geteuid() && - (by_handle.st_mode & 07777) == 0700 && - (by_path.st_mode & 07777) == 0700 && - by_handle.st_dev == by_path.st_dev && + bool valid = fd >= 0 && path && path[0] != '\0' && fstat(fd, &by_handle) == 0 && + lstat(path, &by_path) == 0 && S_ISDIR(by_handle.st_mode) && + S_ISDIR(by_path.st_mode) && by_handle.st_uid == geteuid() && + by_path.st_uid == geteuid() && (by_handle.st_mode & 07777) == 0700 && + (by_path.st_mode & 07777) == 0700 && by_handle.st_dev == by_path.st_dev && by_handle.st_ino == by_path.st_ino && - (!require_empty_acl || - cbm_macos_extended_acl_fd_is_empty(fd)); + (!require_empty_acl || cbm_macos_extended_acl_fd_is_empty(fd)); if (valid && status_out) { *status_out = by_handle; } return valid; } +static int private_directory_tree_open(const char *directory_path); + static bool private_runtime_open(const char *path, int *fd_out, dev_t *device_out, ino_t *inode_out) { if (!path || !fd_out || !device_out || !inode_out) { return false; } - bool created = false; - if (mkdir(path, 0700) == 0) { - created = true; - } else if (errno != EEXIST) { - return false; - } - - int fd = open(path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); - if (fd < 0 || !fd_set_cloexec(fd)) { - if (fd >= 0) { - (void)close(fd); - } - return false; - } - if (created && fchmod(fd, 0700) != 0) { - (void)close(fd); + int fd = private_directory_tree_open(path); + if (fd < 0) { return false; } struct stat before; - struct stat after; - bool secured = private_runtime_snapshot(fd, path, false, &before) && - cbm_macos_extended_acl_fd_clear(fd) && - private_runtime_snapshot(fd, path, true, &after) && - before.st_dev == after.st_dev && before.st_ino == after.st_ino; + bool secured = private_runtime_snapshot(fd, path, true, &before); if (!secured) { (void)close(fd); return false; } *fd_out = fd; - *device_out = after.st_dev; - *inode_out = after.st_ino; + *device_out = before.st_dev; + *inode_out = before.st_ino; return true; } @@ -736,30 +672,22 @@ static bool endpoint_runtime_still_valid(const cbm_daemon_ipc_endpoint_t *endpoi return false; } struct stat current; - return private_runtime_snapshot(endpoint->dir_fd, endpoint->runtime_dir, - true, ¤t) && - current.st_dev == endpoint->dir_device && - current.st_ino == endpoint->dir_inode; + return private_runtime_snapshot(endpoint->dir_fd, endpoint->runtime_dir, true, ¤t) && + current.st_dev == endpoint->dir_device && current.st_ino == endpoint->dir_inode; } -static bool private_regular_file_snapshot(int directory_fd, - const char *base_name, int fd, - nlink_t expected_links, - struct stat *status_out) { +static bool private_regular_file_snapshot(int directory_fd, const char *base_name, int fd, + nlink_t expected_links, struct stat *status_out) { struct stat by_handle; struct stat by_path; - bool valid = directory_fd >= 0 && base_name && base_name[0] != '\0' && - fd >= 0 && fstat(fd, &by_handle) == 0 && - fstatat(directory_fd, base_name, &by_path, - AT_SYMLINK_NOFOLLOW) == 0 && + bool valid = directory_fd >= 0 && base_name && base_name[0] != '\0' && fd >= 0 && + fstat(fd, &by_handle) == 0 && + fstatat(directory_fd, base_name, &by_path, AT_SYMLINK_NOFOLLOW) == 0 && S_ISREG(by_handle.st_mode) && S_ISREG(by_path.st_mode) && by_handle.st_uid == geteuid() && by_path.st_uid == geteuid() && - by_handle.st_nlink == expected_links && - by_path.st_nlink == expected_links && - (by_handle.st_mode & 07777) == 0600 && - (by_path.st_mode & 07777) == 0600 && - by_handle.st_dev == by_path.st_dev && - by_handle.st_ino == by_path.st_ino && + by_handle.st_nlink == expected_links && by_path.st_nlink == expected_links && + (by_handle.st_mode & 07777) == 0600 && (by_path.st_mode & 07777) == 0600 && + by_handle.st_dev == by_path.st_dev && by_handle.st_ino == by_path.st_ino && cbm_macos_extended_acl_fd_is_empty(directory_fd) && cbm_macos_extended_acl_fd_is_empty(fd); if (valid && status_out) { @@ -768,14 +696,11 @@ static bool private_regular_file_snapshot(int directory_fd, return valid; } -static bool private_regular_file_at_is_safe(int directory_fd, - const char *base_name, +static bool private_regular_file_at_is_safe(int directory_fd, const char *base_name, nlink_t expected_links) { - int fd = openat(directory_fd, base_name, - O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + int fd = openat(directory_fd, base_name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); bool safe = fd >= 0 && fd_set_cloexec(fd) && - private_regular_file_snapshot(directory_fd, base_name, fd, - expected_links, NULL); + private_regular_file_snapshot(directory_fd, base_name, fd, expected_links, NULL); if (fd >= 0 && close(fd) != 0) { safe = false; } @@ -915,17 +840,13 @@ cbm_daemon_ipc_endpoint_t *cbm_daemon_ipc_endpoint_new(const char *instance_key, "cbm-daemon-", (unsigned long)geteuid()); endpoint->socket_name = string_format("cbm-%s.sock", instance_key); endpoint->socket_anchor_name = string_format("cbm-%s.anc", instance_key); - endpoint->socket_identity_name = - string_format("cbm-%s.sock.identity", instance_key); - endpoint->socket_pending_name = - string_format("cbm-%s.sock.pending", instance_key); + endpoint->socket_identity_name = string_format("cbm-%s.sock.identity", instance_key); + endpoint->socket_pending_name = string_format("cbm-%s.sock.pending", instance_key); endpoint->lock_name = string_format("cbm-%s.lock", instance_key); - endpoint->startup_v2_lock_name = - string_format("cbm-%s.startup-v2.lock", instance_key); + endpoint->startup_v2_lock_name = string_format("cbm-%s.startup-v2.lock", instance_key); endpoint->lifetime_lock_name = string_format("cbm-%s.lifetime.lock", instance_key); - if (!endpoint->runtime_dir || !endpoint->socket_name || - !endpoint->socket_anchor_name || !endpoint->socket_identity_name || - !endpoint->socket_pending_name || !endpoint->lock_name || + if (!endpoint->runtime_dir || !endpoint->socket_name || !endpoint->socket_anchor_name || + !endpoint->socket_identity_name || !endpoint->socket_pending_name || !endpoint->lock_name || !endpoint->startup_v2_lock_name || !endpoint->lifetime_lock_name || !private_runtime_open(endpoint->runtime_dir, &endpoint->dir_fd, &endpoint->dir_device, &endpoint->dir_inode)) { @@ -934,16 +855,13 @@ cbm_daemon_ipc_endpoint_t *cbm_daemon_ipc_endpoint_new(const char *instance_key, } endpoint->address = string_format("%s/%s", endpoint->runtime_dir, endpoint->socket_name); endpoint->socket_anchor_address = - string_format("%s/%s", endpoint->runtime_dir, - endpoint->socket_anchor_name); + string_format("%s/%s", endpoint->runtime_dir, endpoint->socket_anchor_name); struct sockaddr_un address; socklen_t address_length; if (!endpoint->address || !endpoint->socket_anchor_address || - strlen(endpoint->socket_anchor_name) >= - POSIX_SOCKET_ANCHOR_NAME_CAP || + strlen(endpoint->socket_anchor_name) >= POSIX_SOCKET_ANCHOR_NAME_CAP || !unix_address_set(&address, endpoint->address, &address_length) || - !unix_address_set(&address, endpoint->socket_anchor_address, - &address_length)) { + !unix_address_set(&address, endpoint->socket_anchor_address, &address_length)) { cbm_daemon_ipc_endpoint_free(endpoint); return NULL; } @@ -970,8 +888,7 @@ void cbm_daemon_ipc_endpoint_free(cbm_daemon_ipc_endpoint_t *endpoint) { free(endpoint); } -static cbm_daemon_ipc_endpoint_t *endpoint_snapshot_new( - const cbm_daemon_ipc_endpoint_t *endpoint) { +static cbm_daemon_ipc_endpoint_t *endpoint_snapshot_new(const cbm_daemon_ipc_endpoint_t *endpoint) { if (!endpoint_runtime_still_valid(endpoint)) { return NULL; } @@ -983,30 +900,21 @@ static cbm_daemon_ipc_endpoint_t *endpoint_snapshot_new( snapshot->runtime_dir = string_copy(endpoint->runtime_dir); snapshot->address = string_copy(endpoint->address); snapshot->socket_name = string_copy(endpoint->socket_name); - snapshot->socket_anchor_address = - string_copy(endpoint->socket_anchor_address); - snapshot->socket_anchor_name = - string_copy(endpoint->socket_anchor_name); - snapshot->socket_identity_name = - string_copy(endpoint->socket_identity_name); - snapshot->socket_pending_name = - string_copy(endpoint->socket_pending_name); + snapshot->socket_anchor_address = string_copy(endpoint->socket_anchor_address); + snapshot->socket_anchor_name = string_copy(endpoint->socket_anchor_name); + snapshot->socket_identity_name = string_copy(endpoint->socket_identity_name); + snapshot->socket_pending_name = string_copy(endpoint->socket_pending_name); snapshot->lock_name = string_copy(endpoint->lock_name); - snapshot->startup_v2_lock_name = - string_copy(endpoint->startup_v2_lock_name); - snapshot->lifetime_lock_name = - string_copy(endpoint->lifetime_lock_name); + snapshot->startup_v2_lock_name = string_copy(endpoint->startup_v2_lock_name); + snapshot->lifetime_lock_name = string_copy(endpoint->lifetime_lock_name); snapshot->dir_fd = dup(endpoint->dir_fd); snapshot->dir_device = endpoint->dir_device; snapshot->dir_inode = endpoint->dir_inode; - if (!snapshot->runtime_dir || !snapshot->address || - !snapshot->socket_name || !snapshot->socket_anchor_address || - !snapshot->socket_anchor_name || !snapshot->socket_identity_name || - !snapshot->socket_pending_name || - !snapshot->lock_name || !snapshot->startup_v2_lock_name || - !snapshot->lifetime_lock_name || - snapshot->dir_fd < 0 || !fd_set_cloexec(snapshot->dir_fd) || - !endpoint_runtime_still_valid(snapshot)) { + if (!snapshot->runtime_dir || !snapshot->address || !snapshot->socket_name || + !snapshot->socket_anchor_address || !snapshot->socket_anchor_name || + !snapshot->socket_identity_name || !snapshot->socket_pending_name || !snapshot->lock_name || + !snapshot->startup_v2_lock_name || !snapshot->lifetime_lock_name || snapshot->dir_fd < 0 || + !fd_set_cloexec(snapshot->dir_fd) || !endpoint_runtime_still_valid(snapshot)) { cbm_daemon_ipc_endpoint_free(snapshot); return NULL; } @@ -1022,8 +930,7 @@ const char *cbm_daemon_ipc_endpoint_runtime_dir(const cbm_daemon_ipc_endpoint_t } cbm_private_file_lock_status_t cbm_daemon_ipc_private_lock_directory_new( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_private_lock_directory_t **directory_out) { + const cbm_daemon_ipc_endpoint_t *endpoint, cbm_private_lock_directory_t **directory_out) { if (directory_out) { *directory_out = NULL; } @@ -1043,8 +950,7 @@ cbm_private_file_lock_status_t cbm_daemon_ipc_private_lock_directory_new( return CBM_PRIVATE_FILE_LOCK_IO; } cbm_private_file_lock_status_t status = - cbm_private_lock_directory_adopt_posix(duplicate, endpoint->runtime_dir, - directory_out); + cbm_private_lock_directory_adopt_posix(duplicate, endpoint->runtime_dir, directory_out); if (status != CBM_PRIVATE_FILE_LOCK_OK) { (void)close(duplicate); } @@ -1059,32 +965,29 @@ static void posix_named_lock_release(int fd, process_lock_entry_t *process_entry process_lock_unclaim(process_entry); } -static int posix_named_lock_try_acquire_mode( - const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, - bool shared, int *fd_out, process_lock_entry_t **process_entry_out) { +static int posix_named_lock_try_acquire_mode(const cbm_daemon_ipc_endpoint_t *endpoint, + const char *lock_name, bool shared, int *fd_out, + process_lock_entry_t **process_entry_out) { if (fd_out) { *fd_out = -1; } if (process_entry_out) { *process_entry_out = NULL; } - if (!fd_out || !process_entry_out || !lock_name || - !endpoint_runtime_still_valid(endpoint)) { + if (!fd_out || !process_entry_out || !lock_name || !endpoint_runtime_still_valid(endpoint)) { return -1; } process_lock_entry_t *process_entry = NULL; - int process_result = process_lock_claim_mode( - endpoint, lock_name, shared, &process_entry); + int process_result = process_lock_claim_mode(endpoint, lock_name, shared, &process_entry); if (process_result != 1) { - return process_result == 0 && private_regular_file_at_is_safe( - endpoint->dir_fd, lock_name, 1) && + return process_result == 0 && + private_regular_file_at_is_safe(endpoint->dir_fd, lock_name, 1) && endpoint_runtime_still_valid(endpoint) ? 0 : -1; } - int fd = openat(endpoint->dir_fd, lock_name, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, - 0600); + int fd = openat(endpoint->dir_fd, lock_name, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600); if (fd < 0 || !fd_set_cloexec(fd)) { if (fd >= 0) { (void)close(fd); @@ -1093,8 +996,7 @@ static int posix_named_lock_try_acquire_mode( return -1; } if (fchmod(fd, 0600) != 0 || - !private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, - NULL) || + !private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, NULL) || !endpoint_runtime_still_valid(endpoint)) { (void)close(fd); process_lock_unclaim(process_entry); @@ -1102,18 +1004,14 @@ static int posix_named_lock_try_acquire_mode( } if (flock(fd, (shared ? LOCK_SH : LOCK_EX) | LOCK_NB) != 0) { int lock_error = errno; - bool still_private = private_regular_file_snapshot( - endpoint->dir_fd, lock_name, fd, 1, NULL) && - endpoint_runtime_still_valid(endpoint); + bool still_private = + private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, NULL) && + endpoint_runtime_still_valid(endpoint); (void)close(fd); process_lock_unclaim(process_entry); - return still_private && - (lock_error == EWOULDBLOCK || lock_error == EAGAIN) - ? 0 - : -1; + return still_private && (lock_error == EWOULDBLOCK || lock_error == EAGAIN) ? 0 : -1; } - if (!private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, - NULL) || + if (!private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, NULL) || !endpoint_runtime_still_valid(endpoint)) { posix_named_lock_release(fd, process_entry); return -1; @@ -1123,18 +1021,16 @@ static int posix_named_lock_try_acquire_mode( return 1; } -static int posix_named_lock_try_acquire( - const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, - int *fd_out, process_lock_entry_t **process_entry_out) { - return posix_named_lock_try_acquire_mode( - endpoint, lock_name, false, fd_out, process_entry_out); +static int posix_named_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *endpoint, + const char *lock_name, int *fd_out, + process_lock_entry_t **process_entry_out) { + return posix_named_lock_try_acquire_mode(endpoint, lock_name, false, fd_out, process_entry_out); } -static int posix_named_shared_lock_try_acquire( - const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, - int *fd_out, process_lock_entry_t **process_entry_out) { - return posix_named_lock_try_acquire_mode( - endpoint, lock_name, true, fd_out, process_entry_out); +static int posix_named_shared_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *endpoint, + const char *lock_name, int *fd_out, + process_lock_entry_t **process_entry_out) { + return posix_named_lock_try_acquire_mode(endpoint, lock_name, true, fd_out, process_entry_out); } static int posix_record_lock_set(int fd, short lock_type) { @@ -1166,8 +1062,7 @@ static int posix_lifetime_lock_probe(const cbm_daemon_ipc_endpoint_t *endpoint, return errno == ENOENT && process_claimed == 0 ? 0 : -1; } if (!fd_set_cloexec(fd) || - !private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, - NULL) || + !private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, NULL) || !endpoint_runtime_still_valid(endpoint)) { (void)close(fd); return -1; @@ -1186,10 +1081,8 @@ static int posix_lifetime_lock_probe(const cbm_daemon_ipc_endpoint_t *endpoint, do { query_result = fcntl(fd, F_GETLK, &record_lock); } while (query_result != 0 && errno == EINTR); - bool still_private = - private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, - NULL) && - endpoint_runtime_still_valid(endpoint); + bool still_private = private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, NULL) && + endpoint_runtime_still_valid(endpoint); (void)close(fd); if (query_result != 0 || !still_private) { return -1; @@ -1200,30 +1093,28 @@ static int posix_lifetime_lock_probe(const cbm_daemon_ipc_endpoint_t *endpoint, return record_lock.l_type == F_RDLCK || record_lock.l_type == F_WRLCK ? 1 : -1; } -static int posix_lifetime_lock_try_acquire( - const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, int *fd_out, - process_lock_entry_t **process_entry_out) { +static int posix_lifetime_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *endpoint, + const char *lock_name, int *fd_out, + process_lock_entry_t **process_entry_out) { if (fd_out) { *fd_out = -1; } if (process_entry_out) { *process_entry_out = NULL; } - if (!fd_out || !process_entry_out || !lock_name || - !endpoint_runtime_still_valid(endpoint)) { + if (!fd_out || !process_entry_out || !lock_name || !endpoint_runtime_still_valid(endpoint)) { return -1; } process_lock_entry_t *process_entry = NULL; int process_result = process_lock_claim(endpoint, lock_name, &process_entry); if (process_result != 1) { - return process_result == 0 && private_regular_file_at_is_safe( - endpoint->dir_fd, lock_name, 1) && + return process_result == 0 && + private_regular_file_at_is_safe(endpoint->dir_fd, lock_name, 1) && endpoint_runtime_still_valid(endpoint) ? 0 : -1; } - int fd = openat(endpoint->dir_fd, lock_name, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, - 0600); + int fd = openat(endpoint->dir_fd, lock_name, O_RDWR | O_CREAT | O_CLOEXEC | O_NOFOLLOW, 0600); if (fd < 0 || !fd_set_cloexec(fd)) { if (fd >= 0) { (void)close(fd); @@ -1232,8 +1123,7 @@ static int posix_lifetime_lock_try_acquire( return -1; } if (fchmod(fd, 0600) != 0 || - !private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, - NULL) || + !private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, NULL) || !endpoint_runtime_still_valid(endpoint)) { (void)close(fd); process_lock_unclaim(process_entry); @@ -1241,18 +1131,14 @@ static int posix_lifetime_lock_try_acquire( } if (posix_record_lock_set(fd, F_WRLCK) != 0) { int lock_error = errno; - bool still_private = private_regular_file_snapshot( - endpoint->dir_fd, lock_name, fd, 1, NULL) && - endpoint_runtime_still_valid(endpoint); + bool still_private = + private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, NULL) && + endpoint_runtime_still_valid(endpoint); (void)close(fd); process_lock_unclaim(process_entry); - return still_private && - (lock_error == EACCES || lock_error == EAGAIN) - ? 0 - : -1; + return still_private && (lock_error == EACCES || lock_error == EAGAIN) ? 0 : -1; } - if (!private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, - NULL) || + if (!private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, NULL) || !endpoint_runtime_still_valid(endpoint)) { (void)posix_record_lock_set(fd, F_UNLCK); (void)close(fd); @@ -1312,19 +1198,16 @@ void cbm_daemon_ipc_lifetime_reservation_release( static bool lifetime_reservation_matches_endpoint( const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_ipc_lifetime_reservation_t *reservation) { - const process_lock_entry_t *entry = - reservation ? reservation->process_entry : NULL; - if (!endpoint_runtime_still_valid(endpoint) || !reservation || - reservation->fd < 0 || reservation->owner_pid != getpid() || - !entry || !endpoint->lifetime_lock_name || + const process_lock_entry_t *entry = reservation ? reservation->process_entry : NULL; + if (!endpoint_runtime_still_valid(endpoint) || !reservation || reservation->fd < 0 || + reservation->owner_pid != getpid() || !entry || !endpoint->lifetime_lock_name || entry->directory_device != endpoint->dir_device || entry->directory_inode != endpoint->dir_inode || !entry->lock_name || strcmp(entry->lock_name, endpoint->lifetime_lock_name) != 0) { return false; } - if (!private_regular_file_snapshot( - endpoint->dir_fd, endpoint->lifetime_lock_name, - reservation->fd, 1, NULL) || + if (!private_regular_file_snapshot(endpoint->dir_fd, endpoint->lifetime_lock_name, + reservation->fd, 1, NULL) || !endpoint_runtime_still_valid(endpoint)) { return false; } @@ -1333,13 +1216,11 @@ static bool lifetime_reservation_matches_endpoint( return posix_record_lock_set(reservation->fd, F_WRLCK) == 0; } -int cbm_daemon_ipc_lifetime_reservation_probe( - const cbm_daemon_ipc_endpoint_t *endpoint) { +int cbm_daemon_ipc_lifetime_reservation_probe(const cbm_daemon_ipc_endpoint_t *endpoint) { return endpoint ? posix_lifetime_lock_probe(endpoint, endpoint->lifetime_lock_name) : -1; } -static int posix_startup_lock_probe( - const cbm_daemon_ipc_endpoint_t *endpoint) { +static int posix_startup_lock_probe(const cbm_daemon_ipc_endpoint_t *endpoint) { if (!endpoint_runtime_still_valid(endpoint)) { return -1; } @@ -1347,14 +1228,12 @@ static int posix_startup_lock_probe( if (process_claimed < 0) { return -1; } - int fd = openat(endpoint->dir_fd, endpoint->lock_name, - O_RDWR | O_CLOEXEC | O_NOFOLLOW); + int fd = openat(endpoint->dir_fd, endpoint->lock_name, O_RDWR | O_CLOEXEC | O_NOFOLLOW); if (fd < 0) { return errno == ENOENT && process_claimed == 0 ? 0 : -1; } if (!fd_set_cloexec(fd) || - !private_regular_file_snapshot(endpoint->dir_fd, - endpoint->lock_name, fd, 1, NULL)) { + !private_regular_file_snapshot(endpoint->dir_fd, endpoint->lock_name, fd, 1, NULL)) { (void)close(fd); return -1; } @@ -1368,37 +1247,30 @@ static int posix_startup_lock_probe( } while (lock_result != 0 && errno == EINTR); if (lock_result != 0) { int lock_error = errno; - bool still_private = private_regular_file_snapshot( - endpoint->dir_fd, endpoint->lock_name, fd, 1, NULL) && + bool still_private = + private_regular_file_snapshot(endpoint->dir_fd, endpoint->lock_name, fd, 1, NULL) && endpoint_runtime_still_valid(endpoint); (void)close(fd); - return still_private && - (lock_error == EWOULDBLOCK || lock_error == EAGAIN) - ? 1 - : -1; + return still_private && (lock_error == EWOULDBLOCK || lock_error == EAGAIN) ? 1 : -1; } int unlock_result; do { unlock_result = flock(fd, LOCK_UN); } while (unlock_result != 0 && errno == EINTR); - bool still_private = private_regular_file_snapshot( - endpoint->dir_fd, endpoint->lock_name, fd, 1, NULL) && + bool still_private = + private_regular_file_snapshot(endpoint->dir_fd, endpoint->lock_name, fd, 1, NULL) && endpoint_runtime_still_valid(endpoint); int close_result = close(fd); return unlock_result == 0 && still_private && close_result == 0 ? 0 : -1; } -int cbm_daemon_ipc_legacy_generation_probe( - const cbm_daemon_ipc_endpoint_t *endpoint) { +int cbm_daemon_ipc_legacy_generation_probe(const cbm_daemon_ipc_endpoint_t *endpoint) { if (!endpoint_runtime_still_valid(endpoint)) { return -1; } struct stat status; - if (fstatat(endpoint->dir_fd, endpoint->socket_name, &status, - AT_SYMLINK_NOFOLLOW) == 0) { - return posix_socket_status_secure_transport(&status) - ? 1 - : -1; + if (fstatat(endpoint->dir_fd, endpoint->socket_name, &status, AT_SYMLINK_NOFOLLOW) == 0) { + return posix_socket_status_secure_transport(&status) ? 1 : -1; } if (errno != ENOENT) { return -1; @@ -1408,8 +1280,7 @@ int cbm_daemon_ipc_legacy_generation_probe( static bool private_log_base_name_valid(const char *base_name) { if (!base_name || !base_name[0] || strcmp(base_name, ".") == 0 || - strcmp(base_name, "..") == 0 || strchr(base_name, '/') || - strchr(base_name, '\\')) { + strcmp(base_name, "..") == 0 || strchr(base_name, '/') || strchr(base_name, '\\')) { return false; } size_t length = strlen(base_name); @@ -1442,8 +1313,8 @@ static char *private_log_directory_path_copy(const char *directory_path) { return NULL; } struct stat resolved_status; - if (lstat(resolved, &resolved_status) != 0 || - !S_ISDIR(resolved_status.st_mode) || resolved_status.st_uid != 0) { + if (lstat(resolved, &resolved_status) != 0 || !S_ISDIR(resolved_status.st_mode) || + resolved_status.st_uid != 0) { return NULL; } return string_format("%s%s", resolved, directory_path + alias_length); @@ -1452,9 +1323,41 @@ static char *private_log_directory_path_copy(const char *directory_path) { return string_copy(directory_path); } -static int private_log_directory_open(const char *directory_path) { - if (!directory_path || !directory_path[0] || O_DIRECTORY == 0 || - O_NOFOLLOW == 0) { +static bool posix_directory_owner_trusted(uid_t owner) { + return owner == (uid_t)0 || owner == geteuid(); +} + +static bool posix_directory_parent_secure(int directory_fd) { + struct stat status; + if (directory_fd < 0 || fstat(directory_fd, &status) != 0 || !S_ISDIR(status.st_mode) || + !posix_directory_owner_trusted(status.st_uid) || + !cbm_macos_extended_acl_fd_is_deny_only(directory_fd)) { + return false; + } + return (status.st_mode & 0022) == 0 || + (status.st_uid == (uid_t)0 && (status.st_mode & S_ISVTX) != 0); +} + +/* Validate a path transition only through the two already-open directory + * handles. A group/other-writable parent is unsafe unless it is the standard + * root-owned sticky-directory pattern (for example /tmp) and the selected + * child is itself root/current-user owned. Existing ancestors are observed, + * never chmod'd or ACL-rewritten. */ +static bool posix_directory_transition_secure(int parent_fd, int child_fd) { + struct stat parent; + struct stat child; + if (parent_fd < 0 || child_fd < 0 || !posix_directory_parent_secure(parent_fd) || + fstat(parent_fd, &parent) != 0 || fstat(child_fd, &child) != 0 || + !S_ISDIR(parent.st_mode) || !S_ISDIR(child.st_mode) || + !posix_directory_owner_trusted(parent.st_uid) || + !posix_directory_owner_trusted(child.st_uid)) { + return false; + } + return true; +} + +static int private_directory_tree_open(const char *directory_path) { + if (!directory_path || !directory_path[0] || O_DIRECTORY == 0 || O_NOFOLLOW == 0) { return -1; } char *path = private_log_directory_path_copy(directory_path); @@ -1462,8 +1365,7 @@ static int private_log_directory_open(const char *directory_path) { return -1; } bool absolute = path[0] == '/'; - int current_fd = open(absolute ? "/" : ".", - O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); + int current_fd = open(absolute ? "/" : ".", O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); bool ok = current_fd >= 0 && fd_set_cloexec(current_fd); char *cursor = path; while (ok && *cursor == '/') { @@ -1483,19 +1385,21 @@ static int private_log_directory_open(const char *directory_path) { } else if (strcmp(component, "..") == 0 || !component[0]) { ok = false; } else { - bool created = mkdirat(current_fd, component, 0700) == 0; + ok = posix_directory_parent_secure(current_fd); + bool created = ok && mkdirat(current_fd, component, 0700) == 0; if (!created && errno != EEXIST) { ok = false; } - int next_fd = ok ? openat(current_fd, component, - O_RDONLY | O_DIRECTORY | O_CLOEXEC | - O_NOFOLLOW) - : -1; + int next_fd = + ok ? openat(current_fd, component, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW) + : -1; struct stat status; - ok = next_fd >= 0 && fd_set_cloexec(next_fd) && - fstat(next_fd, &status) == 0 && S_ISDIR(status.st_mode); + ok = next_fd >= 0 && fd_set_cloexec(next_fd) && fstat(next_fd, &status) == 0 && + S_ISDIR(status.st_mode) && posix_directory_transition_secure(current_fd, next_fd); if (ok && created) { - ok = status.st_uid == geteuid() && fchmod(next_fd, 0700) == 0; + ok = status.st_uid == geteuid() && fchmod(next_fd, 0700) == 0 && + cbm_macos_extended_acl_fd_clear(next_fd) && + cbm_macos_extended_acl_fd_is_empty(next_fd); } if (ok) { (void)close(current_fd); @@ -1511,12 +1415,10 @@ static int private_log_directory_open(const char *directory_path) { } } struct stat final_status; - ok = ok && visited && fstat(current_fd, &final_status) == 0 && - S_ISDIR(final_status.st_mode) && final_status.st_uid == geteuid() && - fchmod(current_fd, 0700) == 0 && - fstat(current_fd, &final_status) == 0 && - (final_status.st_mode & 07777) == 0700 && - cbm_macos_extended_acl_fd_is_empty(current_fd); + ok = ok && visited && fstat(current_fd, &final_status) == 0 && S_ISDIR(final_status.st_mode) && + final_status.st_uid == geteuid() && fchmod(current_fd, 0700) == 0 && + cbm_macos_extended_acl_fd_clear(current_fd) && fstat(current_fd, &final_status) == 0 && + (final_status.st_mode & 07777) == 0700 && cbm_macos_extended_acl_fd_is_empty(current_fd); free(path); if (!ok) { if (current_fd >= 0) { @@ -1527,18 +1429,23 @@ static int private_log_directory_open(const char *directory_path) { return current_fd; } -static int private_log_file_open(int directory_fd, const char *base_name, - struct stat *status_out) { +bool cbm_daemon_ipc_private_directory_secure(const char *directory_path) { + int directory_fd = private_directory_tree_open(directory_path); + if (directory_fd < 0) { + return false; + } + return close(directory_fd) == 0; +} + +static int private_log_file_open(int directory_fd, const char *base_name, struct stat *status_out) { int flags = O_WRONLY | O_APPEND | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK; - int fd = openat(directory_fd, base_name, - flags | O_CREAT | O_EXCL, 0600); + int fd = openat(directory_fd, base_name, flags | O_CREAT | O_EXCL, 0600); if (fd < 0 && errno == EEXIST) { fd = openat(directory_fd, base_name, flags); } struct stat status; if (fd < 0 || !fd_set_cloexec(fd) || fchmod(fd, 0600) != 0 || - !private_regular_file_snapshot(directory_fd, base_name, fd, 1, - &status) || + !private_regular_file_snapshot(directory_fd, base_name, fd, 1, &status) || status.st_size < 0) { if (fd >= 0) { (void)close(fd); @@ -1549,13 +1456,12 @@ static int private_log_file_open(int directory_fd, const char *base_name, return fd; } -FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, - const char *base_name, +FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, const char *base_name, size_t rotate_cap_bytes) { if (!private_log_base_name_valid(base_name) || rotate_cap_bytes == 0) { return NULL; } - int directory_fd = private_log_directory_open(directory_path); + int directory_fd = private_directory_tree_open(directory_path); if (directory_fd < 0) { return NULL; } @@ -1568,22 +1474,18 @@ FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, struct stat destination; bool destination_ok = false; if (written > 0 && written < (int)sizeof(rotated)) { - if (fstatat(directory_fd, rotated, &destination, - AT_SYMLINK_NOFOLLOW) == 0) { - destination_ok = S_ISREG(destination.st_mode) && - destination.st_uid == geteuid() && + if (fstatat(directory_fd, rotated, &destination, AT_SYMLINK_NOFOLLOW) == 0) { + destination_ok = S_ISREG(destination.st_mode) && destination.st_uid == geteuid() && destination.st_nlink == 1; } else { destination_ok = errno == ENOENT; } } struct stat current; - bool current_ok = - fstatat(directory_fd, base_name, ¤t, - AT_SYMLINK_NOFOLLOW) == 0 && - S_ISREG(current.st_mode) && current.st_uid == geteuid() && - current.st_nlink == 1 && current.st_dev == status.st_dev && - current.st_ino == status.st_ino; + bool current_ok = fstatat(directory_fd, base_name, ¤t, AT_SYMLINK_NOFOLLOW) == 0 && + S_ISREG(current.st_mode) && current.st_uid == geteuid() && + current.st_nlink == 1 && current.st_dev == status.st_dev && + current.st_ino == status.st_ino; ok = destination_ok && current_ok && close(fd) == 0; fd = -1; if (ok) { @@ -1617,8 +1519,8 @@ static void posix_u32_encode(uint8_t out[4], uint32_t value) { } static uint32_t posix_u32_decode(const uint8_t in[4]) { - return (uint32_t)in[0] | ((uint32_t)in[1] << 8U) | - ((uint32_t)in[2] << 16U) | ((uint32_t)in[3] << 24U); + return (uint32_t)in[0] | ((uint32_t)in[1] << 8U) | ((uint32_t)in[2] << 16U) | + ((uint32_t)in[3] << 24U); } static void posix_u64_encode(uint8_t out[8], uint64_t value) { @@ -1640,8 +1542,7 @@ static bool posix_stat_ctime(const struct stat *status, int64_t *seconds_out, if (!status || !seconds_out || !nanoseconds_out) { return false; } -#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || \ - defined(__NetBSD__) +#if defined(__APPLE__) || defined(__FreeBSD__) || defined(__OpenBSD__) || defined(__NetBSD__) time_t raw_seconds = status->st_ctimespec.tv_sec; long raw_nanoseconds = status->st_ctimespec.tv_nsec; #else @@ -1649,8 +1550,8 @@ static bool posix_stat_ctime(const struct stat *status, int64_t *seconds_out, long raw_nanoseconds = status->st_ctim.tv_nsec; #endif int64_t seconds = (int64_t)raw_seconds; - if ((time_t)seconds != raw_seconds || seconds < 0 || - raw_nanoseconds < 0 || raw_nanoseconds >= 1000000000L) { + if ((time_t)seconds != raw_seconds || seconds < 0 || raw_nanoseconds < 0 || + raw_nanoseconds >= 1000000000L) { return false; } *seconds_out = seconds; @@ -1658,8 +1559,8 @@ static bool posix_stat_ctime(const struct stat *status, int64_t *seconds_out, return true; } -static bool posix_socket_identity_from_stat( - const struct stat *status, posix_socket_identity_t *identity_out) { +static bool posix_socket_identity_from_stat(const struct stat *status, + posix_socket_identity_t *identity_out) { if (!status || !identity_out || !S_ISSOCK(status->st_mode)) { return false; } @@ -1678,52 +1579,40 @@ static bool posix_socket_identity_from_stat( return true; } -static bool posix_socket_identity_equal( - const posix_socket_identity_t *left, - const posix_socket_identity_t *right) { - return left && right && left->device == right->device && - left->inode == right->inode && +static bool posix_socket_identity_equal(const posix_socket_identity_t *left, + const posix_socket_identity_t *right) { + return left && right && left->device == right->device && left->inode == right->inode && left->ctime_seconds == right->ctime_seconds && left->ctime_nanoseconds == right->ctime_nanoseconds; } -static bool posix_socket_inode_equal( - const posix_socket_identity_t *left, - const posix_socket_identity_t *right) { - return left && right && left->device == right->device && - left->inode == right->inode; +static bool posix_socket_inode_equal(const posix_socket_identity_t *left, + const posix_socket_identity_t *right) { + return left && right && left->device == right->device && left->inode == right->inode; } -static bool posix_socket_status_secure_links(const struct stat *status, - nlink_t links) { - return status && S_ISSOCK(status->st_mode) && - status->st_uid == geteuid() && status->st_nlink == links && - (status->st_mode & 0777) == 0600; +static bool posix_socket_status_secure_links(const struct stat *status, nlink_t links) { + return status && S_ISSOCK(status->st_mode) && status->st_uid == geteuid() && + status->st_nlink == links && (status->st_mode & 0777) == 0600; } -static bool posix_socket_status_secure_transport( - const struct stat *status) { - return status && S_ISSOCK(status->st_mode) && - status->st_uid == geteuid() && - (status->st_nlink == 1 || status->st_nlink == 2) && - (status->st_mode & 0777) == 0600; +static bool posix_socket_status_secure_transport(const struct stat *status) { + return status && S_ISSOCK(status->st_mode) && status->st_uid == geteuid() && + (status->st_nlink == 1 || status->st_nlink == 2) && (status->st_mode & 0777) == 0600; } -static bool posix_socket_record_encode( - const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], - const posix_socket_record_t *source, - uint8_t record[POSIX_SOCKET_RECORD_SIZE]) { +static bool posix_socket_record_encode(const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], + const posix_socket_record_t *source, + uint8_t record[POSIX_SOCKET_RECORD_SIZE]) { if (!magic || !source || !record || source->identity.ctime_seconds < 0 || source->identity.ctime_nanoseconds >= 1000000000U) { return false; } - size_t anchor_length = strnlen(source->anchor_name, - POSIX_SOCKET_ANCHOR_NAME_CAP); + size_t anchor_length = strnlen(source->anchor_name, POSIX_SOCKET_ANCHOR_NAME_CAP); uint64_t device = (uint64_t)source->identity.device; uint64_t inode = (uint64_t)source->identity.inode; - if ((dev_t)device != source->identity.device || - (ino_t)inode != source->identity.inode || anchor_length == 0 || - anchor_length >= POSIX_SOCKET_ANCHOR_NAME_CAP || + if ((dev_t)device != source->identity.device || (ino_t)inode != source->identity.inode || + anchor_length == 0 || anchor_length >= POSIX_SOCKET_ANCHOR_NAME_CAP || strchr(source->anchor_name, '/')) { return false; } @@ -1733,45 +1622,36 @@ static bool posix_socket_record_encode( posix_u64_encode(record + POSIX_SOCKET_IDENTITY_INODE_OFFSET, inode); posix_u64_encode(record + POSIX_SOCKET_IDENTITY_CTIME_SECONDS_OFFSET, (uint64_t)source->identity.ctime_seconds); - posix_u32_encode( - record + POSIX_SOCKET_IDENTITY_CTIME_NANOSECONDS_OFFSET, - source->identity.ctime_nanoseconds); - memcpy(record + POSIX_SOCKET_ANCHOR_NAME_OFFSET, source->anchor_name, - anchor_length + 1U); + posix_u32_encode(record + POSIX_SOCKET_IDENTITY_CTIME_NANOSECONDS_OFFSET, + source->identity.ctime_nanoseconds); + memcpy(record + POSIX_SOCKET_ANCHOR_NAME_OFFSET, source->anchor_name, anchor_length + 1U); return true; } -static bool posix_socket_record_decode( - const uint8_t record[POSIX_SOCKET_RECORD_SIZE], - const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], - posix_socket_record_t *record_out) { +static bool posix_socket_record_decode(const uint8_t record[POSIX_SOCKET_RECORD_SIZE], + const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], + posix_socket_record_t *record_out) { if (!record || !magic || !record_out || memcmp(record, magic, POSIX_SOCKET_RECORD_MAGIC_SIZE) != 0 || posix_u32_decode(record + POSIX_SOCKET_IDENTITY_RESERVED_OFFSET) != 0) { return false; } - uint64_t device = - posix_u64_decode(record + POSIX_SOCKET_IDENTITY_DEVICE_OFFSET); - uint64_t inode = - posix_u64_decode(record + POSIX_SOCKET_IDENTITY_INODE_OFFSET); - uint64_t seconds = posix_u64_decode( - record + POSIX_SOCKET_IDENTITY_CTIME_SECONDS_OFFSET); - uint32_t nanoseconds = posix_u32_decode( - record + POSIX_SOCKET_IDENTITY_CTIME_NANOSECONDS_OFFSET); - if ((dev_t)device != device || (ino_t)inode != inode || - seconds > (uint64_t)INT64_MAX || nanoseconds >= 1000000000U) { + uint64_t device = posix_u64_decode(record + POSIX_SOCKET_IDENTITY_DEVICE_OFFSET); + uint64_t inode = posix_u64_decode(record + POSIX_SOCKET_IDENTITY_INODE_OFFSET); + uint64_t seconds = posix_u64_decode(record + POSIX_SOCKET_IDENTITY_CTIME_SECONDS_OFFSET); + uint32_t nanoseconds = + posix_u32_decode(record + POSIX_SOCKET_IDENTITY_CTIME_NANOSECONDS_OFFSET); + if ((dev_t)device != device || (ino_t)inode != inode || seconds > (uint64_t)INT64_MAX || + nanoseconds >= 1000000000U) { return false; } const uint8_t *anchor = record + POSIX_SOCKET_ANCHOR_NAME_OFFSET; - const uint8_t *terminator = - memchr(anchor, 0, POSIX_SOCKET_ANCHOR_NAME_CAP); - if (!terminator || terminator == anchor || - memchr(anchor, '/', (size_t)(terminator - anchor))) { + const uint8_t *terminator = memchr(anchor, 0, POSIX_SOCKET_ANCHOR_NAME_CAP); + if (!terminator || terminator == anchor || memchr(anchor, '/', (size_t)(terminator - anchor))) { return false; } size_t anchor_length = (size_t)(terminator - anchor); - for (size_t index = anchor_length + 1U; - index < POSIX_SOCKET_ANCHOR_NAME_CAP; index++) { + for (size_t index = anchor_length + 1U; index < POSIX_SOCKET_ANCHOR_NAME_CAP; index++) { if (anchor[index] != 0) { return false; } @@ -1785,8 +1665,7 @@ static bool posix_socket_record_decode( return true; } -static bool posix_fd_write_all(int fd, const uint8_t *buffer, - size_t length) { +static bool posix_fd_write_all(int fd, const uint8_t *buffer, size_t length) { size_t offset = 0; while (offset < length) { ssize_t written = write(fd, buffer + offset, length - offset); @@ -1804,8 +1683,7 @@ static bool posix_fd_write_all(int fd, const uint8_t *buffer, static bool posix_fd_pread_all(int fd, uint8_t *buffer, size_t length) { size_t offset = 0; while (offset < length) { - ssize_t count = pread(fd, buffer + offset, length - offset, - (off_t)offset); + ssize_t count = pread(fd, buffer + offset, length - offset, (off_t)offset); if (count > 0) { offset += (size_t)count; } else if (count < 0 && errno == EINTR) { @@ -1826,20 +1704,16 @@ typedef enum { static posix_record_state_t posix_socket_record_read_links( const cbm_daemon_ipc_endpoint_t *endpoint, const char *record_name, - const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], - nlink_t expected_links, posix_socket_record_t *record_out, - struct stat *record_status_out) { - if (!endpoint || !record_name || !magic || !record_out || - !record_status_out || !endpoint->socket_anchor_name || - (expected_links != 1 && expected_links != 2) || + const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], nlink_t expected_links, + posix_socket_record_t *record_out, struct stat *record_status_out) { + if (!endpoint || !record_name || !magic || !record_out || !record_status_out || + !endpoint->socket_anchor_name || (expected_links != 1 && expected_links != 2) || !endpoint_runtime_still_valid(endpoint)) { return POSIX_RECORD_UNSAFE; } - int fd = openat(endpoint->dir_fd, record_name, - O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + int fd = openat(endpoint->dir_fd, record_name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); if (fd < 0) { - return errno == ENOENT ? POSIX_RECORD_ABSENT - : POSIX_RECORD_UNSAFE; + return errno == ENOENT ? POSIX_RECORD_ABSENT : POSIX_RECORD_UNSAFE; } struct stat before; struct stat after; @@ -1849,54 +1723,41 @@ static posix_record_state_t posix_socket_record_read_links( int64_t after_ctime_seconds = 0; uint32_t after_ctime_nanoseconds = 0; bool metadata_safe = - fd_set_cloexec(fd) && fstat(fd, &before) == 0 && - S_ISREG(before.st_mode) && before.st_uid == geteuid() && - before.st_nlink == expected_links && - (before.st_mode & 07777) == 0600 && - cbm_macos_extended_acl_fd_is_empty(fd) && - posix_stat_ctime(&before, &before_ctime_seconds, - &before_ctime_nanoseconds); + fd_set_cloexec(fd) && fstat(fd, &before) == 0 && S_ISREG(before.st_mode) && + before.st_uid == geteuid() && before.st_nlink == expected_links && + (before.st_mode & 07777) == 0600 && cbm_macos_extended_acl_fd_is_empty(fd) && + posix_stat_ctime(&before, &before_ctime_seconds, &before_ctime_nanoseconds); if (!metadata_safe) { (void)close(fd); return POSIX_RECORD_UNSAFE; } if (before.st_size != (off_t)POSIX_SOCKET_RECORD_SIZE) { - bool still_private = private_regular_file_snapshot( - endpoint->dir_fd, record_name, fd, - expected_links, NULL) && + bool still_private = private_regular_file_snapshot(endpoint->dir_fd, record_name, fd, + expected_links, NULL) && endpoint_runtime_still_valid(endpoint); int close_result = close(fd); - return still_private && close_result == 0 ? POSIX_RECORD_UNKNOWN - : POSIX_RECORD_UNSAFE; + return still_private && close_result == 0 ? POSIX_RECORD_UNKNOWN : POSIX_RECORD_UNSAFE; } uint8_t record[POSIX_SOCKET_RECORD_SIZE]; bool read_ok = posix_fd_pread_all(fd, record, sizeof(record)); - bool stable = - read_ok && fstat(fd, &after) == 0 && - posix_stat_ctime(&after, &after_ctime_seconds, - &after_ctime_nanoseconds) && - fstatat(endpoint->dir_fd, record_name, &by_path, - AT_SYMLINK_NOFOLLOW) == 0 && - S_ISREG(after.st_mode) && after.st_uid == geteuid() && - after.st_nlink == expected_links && - (after.st_mode & 07777) == 0600 && - cbm_macos_extended_acl_fd_is_empty(fd) && - after.st_size == before.st_size && after.st_dev == before.st_dev && - after.st_ino == before.st_ino && - after_ctime_seconds == before_ctime_seconds && - after_ctime_nanoseconds == before_ctime_nanoseconds && - S_ISREG(by_path.st_mode) && by_path.st_uid == geteuid() && - by_path.st_nlink == expected_links && - (by_path.st_mode & 07777) == 0600 && - by_path.st_dev == before.st_dev && by_path.st_ino == before.st_ino && - endpoint_runtime_still_valid(endpoint); + bool stable = read_ok && fstat(fd, &after) == 0 && + posix_stat_ctime(&after, &after_ctime_seconds, &after_ctime_nanoseconds) && + fstatat(endpoint->dir_fd, record_name, &by_path, AT_SYMLINK_NOFOLLOW) == 0 && + S_ISREG(after.st_mode) && after.st_uid == geteuid() && + after.st_nlink == expected_links && (after.st_mode & 07777) == 0600 && + cbm_macos_extended_acl_fd_is_empty(fd) && after.st_size == before.st_size && + after.st_dev == before.st_dev && after.st_ino == before.st_ino && + after_ctime_seconds == before_ctime_seconds && + after_ctime_nanoseconds == before_ctime_nanoseconds && S_ISREG(by_path.st_mode) && + by_path.st_uid == geteuid() && by_path.st_nlink == expected_links && + (by_path.st_mode & 07777) == 0600 && by_path.st_dev == before.st_dev && + by_path.st_ino == before.st_ino && endpoint_runtime_still_valid(endpoint); int close_result = close(fd); if (!stable || close_result != 0) { return POSIX_RECORD_UNSAFE; } if (!posix_socket_record_decode(record, magic, record_out) || - strcmp(record_out->anchor_name, - endpoint->socket_anchor_name) != 0) { + strcmp(record_out->anchor_name, endpoint->socket_anchor_name) != 0) { return POSIX_RECORD_UNKNOWN; } *record_status_out = before; @@ -1905,21 +1766,21 @@ static posix_record_state_t posix_socket_record_read_links( static posix_record_state_t posix_socket_record_read( const cbm_daemon_ipc_endpoint_t *endpoint, const char *record_name, - const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], - posix_socket_record_t *record_out, struct stat *record_status_out) { - return posix_socket_record_read_links( - endpoint, record_name, magic, 1, record_out, record_status_out); + const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], posix_socket_record_t *record_out, + struct stat *record_status_out) { + return posix_socket_record_read_links(endpoint, record_name, magic, 1, record_out, + record_status_out); } -static int posix_socket_path_identity_read( - const cbm_daemon_ipc_endpoint_t *endpoint, const char *socket_name, - posix_socket_identity_t *identity_out, struct stat *status_out) { +static int posix_socket_path_identity_read(const cbm_daemon_ipc_endpoint_t *endpoint, + const char *socket_name, + posix_socket_identity_t *identity_out, + struct stat *status_out) { struct stat status; if (!endpoint || !socket_name || !identity_out) { return -1; } - if (fstatat(endpoint->dir_fd, socket_name, &status, - AT_SYMLINK_NOFOLLOW) != 0) { + if (fstatat(endpoint->dir_fd, socket_name, &status, AT_SYMLINK_NOFOLLOW) != 0) { return errno == ENOENT ? 0 : -1; } if (!posix_socket_status_secure_transport(&status) || @@ -1932,18 +1793,15 @@ static int posix_socket_path_identity_read( return 1; } -static bool posix_path_unlink_regular_if_matches( - int dir_fd, const char *name, dev_t device, ino_t inode, - nlink_t links) { +static bool posix_path_unlink_regular_if_matches(int dir_fd, const char *name, dev_t device, + ino_t inode, nlink_t links) { if (dir_fd < 0 || !name) { return false; } - int fd = openat(dir_fd, name, - O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + int fd = openat(dir_fd, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); struct stat status; bool matches = fd >= 0 && fd_set_cloexec(fd) && - private_regular_file_snapshot(dir_fd, name, fd, links, - &status) && + private_regular_file_snapshot(dir_fd, name, fd, links, &status) && status.st_dev == device && status.st_ino == inode; if (fd >= 0 && close(fd) != 0) { matches = false; @@ -1951,18 +1809,16 @@ static bool posix_path_unlink_regular_if_matches( return matches && unlinkat(dir_fd, name, 0) == 0; } -static bool posix_socket_path_unlink_inode_if_matches( - int dir_fd, const char *socket_name, - const posix_socket_identity_t *identity, nlink_t links) { +static bool posix_socket_path_unlink_inode_if_matches(int dir_fd, const char *socket_name, + const posix_socket_identity_t *identity, + nlink_t links) { struct stat status; posix_socket_identity_t observed; return dir_fd >= 0 && socket_name && identity && - fstatat(dir_fd, socket_name, &status, - AT_SYMLINK_NOFOLLOW) == 0 && + fstatat(dir_fd, socket_name, &status, AT_SYMLINK_NOFOLLOW) == 0 && posix_socket_status_secure_links(&status, links) && posix_socket_identity_from_stat(&status, &observed) && - posix_socket_inode_equal(&observed, identity) && - unlinkat(dir_fd, socket_name, 0) == 0; + posix_socket_inode_equal(&observed, identity) && unlinkat(dir_fd, socket_name, 0) == 0; } static bool posix_directory_sync(int dir_fd) { @@ -1992,8 +1848,7 @@ static bool posix_directory_sync(int dir_fd) { return unsupported; } -static bool posix_socket_record_temp_name( - const char *record_name, char temp_name[NAME_MAX + 1]) { +static bool posix_socket_record_temp_name(const char *record_name, char temp_name[NAME_MAX + 1]) { if (!record_name || !temp_name) { return false; } @@ -2001,32 +1856,25 @@ static bool posix_socket_record_temp_name( return written > 0 && written <= NAME_MAX; } -static int posix_record_artifact_status( - const cbm_daemon_ipc_endpoint_t *endpoint, const char *name, - struct stat *status_out) { - if (!endpoint || !name || !status_out || - !endpoint_runtime_still_valid(endpoint)) { +static int posix_record_artifact_status(const cbm_daemon_ipc_endpoint_t *endpoint, const char *name, + struct stat *status_out) { + if (!endpoint || !name || !status_out || !endpoint_runtime_still_valid(endpoint)) { return -1; } struct stat status; - if (fstatat(endpoint->dir_fd, name, &status, - AT_SYMLINK_NOFOLLOW) != 0) { + if (fstatat(endpoint->dir_fd, name, &status, AT_SYMLINK_NOFOLLOW) != 0) { return errno == ENOENT ? 0 : -1; } if (!S_ISREG(status.st_mode) || status.st_uid != geteuid() || - (status.st_mode & 07777) != 0600 || - (status.st_nlink != 1 && status.st_nlink != 2)) { + (status.st_mode & 07777) != 0600 || (status.st_nlink != 1 && status.st_nlink != 2)) { return -1; } - int fd = openat(endpoint->dir_fd, name, - O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + int fd = openat(endpoint->dir_fd, name, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); struct stat by_handle; - bool safe = fd >= 0 && fd_set_cloexec(fd) && - private_regular_file_snapshot( - endpoint->dir_fd, name, fd, status.st_nlink, - &by_handle) && - by_handle.st_dev == status.st_dev && - by_handle.st_ino == status.st_ino; + bool safe = + fd >= 0 && fd_set_cloexec(fd) && + private_regular_file_snapshot(endpoint->dir_fd, name, fd, status.st_nlink, &by_handle) && + by_handle.st_dev == status.st_dev && by_handle.st_ino == status.st_ino; if (fd >= 0 && close(fd) != 0) { safe = false; } @@ -2038,8 +1886,7 @@ static int posix_record_artifact_status( } static int posix_socket_record_identity_corroborated( - const cbm_daemon_ipc_endpoint_t *endpoint, - const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], + const cbm_daemon_ipc_endpoint_t *endpoint, const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], const posix_socket_record_t *record) { if (!endpoint || !magic || !record) { return -1; @@ -2048,32 +1895,25 @@ static int posix_socket_record_identity_corroborated( posix_socket_identity_t anchor = {0}; struct stat stable_status = {0}; struct stat anchor_status = {0}; - int stable_state = posix_socket_path_identity_read( - endpoint, endpoint->socket_name, &stable, &stable_status); - int anchor_state = posix_socket_path_identity_read( - endpoint, endpoint->socket_anchor_name, &anchor, &anchor_status); + int stable_state = + posix_socket_path_identity_read(endpoint, endpoint->socket_name, &stable, &stable_status); + int anchor_state = posix_socket_path_identity_read(endpoint, endpoint->socket_anchor_name, + &anchor, &anchor_status); if (stable_state < 0 || anchor_state < 0) { return -1; } - if (memcmp(magic, POSIX_SOCKET_PENDING_MAGIC, - POSIX_SOCKET_RECORD_MAGIC_SIZE) == 0) { - return stable_state == 0 && anchor_state == 1 && - anchor_status.st_nlink == 1 && - posix_socket_identity_equal(&record->identity, - &anchor) + if (memcmp(magic, POSIX_SOCKET_PENDING_MAGIC, POSIX_SOCKET_RECORD_MAGIC_SIZE) == 0) { + return stable_state == 0 && anchor_state == 1 && anchor_status.st_nlink == 1 && + posix_socket_identity_equal(&record->identity, &anchor) ? 1 : 0; } - if (memcmp(magic, POSIX_SOCKET_MARKER_MAGIC, - POSIX_SOCKET_RECORD_MAGIC_SIZE) == 0) { - return stable_state == 1 && anchor_state == 1 && - stable_status.st_nlink == 2 && + if (memcmp(magic, POSIX_SOCKET_MARKER_MAGIC, POSIX_SOCKET_RECORD_MAGIC_SIZE) == 0) { + return stable_state == 1 && anchor_state == 1 && stable_status.st_nlink == 2 && anchor_status.st_nlink == 2 && - posix_socket_identity_equal(&record->identity, - &stable) && - posix_socket_identity_equal(&record->identity, - &anchor) + posix_socket_identity_equal(&record->identity, &stable) && + posix_socket_identity_equal(&record->identity, &anchor) ? 1 : 0; } @@ -2093,10 +1933,8 @@ static int posix_socket_record_publication_recover( } struct stat record_status = {0}; struct stat temp_status = {0}; - int record_state = posix_record_artifact_status( - endpoint, record_name, &record_status); - int temp_state = posix_record_artifact_status( - endpoint, temp_name, &temp_status); + int record_state = posix_record_artifact_status(endpoint, record_name, &record_status); + int temp_state = posix_record_artifact_status(endpoint, temp_name, &temp_status); if (record_state < 0 || temp_state < 0) { return -1; } @@ -2111,8 +1949,7 @@ static int posix_socket_record_publication_recover( return 0; } posix_record_state_t read_state = posix_socket_record_read_links( - endpoint, temp_name, magic, 1, &recovered, - &recovered_status); + endpoint, temp_name, magic, 1, &recovered, &recovered_status); if (read_state != POSIX_RECORD_VALID) { return read_state == POSIX_RECORD_UNSAFE ? -1 : 0; } @@ -2123,8 +1960,7 @@ static int posix_socket_record_publication_recover( return 0; } posix_record_state_t read_state = posix_socket_record_read_links( - endpoint, record_name, magic, 2, &recovered, - &recovered_status); + endpoint, record_name, magic, 2, &recovered, &recovered_status); if (read_state != POSIX_RECORD_VALID) { return read_state == POSIX_RECORD_UNSAFE ? -1 : 0; } @@ -2134,24 +1970,20 @@ static int posix_socket_record_publication_recover( } } - int corroborated = posix_socket_record_identity_corroborated( - endpoint, magic, &recovered); + int corroborated = posix_socket_record_identity_corroborated(endpoint, magic, &recovered); if (corroborated != 1) { return corroborated; } - if (!posix_path_unlink_regular_if_matches( - endpoint->dir_fd, temp_name, recovered_status.st_dev, - recovered_status.st_ino, record_state == 1 ? 2 : 1) || + if (!posix_path_unlink_regular_if_matches(endpoint->dir_fd, temp_name, recovered_status.st_dev, + recovered_status.st_ino, record_state == 1 ? 2 : 1) || !posix_directory_sync(endpoint->dir_fd)) { return -1; } return 1; } -static int posix_linkat_no_follow(int source_dir_fd, - const char *source_name, - int destination_dir_fd, - const char *destination_name) { +static int posix_linkat_no_follow(int source_dir_fd, const char *source_name, + int destination_dir_fd, const char *destination_name) { int flags = 0; #if defined(__APPLE__) && defined(AT_SYMLINK_NOFOLLOW_ANY) /* Darwin documents that linkat(..., 0) may be rejected by some @@ -2159,33 +1991,32 @@ static int posix_linkat_no_follow(int source_dir_fd, * semantics while making the operation portable to those filesystems. */ flags = AT_SYMLINK_NOFOLLOW_ANY; #endif - return linkat(source_dir_fd, source_name, destination_dir_fd, - destination_name, flags); + return linkat(source_dir_fd, source_name, destination_dir_fd, destination_name, flags); } -static void posix_bound_socket_unlink_if_matches( - int dir_fd, const char *socket_name, dev_t device, ino_t inode) { +static void posix_bound_socket_unlink_if_matches(int dir_fd, const char *socket_name, dev_t device, + ino_t inode) { struct stat status; if (dir_fd >= 0 && socket_name && fstatat(dir_fd, socket_name, &status, AT_SYMLINK_NOFOLLOW) == 0 && - S_ISSOCK(status.st_mode) && status.st_uid == geteuid() && - status.st_dev == device && status.st_ino == inode) { + S_ISSOCK(status.st_mode) && status.st_uid == geteuid() && status.st_dev == device && + status.st_ino == inode) { (void)unlinkat(dir_fd, socket_name, 0); } } -static bool posix_socket_record_publish( - const cbm_daemon_ipc_endpoint_t *endpoint, const char *record_name, - const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], - const posix_socket_record_t *source, dev_t *device_out, - ino_t *inode_out) { - if (!endpoint || !record_name || !magic || !source || !device_out || - !inode_out || !endpoint_runtime_still_valid(endpoint)) { +static bool posix_socket_record_publish(const cbm_daemon_ipc_endpoint_t *endpoint, + const char *record_name, + const uint8_t magic[POSIX_SOCKET_RECORD_MAGIC_SIZE], + const posix_socket_record_t *source, dev_t *device_out, + ino_t *inode_out) { + if (!endpoint || !record_name || !magic || !source || !device_out || !inode_out || + !endpoint_runtime_still_valid(endpoint)) { return false; } struct stat existing; - if (fstatat(endpoint->dir_fd, record_name, &existing, - AT_SYMLINK_NOFOLLOW) == 0 || errno != ENOENT) { + if (fstatat(endpoint->dir_fd, record_name, &existing, AT_SYMLINK_NOFOLLOW) == 0 || + errno != ENOENT) { return false; } uint8_t record[POSIX_SOCKET_RECORD_SIZE]; @@ -2198,9 +2029,7 @@ static bool posix_socket_record_publish( return false; } int fd = openat(endpoint->dir_fd, temp_name, - O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW | - O_NONBLOCK, - 0600); + O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK, 0600); if (fd < 0) { return false; } @@ -2209,25 +2038,21 @@ static bool posix_socket_record_publish( bool temp_exists = true; bool stable_linked = false; bool ok = fd_set_cloexec(fd) && fchmod(fd, 0600) == 0 && - private_regular_file_snapshot(endpoint->dir_fd, temp_name, fd, - 1, &created) && - posix_fd_write_all(fd, record, sizeof(record)) && - fsync(fd) == 0 && - private_regular_file_snapshot(endpoint->dir_fd, temp_name, fd, - 1, &created) && + private_regular_file_snapshot(endpoint->dir_fd, temp_name, fd, 1, &created) && + posix_fd_write_all(fd, record, sizeof(record)) && fsync(fd) == 0 && + private_regular_file_snapshot(endpoint->dir_fd, temp_name, fd, 1, &created) && created.st_size == (off_t)POSIX_SOCKET_RECORD_SIZE && endpoint_runtime_still_valid(endpoint); if (ok) { posix_record_publication_stage_reached(magic, false); - ok = posix_linkat_no_follow( - endpoint->dir_fd, temp_name, endpoint->dir_fd, - record_name) == 0; + ok = + posix_linkat_no_follow(endpoint->dir_fd, temp_name, endpoint->dir_fd, record_name) == 0; stable_linked = ok; } if (ok) { posix_record_publication_stage_reached(magic, true); - ok = posix_path_unlink_regular_if_matches( - endpoint->dir_fd, temp_name, created.st_dev, created.st_ino, 2); + ok = posix_path_unlink_regular_if_matches(endpoint->dir_fd, temp_name, created.st_dev, + created.st_ino, 2); temp_exists = !ok; } if (ok) { @@ -2240,27 +2065,20 @@ static bool posix_socket_record_publish( posix_socket_record_t published_record; struct stat published_status; if (ok) { - ok = posix_socket_record_read(endpoint, record_name, magic, - &published_record, - &published_status) == - POSIX_RECORD_VALID && - posix_socket_identity_equal(&published_record.identity, - &source->identity) && - strcmp(published_record.anchor_name, - source->anchor_name) == 0 && - published_status.st_dev == created.st_dev && - published_status.st_ino == created.st_ino; + ok = posix_socket_record_read(endpoint, record_name, magic, &published_record, + &published_status) == POSIX_RECORD_VALID && + posix_socket_identity_equal(&published_record.identity, &source->identity) && + strcmp(published_record.anchor_name, source->anchor_name) == 0 && + published_status.st_dev == created.st_dev && published_status.st_ino == created.st_ino; } if (!ok) { if (stable_linked) { (void)posix_path_unlink_regular_if_matches( - endpoint->dir_fd, record_name, - created.st_dev, created.st_ino, temp_exists ? 2 : 1); + endpoint->dir_fd, record_name, created.st_dev, created.st_ino, temp_exists ? 2 : 1); } if (temp_exists) { - (void)posix_path_unlink_regular_if_matches( - endpoint->dir_fd, temp_name, created.st_dev, - created.st_ino, 1); + (void)posix_path_unlink_regular_if_matches(endpoint->dir_fd, temp_name, created.st_dev, + created.st_ino, 1); } (void)posix_directory_sync(endpoint->dir_fd); return false; @@ -2270,41 +2088,31 @@ static bool posix_socket_record_publish( return true; } -static bool posix_endpoint_namespace_equal( - const cbm_daemon_ipc_endpoint_t *left, - const cbm_daemon_ipc_endpoint_t *right) { +static bool posix_endpoint_namespace_equal(const cbm_daemon_ipc_endpoint_t *left, + const cbm_daemon_ipc_endpoint_t *right) { return left && right && left->dir_device == right->dir_device && - left->dir_inode == right->dir_inode && left->socket_name && - right->socket_name && - strcmp(left->socket_name, right->socket_name) == 0 && - left->socket_anchor_name && right->socket_anchor_name && - strcmp(left->socket_anchor_name, - right->socket_anchor_name) == 0 && + left->dir_inode == right->dir_inode && left->socket_name && right->socket_name && + strcmp(left->socket_name, right->socket_name) == 0 && left->socket_anchor_name && + right->socket_anchor_name && + strcmp(left->socket_anchor_name, right->socket_anchor_name) == 0 && left->socket_anchor_address && right->socket_anchor_address && - strcmp(left->socket_anchor_address, - right->socket_anchor_address) == 0 && + strcmp(left->socket_anchor_address, right->socket_anchor_address) == 0 && left->socket_identity_name && right->socket_identity_name && - strcmp(left->socket_identity_name, - right->socket_identity_name) == 0 && + strcmp(left->socket_identity_name, right->socket_identity_name) == 0 && left->socket_pending_name && right->socket_pending_name && - strcmp(left->socket_pending_name, - right->socket_pending_name) == 0 && - left->lock_name && right->lock_name && - strcmp(left->lock_name, right->lock_name) == 0 && + strcmp(left->socket_pending_name, right->socket_pending_name) == 0 && left->lock_name && + right->lock_name && strcmp(left->lock_name, right->lock_name) == 0 && left->startup_v2_lock_name && right->startup_v2_lock_name && - strcmp(left->startup_v2_lock_name, - right->startup_v2_lock_name) == 0 && + strcmp(left->startup_v2_lock_name, right->startup_v2_lock_name) == 0 && left->lifetime_lock_name && right->lifetime_lock_name && - strcmp(left->lifetime_lock_name, - right->lifetime_lock_name) == 0; + strcmp(left->lifetime_lock_name, right->lifetime_lock_name) == 0; } -static bool posix_named_lock_is_retained( - const cbm_daemon_ipc_endpoint_t *endpoint, const char *lock_name, - bool shared, int fd, const process_lock_entry_t *entry, - pid_t owner_pid) { - if (!endpoint_runtime_still_valid(endpoint) || !lock_name || fd < 0 || - !entry || entry->shared != shared || owner_pid != getpid()) { +static bool posix_named_lock_is_retained(const cbm_daemon_ipc_endpoint_t *endpoint, + const char *lock_name, bool shared, int fd, + const process_lock_entry_t *entry, pid_t owner_pid) { + if (!endpoint_runtime_still_valid(endpoint) || !lock_name || fd < 0 || !entry || + entry->shared != shared || owner_pid != getpid()) { return false; } if (entry->directory_device != endpoint->dir_device || @@ -2314,8 +2122,7 @@ static bool posix_named_lock_is_retained( return false; } - if (!private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, - NULL)) { + if (!private_regular_file_snapshot(endpoint->dir_fd, lock_name, fd, 1, NULL)) { return false; } int lock_result; @@ -2325,45 +2132,34 @@ static bool posix_named_lock_is_retained( return lock_result == 0; } -static bool posix_startup_lock_matches_endpoint( - const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_ipc_startup_lock_t *startup_lock) { - return startup_lock && - endpoint_runtime_still_valid(startup_lock->endpoint_snapshot) && - posix_endpoint_namespace_equal(endpoint, - startup_lock->endpoint_snapshot) && +static bool posix_startup_lock_matches_endpoint(const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_startup_lock_t *startup_lock) { + return startup_lock && endpoint_runtime_still_valid(startup_lock->endpoint_snapshot) && + posix_endpoint_namespace_equal(endpoint, startup_lock->endpoint_snapshot) && posix_named_lock_is_retained( - endpoint, endpoint->startup_v2_lock_name, false, - startup_lock->startup_v2_fd, - startup_lock->startup_v2_process_entry, - startup_lock->owner_pid) && - posix_named_lock_is_retained( - endpoint, endpoint->lock_name, true, - startup_lock->legacy_fd, - startup_lock->legacy_process_entry, - startup_lock->owner_pid); + endpoint, endpoint->startup_v2_lock_name, false, startup_lock->startup_v2_fd, + startup_lock->startup_v2_process_entry, startup_lock->owner_pid) && + posix_named_lock_is_retained(endpoint, endpoint->lock_name, true, + startup_lock->legacy_fd, startup_lock->legacy_process_entry, + startup_lock->owner_pid); } -static int posix_stale_generation_cleanup_locked( - const cbm_daemon_ipc_endpoint_t *endpoint) { +static int posix_stale_generation_cleanup_locked(const cbm_daemon_ipc_endpoint_t *endpoint) { /* Holding the startup lock excludes compliant starters. Reserving the * endpoint lifetime as well closes the check/unlink race against a caller * that already owns a transferred reservation. */ cbm_daemon_ipc_lifetime_reservation_t *cleanup_reservation = NULL; int reservation_result = - cbm_daemon_ipc_lifetime_reservation_try_acquire( - endpoint, &cleanup_reservation); + cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &cleanup_reservation); if (reservation_result != 1) { return reservation_result == 0 ? 0 : -1; } int result = -1; int pending_publication = posix_socket_record_publication_recover( - endpoint, endpoint->socket_pending_name, - POSIX_SOCKET_PENDING_MAGIC); + endpoint, endpoint->socket_pending_name, POSIX_SOCKET_PENDING_MAGIC); int marker_publication = posix_socket_record_publication_recover( - endpoint, endpoint->socket_identity_name, - POSIX_SOCKET_MARKER_MAGIC); + endpoint, endpoint->socket_identity_name, POSIX_SOCKET_MARKER_MAGIC); if (pending_publication != 1 || marker_publication != 1) { result = pending_publication < 0 || marker_publication < 0 ? -1 : 0; cbm_daemon_ipc_lifetime_reservation_release(cleanup_reservation); @@ -2374,34 +2170,29 @@ static int posix_stale_generation_cleanup_locked( posix_socket_record_t pending = {0}; struct stat marker_status = {0}; struct stat pending_status = {0}; - posix_record_state_t marker_state = posix_socket_record_read( - endpoint, endpoint->socket_identity_name, POSIX_SOCKET_MARKER_MAGIC, - &marker, &marker_status); - posix_record_state_t pending_state = posix_socket_record_read( - endpoint, endpoint->socket_pending_name, POSIX_SOCKET_PENDING_MAGIC, - &pending, &pending_status); + posix_record_state_t marker_state = + posix_socket_record_read(endpoint, endpoint->socket_identity_name, + POSIX_SOCKET_MARKER_MAGIC, &marker, &marker_status); + posix_record_state_t pending_state = + posix_socket_record_read(endpoint, endpoint->socket_pending_name, + POSIX_SOCKET_PENDING_MAGIC, &pending, &pending_status); posix_socket_identity_t stable_identity = {0}; posix_socket_identity_t anchor_identity = {0}; struct stat stable_status = {0}; struct stat anchor_status = {0}; - int stable_state = posix_socket_path_identity_read( - endpoint, endpoint->socket_name, &stable_identity, &stable_status); - int anchor_state = posix_socket_path_identity_read( - endpoint, endpoint->socket_anchor_name, &anchor_identity, - &anchor_status); - - if (marker_state == POSIX_RECORD_UNSAFE || - pending_state == POSIX_RECORD_UNSAFE || stable_state < 0 || - anchor_state < 0) { + int stable_state = posix_socket_path_identity_read(endpoint, endpoint->socket_name, + &stable_identity, &stable_status); + int anchor_state = posix_socket_path_identity_read(endpoint, endpoint->socket_anchor_name, + &anchor_identity, &anchor_status); + + if (marker_state == POSIX_RECORD_UNSAFE || pending_state == POSIX_RECORD_UNSAFE || + stable_state < 0 || anchor_state < 0) { result = -1; goto cleanup_done; } - if (marker_state == POSIX_RECORD_UNKNOWN || - pending_state == POSIX_RECORD_UNKNOWN || - (marker_state == POSIX_RECORD_VALID && - pending_state == POSIX_RECORD_VALID && - !posix_socket_inode_equal(&marker.identity, - &pending.identity))) { + if (marker_state == POSIX_RECORD_UNKNOWN || pending_state == POSIX_RECORD_UNKNOWN || + (marker_state == POSIX_RECORD_VALID && pending_state == POSIX_RECORD_VALID && + !posix_socket_inode_equal(&marker.identity, &pending.identity))) { result = 0; goto cleanup_done; } @@ -2410,22 +2201,19 @@ static int posix_stale_generation_cleanup_locked( * still corroborate its exact anchor inode. It never authorizes deleting * the stable name by itself. linkat changes ctime, so pending's pre-link * identity is compared by dev+ino and the final identity is captured now. */ - if (marker_state == POSIX_RECORD_ABSENT && - pending_state == POSIX_RECORD_VALID && stable_state == 1 && - anchor_state == 1 && stable_status.st_nlink == 2 && + if (marker_state == POSIX_RECORD_ABSENT && pending_state == POSIX_RECORD_VALID && + stable_state == 1 && anchor_state == 1 && stable_status.st_nlink == 2 && anchor_status.st_nlink == 2 && posix_socket_inode_equal(&stable_identity, &anchor_identity) && posix_socket_inode_equal(&pending.identity, &anchor_identity)) { posix_socket_record_t completed = { .identity = anchor_identity, }; - (void)snprintf(completed.anchor_name, - sizeof(completed.anchor_name), "%s", + (void)snprintf(completed.anchor_name, sizeof(completed.anchor_name), "%s", endpoint->socket_anchor_name); - if (!posix_socket_record_publish( - endpoint, endpoint->socket_identity_name, - POSIX_SOCKET_MARKER_MAGIC, &completed, - &marker_status.st_dev, &marker_status.st_ino)) { + if (!posix_socket_record_publish(endpoint, endpoint->socket_identity_name, + POSIX_SOCKET_MARKER_MAGIC, &completed, + &marker_status.st_dev, &marker_status.st_ino)) { result = -1; goto cleanup_done; } @@ -2435,16 +2223,12 @@ static int posix_stale_generation_cleanup_locked( if (marker_state == POSIX_RECORD_VALID) { bool anchor_owned = - anchor_state == 1 && - posix_socket_inode_equal(&marker.identity, &anchor_identity); + anchor_state == 1 && posix_socket_inode_equal(&marker.identity, &anchor_identity); if (stable_state == 1 && anchor_owned && posix_socket_inode_equal(&stable_identity, &anchor_identity)) { - if (stable_status.st_nlink != 2 || - anchor_status.st_nlink != 2 || - !posix_socket_identity_equal(&marker.identity, - &stable_identity) || - !posix_socket_identity_equal(&marker.identity, - &anchor_identity)) { + if (stable_status.st_nlink != 2 || anchor_status.st_nlink != 2 || + !posix_socket_identity_equal(&marker.identity, &stable_identity) || + !posix_socket_identity_equal(&marker.identity, &anchor_identity)) { result = -1; goto cleanup_done; } @@ -2453,25 +2237,18 @@ static int posix_stale_generation_cleanup_locked( struct stat confirmed_stable_status = {0}; struct stat confirmed_anchor_status = {0}; bool confirmed = - posix_socket_path_identity_read( - endpoint, endpoint->socket_name, &confirmed_stable, - &confirmed_stable_status) == 1 && - posix_socket_path_identity_read( - endpoint, endpoint->socket_anchor_name, - &confirmed_anchor, &confirmed_anchor_status) == 1 && - confirmed_stable_status.st_nlink == 2 && - confirmed_anchor_status.st_nlink == 2 && - posix_socket_identity_equal(&confirmed_stable, - &marker.identity) && - posix_socket_identity_equal(&confirmed_anchor, - &marker.identity); + posix_socket_path_identity_read(endpoint, endpoint->socket_name, &confirmed_stable, + &confirmed_stable_status) == 1 && + posix_socket_path_identity_read(endpoint, endpoint->socket_anchor_name, + &confirmed_anchor, &confirmed_anchor_status) == 1 && + confirmed_stable_status.st_nlink == 2 && confirmed_anchor_status.st_nlink == 2 && + posix_socket_identity_equal(&confirmed_stable, &marker.identity) && + posix_socket_identity_equal(&confirmed_anchor, &marker.identity); if (!confirmed || + !posix_socket_path_unlink_inode_if_matches(endpoint->dir_fd, endpoint->socket_name, + &marker.identity, 2) || !posix_socket_path_unlink_inode_if_matches( - endpoint->dir_fd, endpoint->socket_name, - &marker.identity, 2) || - !posix_socket_path_unlink_inode_if_matches( - endpoint->dir_fd, endpoint->socket_anchor_name, - &marker.identity, 1)) { + endpoint->dir_fd, endpoint->socket_anchor_name, &marker.identity, 1)) { result = -1; goto cleanup_done; } @@ -2484,8 +2261,7 @@ static int posix_stale_generation_cleanup_locked( if (anchor_state == 1) { if (!anchor_owned || anchor_status.st_nlink != 1 || !posix_socket_path_unlink_inode_if_matches( - endpoint->dir_fd, endpoint->socket_anchor_name, - &marker.identity, 1)) { + endpoint->dir_fd, endpoint->socket_anchor_name, &marker.identity, 1)) { result = -1; goto cleanup_done; } @@ -2493,13 +2269,12 @@ static int posix_stale_generation_cleanup_locked( } } - if (!posix_path_unlink_regular_if_matches( - endpoint->dir_fd, endpoint->socket_identity_name, - marker_status.st_dev, marker_status.st_ino, 1) || + if (!posix_path_unlink_regular_if_matches(endpoint->dir_fd, endpoint->socket_identity_name, + marker_status.st_dev, marker_status.st_ino, 1) || (pending_state == POSIX_RECORD_VALID && - !posix_path_unlink_regular_if_matches( - endpoint->dir_fd, endpoint->socket_pending_name, - pending_status.st_dev, pending_status.st_ino, 1)) || + !posix_path_unlink_regular_if_matches(endpoint->dir_fd, endpoint->socket_pending_name, + pending_status.st_dev, pending_status.st_ino, + 1)) || !posix_directory_sync(endpoint->dir_fd)) { result = -1; goto cleanup_done; @@ -2510,23 +2285,20 @@ static int posix_stale_generation_cleanup_locked( if (pending_state == POSIX_RECORD_VALID) { bool anchor_owned = - anchor_state == 1 && - posix_socket_inode_equal(&pending.identity, &anchor_identity); - if (anchor_state == 1 && - (!anchor_owned || anchor_status.st_nlink != 1)) { + anchor_state == 1 && posix_socket_inode_equal(&pending.identity, &anchor_identity); + if (anchor_state == 1 && (!anchor_owned || anchor_status.st_nlink != 1)) { result = -1; goto cleanup_done; } - if (anchor_owned && - !posix_socket_path_unlink_inode_if_matches( - endpoint->dir_fd, endpoint->socket_anchor_name, - &pending.identity, 1)) { + if (anchor_owned && !posix_socket_path_unlink_inode_if_matches(endpoint->dir_fd, + endpoint->socket_anchor_name, + &pending.identity, 1)) { result = -1; goto cleanup_done; } - if (!posix_path_unlink_regular_if_matches( - endpoint->dir_fd, endpoint->socket_pending_name, - pending_status.st_dev, pending_status.st_ino, 1) || + if (!posix_path_unlink_regular_if_matches(endpoint->dir_fd, endpoint->socket_pending_name, + pending_status.st_dev, pending_status.st_ino, + 1) || !posix_directory_sync(endpoint->dir_fd)) { result = -1; goto cleanup_done; @@ -2542,8 +2314,7 @@ static int posix_stale_generation_cleanup_locked( if (anchor_state == 1) { if (anchor_status.st_nlink != 1 || !posix_socket_path_unlink_inode_if_matches( - endpoint->dir_fd, endpoint->socket_anchor_name, - &anchor_identity, 1) || + endpoint->dir_fd, endpoint->socket_anchor_name, &anchor_identity, 1) || !posix_directory_sync(endpoint->dir_fd)) { result = -1; goto cleanup_done; @@ -2556,19 +2327,17 @@ static int posix_stale_generation_cleanup_locked( return result; } -int cbm_daemon_ipc_stale_generation_cleanup( - const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_ipc_startup_lock_t *startup_lock) { +int cbm_daemon_ipc_stale_generation_cleanup(const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_startup_lock_t *startup_lock) { return posix_startup_lock_matches_endpoint(endpoint, startup_lock) ? posix_stale_generation_cleanup_locked(endpoint) : -1; } -static void posix_publication_abort( - const cbm_daemon_ipc_endpoint_t *endpoint, - const posix_socket_identity_t *anchor_identity, - bool pending_published, const struct stat *pending_status, - bool marker_published, const struct stat *marker_status) { +static void posix_publication_abort(const cbm_daemon_ipc_endpoint_t *endpoint, + const posix_socket_identity_t *anchor_identity, + bool pending_published, const struct stat *pending_status, + bool marker_published, const struct stat *marker_status) { if (!endpoint || !anchor_identity) { return; } @@ -2576,33 +2345,30 @@ static void posix_publication_abort( posix_socket_identity_t anchor = {0}; struct stat stable_status = {0}; struct stat anchor_status = {0}; - int stable_state = posix_socket_path_identity_read( - endpoint, endpoint->socket_name, &stable, &stable_status); - int anchor_state = posix_socket_path_identity_read( - endpoint, endpoint->socket_anchor_name, &anchor, &anchor_status); - if (stable_state == 1 && anchor_state == 1 && - stable_status.st_nlink == 2 && anchor_status.st_nlink == 2 && - posix_socket_inode_equal(&stable, anchor_identity) && + int stable_state = + posix_socket_path_identity_read(endpoint, endpoint->socket_name, &stable, &stable_status); + int anchor_state = posix_socket_path_identity_read(endpoint, endpoint->socket_anchor_name, + &anchor, &anchor_status); + if (stable_state == 1 && anchor_state == 1 && stable_status.st_nlink == 2 && + anchor_status.st_nlink == 2 && posix_socket_inode_equal(&stable, anchor_identity) && posix_socket_inode_equal(&anchor, anchor_identity) && - posix_socket_path_unlink_inode_if_matches( - endpoint->dir_fd, endpoint->socket_name, anchor_identity, 2)) { + posix_socket_path_unlink_inode_if_matches(endpoint->dir_fd, endpoint->socket_name, + anchor_identity, 2)) { anchor_status.st_nlink = 1; } if (anchor_state == 1 && anchor_status.st_nlink == 1 && posix_socket_inode_equal(&anchor, anchor_identity)) { (void)posix_socket_path_unlink_inode_if_matches( - endpoint->dir_fd, endpoint->socket_anchor_name, - anchor_identity, 1); + endpoint->dir_fd, endpoint->socket_anchor_name, anchor_identity, 1); } if (marker_published && marker_status) { - (void)posix_path_unlink_regular_if_matches( - endpoint->dir_fd, endpoint->socket_identity_name, - marker_status->st_dev, marker_status->st_ino, 1); + (void)posix_path_unlink_regular_if_matches(endpoint->dir_fd, endpoint->socket_identity_name, + marker_status->st_dev, marker_status->st_ino, 1); } if (pending_published && pending_status) { - (void)posix_path_unlink_regular_if_matches( - endpoint->dir_fd, endpoint->socket_pending_name, - pending_status->st_dev, pending_status->st_ino, 1); + (void)posix_path_unlink_regular_if_matches(endpoint->dir_fd, endpoint->socket_pending_name, + pending_status->st_dev, pending_status->st_ino, + 1); } (void)posix_directory_sync(endpoint->dir_fd); } @@ -2612,10 +2378,8 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( cbm_daemon_ipc_lifetime_reservation_t **reservation_io) { cbm_daemon_ipc_lifetime_reservation_t *lifetime_reservation = reservation_io ? *reservation_io : NULL; - if (!lifetime_reservation_matches_endpoint(endpoint, - lifetime_reservation)) { - cbm_log_error("daemon.ipc.listen_failed", "stage", - "reservation_validation"); + if (!lifetime_reservation_matches_endpoint(endpoint, lifetime_reservation)) { + cbm_log_error("daemon.ipc.listen_failed", "stage", "reservation_validation"); return NULL; } /* Stale removal happens only under the startup lock, before a daemon host @@ -2623,48 +2387,38 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( * connects to, classifies, or removes pre-existing artifacts. */ char identity_temp_name[NAME_MAX + 1]; char pending_temp_name[NAME_MAX + 1]; - if (!posix_socket_record_temp_name(endpoint->socket_identity_name, - identity_temp_name) || - !posix_socket_record_temp_name(endpoint->socket_pending_name, - pending_temp_name)) { + if (!posix_socket_record_temp_name(endpoint->socket_identity_name, identity_temp_name) || + !posix_socket_record_temp_name(endpoint->socket_pending_name, pending_temp_name)) { cbm_log_error("daemon.ipc.listen_failed", "stage", "temp_names"); return NULL; } struct stat existing; const char *required_absent[] = { - endpoint->socket_name, - endpoint->socket_anchor_name, - endpoint->socket_identity_name, - endpoint->socket_pending_name, - identity_temp_name, - pending_temp_name, + endpoint->socket_name, endpoint->socket_anchor_name, endpoint->socket_identity_name, + endpoint->socket_pending_name, identity_temp_name, pending_temp_name, }; bool namespace_absent = true; - for (size_t index = 0; - index < sizeof(required_absent) / sizeof(required_absent[0]); - index++) { - if (fstatat(endpoint->dir_fd, required_absent[index], &existing, - AT_SYMLINK_NOFOLLOW) == 0 || errno != ENOENT) { + for (size_t index = 0; index < sizeof(required_absent) / sizeof(required_absent[0]); index++) { + if (fstatat(endpoint->dir_fd, required_absent[index], &existing, AT_SYMLINK_NOFOLLOW) == + 0 || + errno != ENOENT) { namespace_absent = false; break; } } if (!endpoint_runtime_still_valid(endpoint) || !namespace_absent) { - cbm_log_error("daemon.ipc.listen_failed", "stage", - "namespace_validation"); + cbm_log_error("daemon.ipc.listen_failed", "stage", "namespace_validation"); return NULL; } int fd = local_socket_new(); if (fd < 0) { - cbm_log_error("daemon.ipc.listen_failed", "stage", - "socket_creation"); + cbm_log_error("daemon.ipc.listen_failed", "stage", "socket_creation"); return NULL; } cbm_daemon_ipc_listener_t *listener = calloc(1, sizeof(*listener)); if (!listener) { - cbm_log_error("daemon.ipc.listen_failed", "stage", - "listener_allocation"); + cbm_log_error("daemon.ipc.listen_failed", "stage", "listener_allocation"); (void)close(fd); return NULL; } @@ -2675,19 +2429,14 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( listener->dir_inode = endpoint->dir_inode; listener->address = string_copy(endpoint->address); listener->socket_name = string_copy(endpoint->socket_name); - listener->socket_anchor_name = - string_copy(endpoint->socket_anchor_name); - listener->socket_identity_name = - string_copy(endpoint->socket_identity_name); - listener->socket_pending_name = - string_copy(endpoint->socket_pending_name); + listener->socket_anchor_name = string_copy(endpoint->socket_anchor_name); + listener->socket_identity_name = string_copy(endpoint->socket_identity_name); + listener->socket_pending_name = string_copy(endpoint->socket_pending_name); listener->owner_pid = getpid(); - if (listener->dir_fd < 0 || !fd_set_cloexec(listener->dir_fd) || - !listener->runtime_dir || !listener->address || !listener->socket_name || - !listener->socket_anchor_name || !listener->socket_identity_name || - !listener->socket_pending_name) { - cbm_log_error("daemon.ipc.listen_failed", "stage", - "listener_initialization"); + if (listener->dir_fd < 0 || !fd_set_cloexec(listener->dir_fd) || !listener->runtime_dir || + !listener->address || !listener->socket_name || !listener->socket_anchor_name || + !listener->socket_identity_name || !listener->socket_pending_name) { + cbm_log_error("daemon.ipc.listen_failed", "stage", "listener_initialization"); if (listener->dir_fd >= 0) { (void)close(listener->dir_fd); } @@ -2704,10 +2453,8 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( struct sockaddr_un address; socklen_t address_length; - if (!unix_address_set(&address, endpoint->socket_anchor_address, - &address_length)) { - cbm_log_error("daemon.ipc.listen_failed", "stage", - "socket_address"); + if (!unix_address_set(&address, endpoint->socket_anchor_address, &address_length)) { + cbm_log_error("daemon.ipc.listen_failed", "stage", "socket_address"); cbm_daemon_ipc_listener_close(listener); return NULL; } @@ -2721,24 +2468,19 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( } struct stat bound_status; - bool bound_path_ok = - fstatat(endpoint->dir_fd, endpoint->socket_anchor_name, - &bound_status, AT_SYMLINK_NOFOLLOW) == 0 && - S_ISSOCK(bound_status.st_mode) && bound_status.st_uid == geteuid(); + bool bound_path_ok = fstatat(endpoint->dir_fd, endpoint->socket_anchor_name, &bound_status, + AT_SYMLINK_NOFOLLOW) == 0 && + S_ISSOCK(bound_status.st_mode) && bound_status.st_uid == geteuid(); struct stat anchor_status = {0}; bool secured = - bound_path_ok && - fchmodat(endpoint->dir_fd, endpoint->socket_anchor_name, 0600, 0) == - 0 && + bound_path_ok && fchmodat(endpoint->dir_fd, endpoint->socket_anchor_name, 0600, 0) == 0 && fstatat(endpoint->dir_fd, endpoint->socket_anchor_name, &anchor_status, - AT_SYMLINK_NOFOLLOW) == 0 && + AT_SYMLINK_NOFOLLOW) == 0 && posix_socket_status_secure_links(&anchor_status, 1) && - anchor_status.st_dev == bound_status.st_dev && - anchor_status.st_ino == bound_status.st_ino; + anchor_status.st_dev == bound_status.st_dev && anchor_status.st_ino == bound_status.st_ino; posix_socket_identity_t anchor_identity = {0}; if (!secured || listen(fd, 32) != 0 || - fstatat(endpoint->dir_fd, endpoint->socket_anchor_name, - &anchor_status, + fstatat(endpoint->dir_fd, endpoint->socket_anchor_name, &anchor_status, AT_SYMLINK_NOFOLLOW) != 0 || !posix_socket_status_secure_links(&anchor_status, 1) || anchor_status.st_dev != bound_status.st_dev || @@ -2746,18 +2488,15 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( !posix_socket_identity_from_stat(&anchor_status, &anchor_identity) || !posix_directory_sync(endpoint->dir_fd)) { if (bound_path_ok) { - posix_bound_socket_unlink_if_matches( - endpoint->dir_fd, endpoint->socket_anchor_name, - bound_status.st_dev, bound_status.st_ino); + posix_bound_socket_unlink_if_matches(endpoint->dir_fd, endpoint->socket_anchor_name, + bound_status.st_dev, bound_status.st_ino); } - cbm_log_error("daemon.ipc.listen_failed", "stage", - "socket_security"); + cbm_log_error("daemon.ipc.listen_failed", "stage", "socket_security"); cbm_daemon_ipc_listener_close(listener); return NULL; } listener->socket_identity = anchor_identity; - posix_publication_stage_reached( - CBM_DAEMON_IPC_POSIX_PUBLICATION_ANCHOR_DURABLE); + posix_publication_stage_reached(CBM_DAEMON_IPC_POSIX_PUBLICATION_ANCHOR_DURABLE); posix_socket_record_t pending = { .identity = anchor_identity, @@ -2766,53 +2505,41 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( endpoint->socket_anchor_name); struct stat pending_status = {0}; bool pending_published = posix_socket_record_publish( - endpoint, endpoint->socket_pending_name, POSIX_SOCKET_PENDING_MAGIC, - &pending, &pending_status.st_dev, &pending_status.st_ino); + endpoint, endpoint->socket_pending_name, POSIX_SOCKET_PENDING_MAGIC, &pending, + &pending_status.st_dev, &pending_status.st_ino); if (!pending_published) { - cbm_log_error("daemon.ipc.listen_failed", "stage", - "pending_publication"); - posix_publication_abort(endpoint, &anchor_identity, false, NULL, - false, NULL); + cbm_log_error("daemon.ipc.listen_failed", "stage", "pending_publication"); + posix_publication_abort(endpoint, &anchor_identity, false, NULL, false, NULL); cbm_daemon_ipc_listener_close(listener); return NULL; } - posix_publication_stage_reached( - CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_DURABLE); + posix_publication_stage_reached(CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_DURABLE); - bool stable_linked = - posix_linkat_no_follow( - endpoint->dir_fd, endpoint->socket_anchor_name, - endpoint->dir_fd, endpoint->socket_name) == 0 && - posix_directory_sync(endpoint->dir_fd); + bool stable_linked = posix_linkat_no_follow(endpoint->dir_fd, endpoint->socket_anchor_name, + endpoint->dir_fd, endpoint->socket_name) == 0 && + posix_directory_sync(endpoint->dir_fd); posix_socket_identity_t stable_identity = {0}; posix_socket_identity_t committed_identity = {0}; struct stat stable_status = {0}; struct stat committed_anchor_status = {0}; bool stable_valid = stable_linked && - posix_socket_path_identity_read( - endpoint, endpoint->socket_name, &stable_identity, - &stable_status) == 1 && - posix_socket_path_identity_read( - endpoint, endpoint->socket_anchor_name, &committed_identity, - &committed_anchor_status) == 1 && - stable_status.st_nlink == 2 && - committed_anchor_status.st_nlink == 2 && - posix_socket_identity_equal(&stable_identity, - &committed_identity) && + posix_socket_path_identity_read(endpoint, endpoint->socket_name, &stable_identity, + &stable_status) == 1 && + posix_socket_path_identity_read(endpoint, endpoint->socket_anchor_name, &committed_identity, + &committed_anchor_status) == 1 && + stable_status.st_nlink == 2 && committed_anchor_status.st_nlink == 2 && + posix_socket_identity_equal(&stable_identity, &committed_identity) && posix_socket_inode_equal(&anchor_identity, &committed_identity); if (!stable_valid) { - cbm_log_error("daemon.ipc.listen_failed", "stage", - "stable_publication"); - posix_publication_abort(endpoint, &anchor_identity, - pending_published, &pending_status, false, - NULL); + cbm_log_error("daemon.ipc.listen_failed", "stage", "stable_publication"); + posix_publication_abort(endpoint, &anchor_identity, pending_published, &pending_status, + false, NULL); cbm_daemon_ipc_listener_close(listener); return NULL; } listener->socket_identity = committed_identity; - posix_publication_stage_reached( - CBM_DAEMON_IPC_POSIX_PUBLICATION_STABLE_DURABLE); + posix_publication_stage_reached(CBM_DAEMON_IPC_POSIX_PUBLICATION_STABLE_DURABLE); posix_socket_record_t marker = { .identity = committed_identity, @@ -2821,61 +2548,49 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( endpoint->socket_anchor_name); struct stat marker_status = {0}; bool marker_published = posix_socket_record_publish( - endpoint, endpoint->socket_identity_name, POSIX_SOCKET_MARKER_MAGIC, - &marker, &marker_status.st_dev, &marker_status.st_ino); + endpoint, endpoint->socket_identity_name, POSIX_SOCKET_MARKER_MAGIC, &marker, + &marker_status.st_dev, &marker_status.st_ino); if (!marker_published) { - cbm_log_error("daemon.ipc.listen_failed", "stage", - "marker_publication"); - posix_publication_abort(endpoint, &committed_identity, - pending_published, &pending_status, false, - NULL); + cbm_log_error("daemon.ipc.listen_failed", "stage", "marker_publication"); + posix_publication_abort(endpoint, &committed_identity, pending_published, &pending_status, + false, NULL); cbm_daemon_ipc_listener_close(listener); return NULL; } listener->identity_device = marker_status.st_dev; listener->identity_inode = marker_status.st_ino; - posix_publication_stage_reached( - CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_DURABLE); + posix_publication_stage_reached(CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_DURABLE); - if (!posix_path_unlink_regular_if_matches( - endpoint->dir_fd, endpoint->socket_pending_name, - pending_status.st_dev, pending_status.st_ino, 1) || + if (!posix_path_unlink_regular_if_matches(endpoint->dir_fd, endpoint->socket_pending_name, + pending_status.st_dev, pending_status.st_ino, 1) || !posix_directory_sync(endpoint->dir_fd)) { - cbm_log_error("daemon.ipc.listen_failed", "stage", - "pending_removal"); - posix_publication_abort(endpoint, &committed_identity, true, - &pending_status, marker_published, - &marker_status); + cbm_log_error("daemon.ipc.listen_failed", "stage", "pending_removal"); + posix_publication_abort(endpoint, &committed_identity, true, &pending_status, + marker_published, &marker_status); cbm_daemon_ipc_listener_close(listener); return NULL; } - posix_publication_stage_reached( - CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_REMOVED); + posix_publication_stage_reached(CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_REMOVED); listener->lifetime_reservation = lifetime_reservation; *reservation_io = NULL; return listener; } -cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen( - const cbm_daemon_ipc_endpoint_t *endpoint) { +cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen(const cbm_daemon_ipc_endpoint_t *endpoint) { cbm_daemon_ipc_startup_lock_t *startup_lock = NULL; - if (cbm_daemon_ipc_startup_lock_try_acquire(endpoint, - &startup_lock) != 1) { + if (cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup_lock) != 1) { return NULL; } cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL; cbm_daemon_ipc_participant_guard_t *participant_guard = NULL; if (cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup_lock) != 1 || - cbm_daemon_ipc_participant_guard_try_join( - endpoint, &participant_guard) != 1 || - cbm_daemon_ipc_lifetime_reservation_try_acquire( - endpoint, &reservation) != 1) { + cbm_daemon_ipc_participant_guard_try_join(endpoint, &participant_guard) != 1 || + cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &reservation) != 1) { ipc_participant_guard_release_complete(&participant_guard); ipc_startup_lock_release_complete(&startup_lock); return NULL; } - cbm_daemon_ipc_listener_t *listener = - cbm_daemon_ipc_listen_reserved(endpoint, &reservation); + cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen_reserved(endpoint, &reservation); if (listener) { listener->participant_guard = participant_guard; participant_guard = NULL; @@ -2886,12 +2601,10 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen( return listener; } -static void posix_listener_artifacts_remove_if_matches( - cbm_daemon_ipc_listener_t *listener) { +static void posix_listener_artifacts_remove_if_matches(cbm_daemon_ipc_listener_t *listener) { if (!listener || listener->dir_fd < 0 || !listener->socket_name || !listener->socket_anchor_name || !listener->socket_identity_name || - !listener->socket_pending_name || - listener->owner_pid != getpid()) { + !listener->socket_pending_name || listener->owner_pid != getpid()) { return; } cbm_daemon_ipc_endpoint_t view = { @@ -2909,21 +2622,18 @@ static void posix_listener_artifacts_remove_if_matches( struct stat marker_status = {0}; struct stat pending_status = {0}; posix_record_state_t marker_state = posix_socket_record_read( - &view, listener->socket_identity_name, POSIX_SOCKET_MARKER_MAGIC, - &marker, &marker_status); - posix_record_state_t pending_state = posix_socket_record_read( - &view, listener->socket_pending_name, POSIX_SOCKET_PENDING_MAGIC, - &pending, &pending_status); + &view, listener->socket_identity_name, POSIX_SOCKET_MARKER_MAGIC, &marker, &marker_status); + posix_record_state_t pending_state = + posix_socket_record_read(&view, listener->socket_pending_name, POSIX_SOCKET_PENDING_MAGIC, + &pending, &pending_status); bool marker_matches = marker_state == POSIX_RECORD_VALID && - marker_status.st_dev == listener->identity_device && - marker_status.st_ino == listener->identity_inode && - posix_socket_identity_equal(&marker.identity, - &listener->socket_identity); + marker_status.st_dev == listener->identity_device && + marker_status.st_ino == listener->identity_inode && + posix_socket_identity_equal(&marker.identity, &listener->socket_identity); bool pending_matches = pending_state == POSIX_RECORD_ABSENT || (pending_state == POSIX_RECORD_VALID && - posix_socket_inode_equal(&pending.identity, - &listener->socket_identity)); + posix_socket_inode_equal(&pending.identity, &listener->socket_identity)); if (!marker_matches || !pending_matches) { return; } @@ -2932,49 +2642,41 @@ static void posix_listener_artifacts_remove_if_matches( posix_socket_identity_t anchor = {0}; struct stat stable_status = {0}; struct stat anchor_status = {0}; - int stable_state = posix_socket_path_identity_read( - &view, listener->socket_name, &stable, &stable_status); - int anchor_state = posix_socket_path_identity_read( - &view, listener->socket_anchor_name, &anchor, &anchor_status); + int stable_state = + posix_socket_path_identity_read(&view, listener->socket_name, &stable, &stable_status); + int anchor_state = posix_socket_path_identity_read(&view, listener->socket_anchor_name, &anchor, + &anchor_status); if (stable_state < 0 || anchor_state < 0) { return; } bool anchor_owned = - anchor_state == 1 && - posix_socket_inode_equal(&anchor, &listener->socket_identity); - if (stable_state == 1 && anchor_owned && - posix_socket_inode_equal(&stable, &anchor)) { + anchor_state == 1 && posix_socket_inode_equal(&anchor, &listener->socket_identity); + if (stable_state == 1 && anchor_owned && posix_socket_inode_equal(&stable, &anchor)) { if (stable_status.st_nlink != 2 || anchor_status.st_nlink != 2 || - !posix_socket_identity_equal(&stable, - &listener->socket_identity) || - !posix_socket_identity_equal(&anchor, - &listener->socket_identity) || - !posix_socket_path_unlink_inode_if_matches( - listener->dir_fd, listener->socket_name, - &listener->socket_identity, 2) || + !posix_socket_identity_equal(&stable, &listener->socket_identity) || + !posix_socket_identity_equal(&anchor, &listener->socket_identity) || + !posix_socket_path_unlink_inode_if_matches(listener->dir_fd, listener->socket_name, + &listener->socket_identity, 2) || !posix_socket_path_unlink_inode_if_matches( - listener->dir_fd, listener->socket_anchor_name, - &listener->socket_identity, 1)) { + listener->dir_fd, listener->socket_anchor_name, &listener->socket_identity, 1)) { return; } } else if (anchor_state == 1) { if (!anchor_owned || anchor_status.st_nlink != 1 || !posix_socket_path_unlink_inode_if_matches( - listener->dir_fd, listener->socket_anchor_name, - &listener->socket_identity, 1)) { + listener->dir_fd, listener->socket_anchor_name, &listener->socket_identity, 1)) { return; } } - if (!posix_path_unlink_regular_if_matches( - listener->dir_fd, listener->socket_identity_name, - listener->identity_device, listener->identity_inode, 1)) { + if (!posix_path_unlink_regular_if_matches(listener->dir_fd, listener->socket_identity_name, + listener->identity_device, listener->identity_inode, + 1)) { return; } if (pending_state == POSIX_RECORD_VALID && - !posix_path_unlink_regular_if_matches( - listener->dir_fd, listener->socket_pending_name, - pending_status.st_dev, pending_status.st_ino, 1)) { + !posix_path_unlink_regular_if_matches(listener->dir_fd, listener->socket_pending_name, + pending_status.st_dev, pending_status.st_ino, 1)) { return; } (void)posix_directory_sync(listener->dir_fd); @@ -2992,8 +2694,7 @@ void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener) { posix_listener_artifacts_remove_if_matches(listener); (void)close(listener->dir_fd); } - cbm_daemon_ipc_lifetime_reservation_release( - listener->lifetime_reservation); + cbm_daemon_ipc_lifetime_reservation_release(listener->lifetime_reservation); ipc_participant_guard_release_complete(&listener->participant_guard); free(listener->runtime_dir); free(listener->address); @@ -3009,8 +2710,7 @@ int cbm_daemon_ipc_accept(cbm_daemon_ipc_listener_t *listener, uint32_t timeout_ if (connection_out) { *connection_out = NULL; } - if (!listener || listener->fd < 0 || listener->owner_pid != getpid() || - !connection_out) { + if (!listener || listener->fd < 0 || listener->owner_pid != getpid() || !connection_out) { return -1; } uint64_t deadline_ms = ipc_deadline_after(timeout_ms); @@ -3043,8 +2743,7 @@ int cbm_daemon_ipc_accept(cbm_daemon_ipc_listener_t *listener, uint32_t timeout_ } } -int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, - uint32_t timeout_ms) { +int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, uint32_t timeout_ms) { if (!endpoint_runtime_still_valid(endpoint)) { return -1; } @@ -3121,8 +2820,7 @@ int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, if (socket_error == ECONNREFUSED) { return 1; } - if (socket_error == EAGAIN || socket_error == EWOULDBLOCK || - socket_error == EINPROGRESS) { + if (socket_error == EAGAIN || socket_error == EWOULDBLOCK || socket_error == EINPROGRESS) { return 1; } return -1; @@ -3219,8 +2917,7 @@ void cbm_daemon_ipc_connection_interrupt(cbm_daemon_ipc_connection_t *connection } } -uint64_t cbm_daemon_ipc_connection_peer_pid( - const cbm_daemon_ipc_connection_t *connection) { +uint64_t cbm_daemon_ipc_connection_peer_pid(const cbm_daemon_ipc_connection_t *connection) { if (!connection || connection->fd < 0 || !socket_peer_is_current_user(connection->fd)) { return 0; } @@ -3255,19 +2952,17 @@ int cbm_daemon_ipc_startup_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *end } int startup_v2_fd = -1; process_lock_entry_t *startup_v2_process_entry = NULL; - int startup_v2_result = posix_named_lock_try_acquire( - endpoint, endpoint->startup_v2_lock_name, &startup_v2_fd, - &startup_v2_process_entry); + int startup_v2_result = posix_named_lock_try_acquire(endpoint, endpoint->startup_v2_lock_name, + &startup_v2_fd, &startup_v2_process_entry); if (startup_v2_result != 1) { return startup_v2_result; } int legacy_fd = -1; process_lock_entry_t *legacy_process_entry = NULL; - int legacy_result = posix_named_shared_lock_try_acquire( - endpoint, endpoint->lock_name, &legacy_fd, &legacy_process_entry); + int legacy_result = posix_named_shared_lock_try_acquire(endpoint, endpoint->lock_name, + &legacy_fd, &legacy_process_entry); if (legacy_result != 1) { - posix_named_lock_release(startup_v2_fd, - startup_v2_process_entry); + posix_named_lock_release(startup_v2_fd, startup_v2_process_entry); return legacy_result; } cbm_daemon_ipc_startup_lock_t *lock = calloc(1, sizeof(*lock)); @@ -3280,8 +2975,7 @@ int cbm_daemon_ipc_startup_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *end } free(lock); posix_named_lock_release(legacy_fd, legacy_process_entry); - posix_named_lock_release(startup_v2_fd, - startup_v2_process_entry); + posix_named_lock_release(startup_v2_fd, startup_v2_process_entry); return -1; } lock->startup_v2_fd = startup_v2_fd; @@ -3294,10 +2988,8 @@ int cbm_daemon_ipc_startup_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *end } int cbm_daemon_ipc_generation_probe_under_startup_lock( - const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_ipc_startup_lock_t *startup_lock) { - if (!posix_startup_lock_matches_endpoint(endpoint, startup_lock) || - startup_lock->prepared) { + const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_ipc_startup_lock_t *startup_lock) { + if (!posix_startup_lock_matches_endpoint(endpoint, startup_lock) || startup_lock->prepared) { return -1; } int lifetime = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); @@ -3307,21 +2999,18 @@ int cbm_daemon_ipc_generation_probe_under_startup_lock( return cbm_daemon_ipc_endpoint_probe(endpoint, 0); } -bool cbm_daemon_ipc_startup_lock_prepare_handoff( - cbm_daemon_ipc_startup_lock_t *lock) { +bool cbm_daemon_ipc_startup_lock_prepare_handoff(cbm_daemon_ipc_startup_lock_t *lock) { if (!lock || !lock->endpoint_snapshot) { return false; } if (lock->prepared) { return true; } - lock->prepared = cbm_daemon_ipc_stale_generation_cleanup( - lock->endpoint_snapshot, lock) == 1; + lock->prepared = cbm_daemon_ipc_stale_generation_cleanup(lock->endpoint_snapshot, lock) == 1; return lock->prepared; } -bool cbm_daemon_ipc_startup_lock_release( - cbm_daemon_ipc_startup_lock_t **lock_io) { +bool cbm_daemon_ipc_startup_lock_release(cbm_daemon_ipc_startup_lock_t **lock_io) { if (!lock_io) { return false; } @@ -3332,19 +3021,16 @@ bool cbm_daemon_ipc_startup_lock_release( if (lock->owner_pid != getpid()) { return false; } - posix_named_lock_release(lock->legacy_fd, - lock->legacy_process_entry); - posix_named_lock_release(lock->startup_v2_fd, - lock->startup_v2_process_entry); + posix_named_lock_release(lock->legacy_fd, lock->legacy_process_entry); + posix_named_lock_release(lock->startup_v2_fd, lock->startup_v2_process_entry); cbm_daemon_ipc_endpoint_free(lock->endpoint_snapshot); free(lock); *lock_io = NULL; return true; } -int cbm_daemon_ipc_participant_guard_try_join( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_daemon_ipc_participant_guard_t **guard_out) { +int cbm_daemon_ipc_participant_guard_try_join(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t **guard_out) { if (guard_out) { *guard_out = NULL; } @@ -3353,13 +3039,12 @@ int cbm_daemon_ipc_participant_guard_try_join( } int legacy_fd = -1; process_lock_entry_t *legacy_process_entry = NULL; - int result = posix_named_shared_lock_try_acquire( - endpoint, endpoint->lock_name, &legacy_fd, &legacy_process_entry); + int result = posix_named_shared_lock_try_acquire(endpoint, endpoint->lock_name, &legacy_fd, + &legacy_process_entry); if (result != 1) { return result; } - cbm_daemon_ipc_participant_guard_t *guard = - calloc(1, sizeof(*guard)); + cbm_daemon_ipc_participant_guard_t *guard = calloc(1, sizeof(*guard)); if (!guard) { posix_named_lock_release(legacy_fd, legacy_process_entry); return -1; @@ -3371,8 +3056,7 @@ int cbm_daemon_ipc_participant_guard_try_join( return 1; } -bool cbm_daemon_ipc_participant_guard_release( - cbm_daemon_ipc_participant_guard_t **guard_io) { +bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t **guard_io) { if (!guard_io) { return false; } @@ -3380,20 +3064,17 @@ bool cbm_daemon_ipc_participant_guard_release( if (!guard) { return true; } - if (guard->legacy_fd < 0 || !guard->legacy_process_entry || - guard->owner_pid != getpid()) { + if (guard->legacy_fd < 0 || !guard->legacy_process_entry || guard->owner_pid != getpid()) { return false; } - posix_named_lock_release(guard->legacy_fd, - guard->legacy_process_entry); + posix_named_lock_release(guard->legacy_fd, guard->legacy_process_entry); free(guard); *guard_io = NULL; return true; } int cbm_daemon_ipc_local_transition_try_acquire( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_daemon_ipc_local_transition_t **transition_out) { + const cbm_daemon_ipc_endpoint_t *endpoint, cbm_daemon_ipc_local_transition_t **transition_out) { if (transition_out) { *transition_out = NULL; } @@ -3402,27 +3083,23 @@ int cbm_daemon_ipc_local_transition_try_acquire( } int startup_v2_fd = -1; process_lock_entry_t *startup_v2_process_entry = NULL; - int startup_v2_result = posix_named_lock_try_acquire( - endpoint, endpoint->startup_v2_lock_name, &startup_v2_fd, - &startup_v2_process_entry); + int startup_v2_result = posix_named_lock_try_acquire(endpoint, endpoint->startup_v2_lock_name, + &startup_v2_fd, &startup_v2_process_entry); if (startup_v2_result != 1) { return startup_v2_result; } int legacy_fd = -1; process_lock_entry_t *legacy_process_entry = NULL; - int legacy_result = posix_named_shared_lock_try_acquire( - endpoint, endpoint->lock_name, &legacy_fd, &legacy_process_entry); + int legacy_result = posix_named_shared_lock_try_acquire(endpoint, endpoint->lock_name, + &legacy_fd, &legacy_process_entry); if (legacy_result != 1) { - posix_named_lock_release(startup_v2_fd, - startup_v2_process_entry); + posix_named_lock_release(startup_v2_fd, startup_v2_process_entry); return legacy_result; } - cbm_daemon_ipc_local_transition_t *transition = - calloc(1, sizeof(*transition)); + cbm_daemon_ipc_local_transition_t *transition = calloc(1, sizeof(*transition)); if (!transition) { posix_named_lock_release(legacy_fd, legacy_process_entry); - posix_named_lock_release(startup_v2_fd, - startup_v2_process_entry); + posix_named_lock_release(startup_v2_fd, startup_v2_process_entry); return -1; } transition->startup_v2_fd = startup_v2_fd; @@ -3435,32 +3112,25 @@ int cbm_daemon_ipc_local_transition_try_acquire( return 1; } -int cbm_daemon_ipc_local_transition_seal_legacy( - cbm_daemon_ipc_local_transition_t *transition) { +int cbm_daemon_ipc_local_transition_seal_legacy(cbm_daemon_ipc_local_transition_t *transition) { if (!transition || !transition->endpoint || !posix_named_lock_is_retained( - transition->endpoint, - transition->endpoint->startup_v2_lock_name, false, - transition->startup_v2_fd, - transition->startup_v2_process_entry, + transition->endpoint, transition->endpoint->startup_v2_lock_name, false, + transition->startup_v2_fd, transition->startup_v2_process_entry, transition->owner_pid) || - !posix_named_lock_is_retained( - transition->endpoint, transition->endpoint->lock_name, true, - transition->legacy_fd, transition->legacy_process_entry, - transition->owner_pid) || transition->work_begun) { + !posix_named_lock_is_retained(transition->endpoint, transition->endpoint->lock_name, true, + transition->legacy_fd, transition->legacy_process_entry, + transition->owner_pid) || + transition->work_begun) { return -1; } if (transition->sealed) { return 1; } - int lifetime = - cbm_daemon_ipc_lifetime_reservation_probe(transition->endpoint); - int result = lifetime == 1 - ? 1 - : lifetime == 0 - ? posix_stale_generation_cleanup_locked( - transition->endpoint) - : -1; + int lifetime = cbm_daemon_ipc_lifetime_reservation_probe(transition->endpoint); + int result = lifetime == 1 ? 1 + : lifetime == 0 ? posix_stale_generation_cleanup_locked(transition->endpoint) + : -1; if (result == 1) { transition->sealed = true; } @@ -3470,48 +3140,37 @@ int cbm_daemon_ipc_local_transition_seal_legacy( int cbm_daemon_ipc_local_transition_lifetime_probe( const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_ipc_local_transition_t *transition) { - if (!transition || !transition->sealed || !transition->endpoint || - transition->work_begun || + if (!transition || !transition->sealed || !transition->endpoint || transition->work_begun || !posix_endpoint_namespace_equal(endpoint, transition->endpoint) || !posix_named_lock_is_retained( - endpoint, endpoint->startup_v2_lock_name, false, - transition->startup_v2_fd, - transition->startup_v2_process_entry, - transition->owner_pid) || - !posix_named_lock_is_retained( - endpoint, endpoint->lock_name, true, transition->legacy_fd, - transition->legacy_process_entry, transition->owner_pid)) { + endpoint, endpoint->startup_v2_lock_name, false, transition->startup_v2_fd, + transition->startup_v2_process_entry, transition->owner_pid) || + !posix_named_lock_is_retained(endpoint, endpoint->lock_name, true, transition->legacy_fd, + transition->legacy_process_entry, transition->owner_pid)) { return -1; } return cbm_daemon_ipc_lifetime_reservation_probe(endpoint); } -bool cbm_daemon_ipc_local_transition_begin_work( - cbm_daemon_ipc_local_transition_t *transition) { - if (!transition || !transition->sealed || transition->work_begun || - !transition->endpoint || +bool cbm_daemon_ipc_local_transition_begin_work(cbm_daemon_ipc_local_transition_t *transition) { + if (!transition || !transition->sealed || transition->work_begun || !transition->endpoint || !posix_named_lock_is_retained( - transition->endpoint, - transition->endpoint->startup_v2_lock_name, false, - transition->startup_v2_fd, - transition->startup_v2_process_entry, + transition->endpoint, transition->endpoint->startup_v2_lock_name, false, + transition->startup_v2_fd, transition->startup_v2_process_entry, transition->owner_pid) || - !posix_named_lock_is_retained( - transition->endpoint, transition->endpoint->lock_name, true, - transition->legacy_fd, transition->legacy_process_entry, - transition->owner_pid)) { + !posix_named_lock_is_retained(transition->endpoint, transition->endpoint->lock_name, true, + transition->legacy_fd, transition->legacy_process_entry, + transition->owner_pid)) { return false; } - posix_named_lock_release(transition->startup_v2_fd, - transition->startup_v2_process_entry); + posix_named_lock_release(transition->startup_v2_fd, transition->startup_v2_process_entry); transition->startup_v2_fd = -1; transition->startup_v2_process_entry = NULL; transition->work_begun = true; return true; } -bool cbm_daemon_ipc_local_transition_release( - cbm_daemon_ipc_local_transition_t **transition_io) { +bool cbm_daemon_ipc_local_transition_release(cbm_daemon_ipc_local_transition_t **transition_io) { if (!transition_io) { return false; } @@ -3522,15 +3181,12 @@ bool cbm_daemon_ipc_local_transition_release( if (transition->legacy_fd < 0 || !transition->legacy_process_entry || transition->owner_pid != getpid() || (!transition->work_begun && - (transition->startup_v2_fd < 0 || - !transition->startup_v2_process_entry))) { + (transition->startup_v2_fd < 0 || !transition->startup_v2_process_entry))) { return false; } - posix_named_lock_release(transition->legacy_fd, - transition->legacy_process_entry); + posix_named_lock_release(transition->legacy_fd, transition->legacy_process_entry); if (!transition->work_begun) { - posix_named_lock_release(transition->startup_v2_fd, - transition->startup_v2_process_entry); + posix_named_lock_release(transition->startup_v2_fd, transition->startup_v2_process_entry); } free(transition); *transition_io = NULL; @@ -3563,12 +3219,14 @@ typedef BOOL(WINAPI *get_token_information_fn)(HANDLE, TOKEN_INFORMATION_CLASS, typedef DWORD(WINAPI *get_length_sid_fn)(PSID); typedef BOOL(WINAPI *copy_sid_fn)(DWORD, PSID, PSID); typedef BOOL(WINAPI *equal_sid_fn)(PSID, PSID); +typedef BOOL(WINAPI *is_valid_sid_fn)(PSID); +typedef BOOL(WINAPI *is_well_known_sid_fn)(PSID, WELL_KNOWN_SID_TYPE); +typedef BOOL(WINAPI *is_valid_acl_fn)(PACL); typedef BOOL(WINAPI *initialize_acl_fn)(PACL, DWORD, DWORD); typedef BOOL(WINAPI *add_access_allowed_ace_fn)(PACL, DWORD, DWORD, PSID); typedef BOOL(WINAPI *initialize_security_descriptor_fn)(PSECURITY_DESCRIPTOR, DWORD); typedef BOOL(WINAPI *set_security_descriptor_dacl_fn)(PSECURITY_DESCRIPTOR, BOOL, PACL, BOOL); -typedef BOOL(WINAPI *get_acl_information_fn)(PACL, LPVOID, DWORD, - ACL_INFORMATION_CLASS); +typedef BOOL(WINAPI *get_acl_information_fn)(PACL, LPVOID, DWORD, ACL_INFORMATION_CLASS); typedef BOOL(WINAPI *get_ace_fn)(PACL, DWORD, LPVOID *); typedef DWORD(WINAPI *get_security_info_fn)(HANDLE, SE_OBJECT_TYPE, SECURITY_INFORMATION, PSID *, PSID *, PACL *, PACL *, PSECURITY_DESCRIPTOR *); @@ -3587,6 +3245,9 @@ typedef struct { get_length_sid_fn get_length_sid; copy_sid_fn copy_sid; equal_sid_fn equal_sid; + is_valid_sid_fn is_valid_sid; + is_well_known_sid_fn is_well_known_sid; + is_valid_acl_fn is_valid_acl; initialize_acl_fn initialize_acl; add_access_allowed_ace_fn add_access_allowed_ace; initialize_security_descriptor_fn initialize_security_descriptor; @@ -3689,20 +3350,17 @@ static uint64_t ipc_deadline_after(uint32_t timeout_ms) { return ipc_now_ms() + (uint64_t)timeout_ms; } -static _Noreturn void ipc_coordination_cleanup_fail_stop( - const char *component) { - cbm_log_error("daemon.forced_shutdown", "component", component, - "action", "coordination_cleanup"); +static _Noreturn void ipc_coordination_cleanup_fail_stop(const char *component) { + cbm_log_error("daemon.forced_shutdown", "component", component, "action", + "coordination_cleanup"); (void)fflush(stdout); (void)fflush(stderr); (void)TerminateProcess(GetCurrentProcess(), EXIT_FAILURE); abort(); } -static void ipc_startup_lock_release_complete( - cbm_daemon_ipc_startup_lock_t **lock_io) { - uint64_t deadline = - ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); +static void ipc_startup_lock_release_complete(cbm_daemon_ipc_startup_lock_t **lock_io) { + uint64_t deadline = ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); while (lock_io && *lock_io) { (void)cbm_daemon_ipc_startup_lock_release(lock_io); if (!*lock_io) { @@ -3715,10 +3373,8 @@ static void ipc_startup_lock_release_complete( } } -static void ipc_participant_guard_release_complete( - cbm_daemon_ipc_participant_guard_t **guard_io) { - uint64_t deadline = - ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); +static void ipc_participant_guard_release_complete(cbm_daemon_ipc_participant_guard_t **guard_io) { + uint64_t deadline = ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); while (guard_io && *guard_io) { (void)cbm_daemon_ipc_participant_guard_release(guard_io); if (!*guard_io) { @@ -3854,6 +3510,9 @@ static bool win_security_init(win_security_t *security) { RESOLVE_ADVAPI_MEMBER(security, get_length_sid, get_length_sid_fn, "GetLengthSid"); RESOLVE_ADVAPI_MEMBER(security, copy_sid, copy_sid_fn, "CopySid"); RESOLVE_ADVAPI_MEMBER(security, equal_sid, equal_sid_fn, "EqualSid"); + RESOLVE_ADVAPI_MEMBER(security, is_valid_sid, is_valid_sid_fn, "IsValidSid"); + RESOLVE_ADVAPI_MEMBER(security, is_well_known_sid, is_well_known_sid_fn, "IsWellKnownSid"); + RESOLVE_ADVAPI_MEMBER(security, is_valid_acl, is_valid_acl_fn, "IsValidAcl"); RESOLVE_ADVAPI_MEMBER(security, initialize_acl, initialize_acl_fn, "InitializeAcl"); RESOLVE_ADVAPI_MEMBER(security, add_access_allowed_ace, add_access_allowed_ace_fn, "AddAccessAllowedAce"); @@ -3861,8 +3520,8 @@ static bool win_security_init(win_security_t *security) { initialize_security_descriptor_fn, "InitializeSecurityDescriptor"); RESOLVE_ADVAPI_MEMBER(security, set_security_descriptor_dacl, set_security_descriptor_dacl_fn, "SetSecurityDescriptorDacl"); - RESOLVE_ADVAPI_MEMBER(security, get_acl_information, - get_acl_information_fn, "GetAclInformation"); + RESOLVE_ADVAPI_MEMBER(security, get_acl_information, get_acl_information_fn, + "GetAclInformation"); RESOLVE_ADVAPI_MEMBER(security, get_ace, get_ace_fn, "GetAce"); RESOLVE_ADVAPI_MEMBER(security, get_security_info, get_security_info_fn, "GetSecurityInfo"); RESOLVE_ADVAPI_MEMBER(security, set_security_info, set_security_info_fn, "SetSecurityInfo"); @@ -3918,8 +3577,7 @@ static bool win_security_init(win_security_t *security) { #undef RESOLVE_ADVAPI_MEMBER -static bool win_kernel_mutex_current_user_only(win_security_t *security, - HANDLE mutex) { +static bool win_kernel_mutex_current_user_only(win_security_t *security, HANDLE mutex) { if (!security || !mutex || mutex == INVALID_HANDLE_VALUE) { return false; } @@ -3927,37 +3585,27 @@ static bool win_kernel_mutex_current_user_only(win_security_t *security, PACL dacl = NULL; PSECURITY_DESCRIPTOR descriptor = NULL; DWORD status = security->get_security_info( - mutex, SE_KERNEL_OBJECT, - OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner, NULL, - &dacl, NULL, &descriptor); + mutex, SE_KERNEL_OBJECT, OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner, + NULL, &dacl, NULL, &descriptor); ACL_SIZE_INFORMATION information; memset(&information, 0, sizeof(information)); void *raw_ace = NULL; - bool valid = - status == ERROR_SUCCESS && owner && - security->equal_sid(owner, security->user_sid) && dacl && - security->get_acl_information(dacl, &information, - sizeof(information), - AclSizeInformation) && - information.AceCount == 1 && security->get_ace(dacl, 0, &raw_ace) && - raw_ace; + bool valid = status == ERROR_SUCCESS && owner && + security->equal_sid(owner, security->user_sid) && dacl && + security->get_acl_information(dacl, &information, sizeof(information), + AclSizeInformation) && + information.AceCount == 1 && security->get_ace(dacl, 0, &raw_ace) && raw_ace; if (valid) { const ACCESS_ALLOWED_ACE *ace = raw_ace; const uint8_t *ace_sid = (const uint8_t *)&ace->SidStart; size_t sid_offset = offsetof(ACCESS_ALLOWED_ACE, SidStart); - size_t sid_capacity = ace->Header.AceSize >= sid_offset - ? ace->Header.AceSize - sid_offset - : 0; - size_t sid_length = sid_capacity >= 8U - ? 8U + (size_t)ace_sid[1] * 4U - : 0; + size_t sid_capacity = + ace->Header.AceSize >= sid_offset ? ace->Header.AceSize - sid_offset : 0; + size_t sid_length = sid_capacity >= 8U ? 8U + (size_t)ace_sid[1] * 4U : 0; DWORD required = SYNCHRONIZE | MUTEX_MODIFY_STATE | READ_CONTROL; - bool access_ok = (ace->Mask & GENERIC_ALL) != 0 || - (ace->Mask & required) == required; - valid = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE && - ace->Header.AceFlags == 0 && access_ok && - sid_length <= sid_capacity && - windows_sid_valid(ace_sid, sid_length) && + bool access_ok = (ace->Mask & GENERIC_ALL) != 0 || (ace->Mask & required) == required; + valid = ace->Header.AceType == ACCESS_ALLOWED_ACE_TYPE && ace->Header.AceFlags == 0 && + access_ok && sid_length <= sid_capacity && windows_sid_valid(ace_sid, sid_length) && security->equal_sid((PSID)ace_sid, security->user_sid); } if (descriptor) { @@ -3985,46 +3633,38 @@ static void *win_legacy_mutex_owner(void *opaque) { } else { acquire_result = wait_result == WAIT_TIMEOUT ? 0 : -1; } - atomic_store_explicit(&guard->acquire_result, acquire_result, - memory_order_release); + atomic_store_explicit(&guard->acquire_result, acquire_result, memory_order_release); bool ready = SetEvent(guard->ready_event) != 0; bool release_ok = acquire_result != 1; if (acquire_result == 1) { DWORD stopped = WaitForSingleObject(guard->stop_event, INFINITE); - release_ok = stopped == WAIT_OBJECT_0 && - ReleaseMutex(guard->mutex) != 0; + release_ok = stopped == WAIT_OBJECT_0 && ReleaseMutex(guard->mutex) != 0; } if (!ready) { release_ok = false; } - atomic_store_explicit(&guard->release_ok, release_ok, - memory_order_release); + atomic_store_explicit(&guard->release_ok, release_ok, memory_order_release); return NULL; } -static bool win_legacy_mutex_guard_release( - win_legacy_mutex_guard_t **guard_io) { +static bool win_legacy_mutex_guard_release(win_legacy_mutex_guard_t **guard_io) { if (!guard_io || !*guard_io) { return true; } win_legacy_mutex_guard_t *guard = *guard_io; - unsigned int failures = atomic_load_explicit( - &g_windows_legacy_guard_release_failures_for_test, - memory_order_acquire); + unsigned int failures = atomic_load_explicit(&g_windows_legacy_guard_release_failures_for_test, + memory_order_acquire); while (failures > 0) { - if (atomic_compare_exchange_weak_explicit( - &g_windows_legacy_guard_release_failures_for_test, - &failures, failures - 1U, memory_order_acq_rel, - memory_order_acquire)) { + if (atomic_compare_exchange_weak_explicit(&g_windows_legacy_guard_release_failures_for_test, + &failures, failures - 1U, memory_order_acq_rel, + memory_order_acquire)) { return false; } } bool stopped = !guard->owner_started || SetEvent(guard->stop_event) != 0; - bool joined = !guard->owner_started || - cbm_thread_join(&guard->owner_thread) == 0; + bool joined = !guard->owner_started || cbm_thread_join(&guard->owner_thread) == 0; bool released = - stopped && joined && - atomic_load_explicit(&guard->release_ok, memory_order_acquire); + stopped && joined && atomic_load_explicit(&guard->release_ok, memory_order_acquire); if (!joined) { return false; } @@ -4042,10 +3682,8 @@ static bool win_legacy_mutex_guard_release( return released; } -static void win_legacy_mutex_guard_release_complete( - win_legacy_mutex_guard_t **guard_io) { - uint64_t deadline = - ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); +static void win_legacy_mutex_guard_release_complete(win_legacy_mutex_guard_t **guard_io) { + uint64_t deadline = ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); while (guard_io && *guard_io) { (void)win_legacy_mutex_guard_release(guard_io); if (!*guard_io) { @@ -4058,9 +3696,8 @@ static void win_legacy_mutex_guard_release_complete( } } -static int win_legacy_mutex_guard_try_acquire( - const cbm_daemon_ipc_endpoint_t *endpoint, - win_legacy_mutex_guard_t **guard_out) { +static int win_legacy_mutex_guard_try_acquire(const cbm_daemon_ipc_endpoint_t *endpoint, + win_legacy_mutex_guard_t **guard_out) { if (guard_out) { *guard_out = NULL; } @@ -4072,20 +3709,16 @@ static int win_legacy_mutex_guard_try_acquire( return -1; } SetLastError(ERROR_SUCCESS); - HANDLE mutex = CreateMutexW(&security.attributes, FALSE, - endpoint->legacy_startup_mutex_name); + HANDLE mutex = CreateMutexW(&security.attributes, FALSE, endpoint->legacy_startup_mutex_name); DWORD create_error = mutex ? GetLastError() : ERROR_GEN_FAILURE; bool secured = mutex && mutex != INVALID_HANDLE_VALUE; if (secured && create_error != ERROR_ALREADY_EXISTS) { - secured = security.set_security_info( - mutex, SE_KERNEL_OBJECT, - OWNER_SECURITY_INFORMATION | - DACL_SECURITY_INFORMATION, - security.user_sid, NULL, security.acl, NULL) == - ERROR_SUCCESS; - } - secured = secured && - SetHandleInformation(mutex, HANDLE_FLAG_INHERIT, 0) != 0 && + secured = security.set_security_info(mutex, SE_KERNEL_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, + security.user_sid, NULL, security.acl, + NULL) == ERROR_SUCCESS; + } + secured = secured && SetHandleInformation(mutex, HANDLE_FLAG_INHERIT, 0) != 0 && win_kernel_mutex_current_user_only(&security, mutex); win_security_destroy(&security); if (!secured) { @@ -4104,8 +3737,7 @@ static int win_legacy_mutex_guard_try_acquire( atomic_init(&guard->acquire_result, -1); } if (!guard || !guard->ready_event || !guard->stop_event || - cbm_thread_create(&guard->owner_thread, 0, win_legacy_mutex_owner, - guard) != 0) { + cbm_thread_create(&guard->owner_thread, 0, win_legacy_mutex_owner, guard) != 0) { if (guard) { win_legacy_mutex_guard_release_complete(&guard); } else { @@ -4115,11 +3747,9 @@ static int win_legacy_mutex_guard_try_acquire( } guard->owner_started = true; DWORD ready = WaitForSingleObject(guard->ready_event, 5000); - int result = - ready == WAIT_OBJECT_0 - ? atomic_load_explicit(&guard->acquire_result, - memory_order_acquire) - : -1; + int result = ready == WAIT_OBJECT_0 + ? atomic_load_explicit(&guard->acquire_result, memory_order_acquire) + : -1; if (result != 1) { win_legacy_mutex_guard_release_complete(&guard); return result; @@ -4140,8 +3770,7 @@ static win_rendezvous_status_t win_endpoint_refresh_rendezvous( const cbm_daemon_ipc_endpoint_t *endpoint); static win_generation_address_t *win_endpoint_generation_snapshot( const cbm_daemon_ipc_endpoint_t *endpoint); -static int win_legacy_pipe_probe( - const cbm_daemon_ipc_endpoint_t *endpoint); +static int win_legacy_pipe_probe(const cbm_daemon_ipc_endpoint_t *endpoint); static bool win_token_is_current_user(win_security_t *security, HANDLE token) { PSID token_sid = NULL; @@ -4151,6 +3780,128 @@ static bool win_token_is_current_user(win_security_t *security, HANDLE token) { return equal; } +static uint32_t win_sid_read_u32_le(const uint8_t *bytes) { + return (uint32_t)bytes[0] | ((uint32_t)bytes[1] << 8U) | ((uint32_t)bytes[2] << 16U) | + ((uint32_t)bytes[3] << 24U); +} + +static bool win_sid_is_trusted_installer(const uint8_t *sid, size_t sid_length) { + static const uint32_t subauthorities[] = { + 80U, 956008885U, 3418522649U, 1831038044U, 1853292631U, 2271478464U, + }; + if (!windows_sid_valid(sid, sid_length) || sid[1] != 6U || sid[2] != 0U || sid[3] != 0U || + sid[4] != 0U || sid[5] != 0U || sid[6] != 0U || sid[7] != 5U) { + return false; + } + for (size_t index = 0; index < sizeof(subauthorities) / sizeof(subauthorities[0]); index++) { + if (win_sid_read_u32_le(sid + 8U + index * 4U) != subauthorities[index]) { + return false; + } + } + return true; +} + +static bool win_sid_trusted(win_security_t *security, PSID sid) { + if (!security || !sid || !security->is_valid_sid(sid)) { + return false; + } + DWORD sid_length = security->get_length_sid(sid); + return (sid_length > 0U && security->equal_sid(sid, security->user_sid)) || + security->is_well_known_sid(sid, WinLocalSystemSid) || + security->is_well_known_sid(sid, WinBuiltinAdministratorsSid) || + win_sid_is_trusted_installer((const uint8_t *)sid, (size_t)sid_length); +} + +static bool win_bounded_sid_trusted(win_security_t *security, const uint8_t *sid, + size_t sid_capacity, bool creator_owner_inherit_only) { + if (!security || !sid || sid_capacity < 8U || sid[1] > 15U) { + return false; + } + size_t sid_length = 8U + (size_t)sid[1] * 4U; + return sid_length <= sid_capacity && windows_sid_valid(sid, sid_length) && + security->is_valid_sid((PSID)sid) && + security->get_length_sid((PSID)sid) == (DWORD)sid_length && + (win_sid_trusted(security, (PSID)sid) || + (creator_owner_inherit_only && + security->is_well_known_sid((PSID)sid, WinCreatorOwnerSid))); +} + +static bool win_file_owner_secure(win_security_t *security, HANDLE file, + bool require_current_user) { + PSID owner = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + DWORD status = security->get_security_info(file, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, + &owner, NULL, NULL, NULL, &descriptor); + bool secure = status == ERROR_SUCCESS && owner && security->is_valid_sid(owner) && + (require_current_user ? security->equal_sid(owner, security->user_sid) + : win_sid_trusted(security, owner)); + if (descriptor) { + (void)LocalFree(descriptor); + } + return secure; +} + +static bool win_file_acl_secure(win_security_t *security, HANDLE file) { + PACL dacl = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + DWORD status = security->get_security_info(file, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, + NULL, NULL, &dacl, NULL, &descriptor); + ACL_SIZE_INFORMATION information; + memset(&information, 0, sizeof(information)); + bool secure = + status == ERROR_SUCCESS && descriptor && dacl && security->is_valid_acl(dacl) && + security->get_acl_information(dacl, &information, sizeof(information), AclSizeInformation); + const DWORD mutation = GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | + FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | + FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | + WRITE_OWNER | ACCESS_SYSTEM_SECURITY; + enum { + WIN_FILE_ACE_ALLOW = 0x00, + WIN_FILE_ACE_DENY = 0x01, + WIN_FILE_ACE_DENY_OBJECT = 0x06, + WIN_FILE_ACE_DENY_CALLBACK = 0x0a, + WIN_FILE_ACE_DENY_CALLBACK_OBJECT = 0x0c, + }; + for (DWORD index = 0U; secure && index < information.AceCount; index++) { + void *opaque = NULL; + if (!security->get_ace(dacl, index, &opaque) || !opaque) { + secure = false; + break; + } + ACE_HEADER *header = opaque; + if (header->AceType == WIN_FILE_ACE_DENY || header->AceType == WIN_FILE_ACE_DENY_OBJECT || + header->AceType == WIN_FILE_ACE_DENY_CALLBACK || + header->AceType == WIN_FILE_ACE_DENY_CALLBACK_OBJECT) { + continue; + } + size_t sid_offset = offsetof(ACCESS_ALLOWED_ACE, SidStart); + if (header->AceType != WIN_FILE_ACE_ALLOW || (size_t)header->AceSize < sid_offset + 8U) { + secure = false; + break; + } + const ACCESS_ALLOWED_ACE *ace = opaque; + if ((ace->Mask & mutation) == 0U) { + continue; + } + const uint8_t *sid = (const uint8_t *)&ace->SidStart; + size_t sid_capacity = (size_t)header->AceSize - sid_offset; + bool creator_owner_inherit_only = (header->AceFlags & INHERIT_ONLY_ACE) != 0U; + if (!win_bounded_sid_trusted(security, sid, sid_capacity, creator_owner_inherit_only)) { + secure = false; + } + } + if (descriptor) { + (void)LocalFree(descriptor); + } + return secure; +} + +static bool win_file_security_secure(win_security_t *security, HANDLE file, + bool require_current_user) { + return win_file_owner_secure(security, file, require_current_user) && + win_file_acl_secure(security, file); +} + static bool win_runtime_directory_secure(const wchar_t *runtime_dir) { win_security_t security; if (!win_security_init(&security)) { @@ -4176,19 +3927,10 @@ static bool win_runtime_directory_secure(const wchar_t *runtime_dir) { return false; } BY_HANDLE_FILE_INFORMATION file_info; - PSID owner = NULL; - PSECURITY_DESCRIPTOR existing_descriptor = NULL; bool valid_handle = GetFileInformationByHandle(directory, &file_info) != 0 && (file_info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (file_info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; - DWORD owner_result = - security.get_security_info(directory, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, - NULL, NULL, NULL, &existing_descriptor); - bool owner_ok = - owner_result == ERROR_SUCCESS && owner && security.equal_sid(owner, security.user_sid); - if (existing_descriptor) { - (void)LocalFree(existing_descriptor); - } + bool owner_ok = valid_handle && win_file_owner_secure(&security, directory, true); DWORD secure_result = ERROR_ACCESS_DENIED; if (valid_handle && owner_ok) { secure_result = security.set_security_info(directory, SE_FILE_OBJECT, @@ -4196,24 +3938,26 @@ static bool win_runtime_directory_secure(const wchar_t *runtime_dir) { PROTECTED_DACL_SECURITY_INFORMATION, NULL, NULL, security.acl, NULL); } + bool final_private = + secure_result == ERROR_SUCCESS && win_file_security_secure(&security, directory, true); (void)CloseHandle(directory); win_security_destroy(&security); - return valid_handle && owner_ok && secure_result == ERROR_SUCCESS; + return valid_handle && owner_ok && final_private; } -static bool win_directory_component_is_plain(const wchar_t *path) { - HANDLE directory = CreateFileW( - path, FILE_READ_ATTRIBUTES, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); +static bool win_directory_component_secure(win_security_t *security, const wchar_t *path) { + HANDLE directory = + CreateFileW(path, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); if (directory == INVALID_HANDLE_VALUE) { return false; } BY_HANDLE_FILE_INFORMATION info; bool valid = GetFileInformationByHandle(directory, &info) != 0 && (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && - (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; + (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && + win_file_security_secure(security, directory, false); (void)CloseHandle(directory); return valid; } @@ -4223,12 +3967,11 @@ static bool win_private_directory_tree_secure(const wchar_t *directory_path) { return false; } size_t length = wcslen(directory_path); - bool drive_absolute = - length >= 4 && - ((directory_path[0] >= L'A' && directory_path[0] <= L'Z') || - (directory_path[0] >= L'a' && directory_path[0] <= L'z')) && - directory_path[1] == L':' && - (directory_path[2] == L'\\' || directory_path[2] == L'/'); + bool drive_absolute = length >= 4 && + ((directory_path[0] >= L'A' && directory_path[0] <= L'Z') || + (directory_path[0] >= L'a' && directory_path[0] <= L'z')) && + directory_path[1] == L':' && + (directory_path[2] == L'\\' || directory_path[2] == L'/'); if (!drive_absolute) { /* Local current-user ACLs do not provide the intended guarantee for * UNC/device namespaces. Daemon cache logs must stay on a local @@ -4268,12 +4011,14 @@ static bool win_private_directory_tree_secure(const wchar_t *directory_path) { DWORD attributes = GetFileAttributesW(path); if (attributes == INVALID_FILE_ATTRIBUTES) { DWORD error = GetLastError(); - ok = (error == ERROR_FILE_NOT_FOUND || - error == ERROR_PATH_NOT_FOUND) && + ok = (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) && CreateDirectoryW(path, &security.attributes) != 0; } - if (ok) { - ok = win_directory_component_is_plain(path); + /* Ancestors are observe-only and must already be secure. The + * final current-user directory is intentionally handled below by + * win_runtime_directory_secure(), which may replace its DACL. */ + if (ok && i < length) { + ok = win_directory_component_secure(&security, path); } } path[i] = saved; @@ -4287,22 +4032,29 @@ static bool win_private_directory_tree_secure(const wchar_t *directory_path) { return ok; } +bool cbm_daemon_ipc_private_directory_secure(const char *directory_path) { + if (!directory_path || !directory_path[0]) { + return false; + } + wchar_t *wide_directory = utf8_to_wide(directory_path); + bool secure = wide_directory && win_private_directory_tree_secure(wide_directory); + free(wide_directory); + return secure; +} + static bool private_log_base_name_valid(const char *base_name) { if (!base_name || !base_name[0] || strcmp(base_name, ".") == 0 || - strcmp(base_name, "..") == 0 || strchr(base_name, '/') || - strchr(base_name, '\\')) { + strcmp(base_name, "..") == 0 || strchr(base_name, '/') || strchr(base_name, '\\')) { return false; } return strlen(base_name) <= 253; } -static HANDLE win_private_log_file_open( - const wchar_t *path, DWORD creation_disposition, win_security_t *security, - LARGE_INTEGER *size_out) { - HANDLE file = CreateFileW( - path, GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC, - FILE_SHARE_READ, &security->attributes, creation_disposition, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); +static HANDLE win_private_log_file_open(const wchar_t *path, DWORD creation_disposition, + win_security_t *security, LARGE_INTEGER *size_out) { + HANDLE file = CreateFileW(path, GENERIC_READ | GENERIC_WRITE | READ_CONTROL | WRITE_DAC, + FILE_SHARE_READ, &security->attributes, creation_disposition, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); if (file == INVALID_HANDLE_VALUE) { return INVALID_HANDLE_VALUE; } @@ -4310,25 +4062,21 @@ static HANDLE win_private_log_file_open( BY_HANDLE_FILE_INFORMATION info; PSID owner = NULL; PSECURITY_DESCRIPTOR descriptor = NULL; - bool valid = GetFileType(file) == FILE_TYPE_DISK && - GetFileInformationByHandle(file, &info) != 0 && - (info.dwFileAttributes & - (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == - 0 && info.nNumberOfLinks == 1 && - GetFileSizeEx(file, size_out) != 0 && size_out->QuadPart >= 0; + bool valid = + GetFileType(file) == FILE_TYPE_DISK && GetFileInformationByHandle(file, &info) != 0 && + (info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + info.nNumberOfLinks == 1 && GetFileSizeEx(file, size_out) != 0 && size_out->QuadPart >= 0; DWORD owner_result = security->get_security_info( - file, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, NULL, NULL, - NULL, &descriptor); - bool owner_ok = owner_result == ERROR_SUCCESS && owner && - security->equal_sid(owner, security->user_sid); + file, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, NULL, NULL, NULL, &descriptor); + bool owner_ok = + owner_result == ERROR_SUCCESS && owner && security->equal_sid(owner, security->user_sid); if (descriptor) { (void)LocalFree(descriptor); } DWORD secure_result = ERROR_ACCESS_DENIED; if (valid && owner_ok) { secure_result = security->set_security_info( - file, SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + file, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, NULL, NULL, security->acl, NULL); } if (!valid || !owner_ok || secure_result != ERROR_SUCCESS) { @@ -4338,11 +4086,10 @@ static HANDLE win_private_log_file_open( return file; } -FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, - const char *base_name, +FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, const char *base_name, size_t rotate_cap_bytes) { - if (!directory_path || !directory_path[0] || - !private_log_base_name_valid(base_name) || rotate_cap_bytes == 0) { + if (!directory_path || !directory_path[0] || !private_log_base_name_valid(base_name) || + rotate_cap_bytes == 0) { return NULL; } wchar_t *wide_directory = utf8_to_wide(directory_path); @@ -4350,8 +4097,8 @@ FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, char *rotated_path = path ? string_format("%s.1", path) : NULL; wchar_t *wide_path = path ? utf8_to_wide(path) : NULL; wchar_t *wide_rotated = rotated_path ? utf8_to_wide(rotated_path) : NULL; - if (!wide_directory || !path || !rotated_path || !wide_path || - !wide_rotated || !win_private_directory_tree_secure(wide_directory)) { + if (!wide_directory || !path || !rotated_path || !wide_path || !wide_rotated || + !win_private_directory_tree_secure(wide_directory)) { free(wide_directory); free(path); free(rotated_path); @@ -4370,8 +4117,7 @@ FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, return NULL; } LARGE_INTEGER size; - HANDLE file = win_private_log_file_open(wide_path, OPEN_ALWAYS, &security, - &size); + HANDLE file = win_private_log_file_open(wide_path, OPEN_ALWAYS, &security, &size); bool ok = file != INVALID_HANDLE_VALUE; if (ok && (uint64_t)size.QuadPart > (uint64_t)rotate_cap_bytes) { (void)CloseHandle(file); @@ -4379,25 +4125,22 @@ FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, DWORD attributes = GetFileAttributesW(wide_rotated); DWORD attributes_error = attributes == INVALID_FILE_ATTRIBUTES ? GetLastError() : ERROR_SUCCESS; - bool destination_ok = attributes == INVALID_FILE_ATTRIBUTES && - (attributes_error == ERROR_FILE_NOT_FOUND || - attributes_error == ERROR_PATH_NOT_FOUND); + bool destination_ok = + attributes == INVALID_FILE_ATTRIBUTES && + (attributes_error == ERROR_FILE_NOT_FOUND || attributes_error == ERROR_PATH_NOT_FOUND); if (attributes != INVALID_FILE_ATTRIBUTES) { LARGE_INTEGER destination_size; - HANDLE destination = win_private_log_file_open( - wide_rotated, OPEN_EXISTING, &security, &destination_size); + HANDLE destination = win_private_log_file_open(wide_rotated, OPEN_EXISTING, &security, + &destination_size); destination_ok = destination != INVALID_HANDLE_VALUE; if (destination != INVALID_HANDLE_VALUE) { (void)CloseHandle(destination); } } - ok = destination_ok && - MoveFileExW(wide_path, wide_rotated, - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != - 0; + ok = destination_ok && MoveFileExW(wide_path, wide_rotated, + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; if (ok) { - file = win_private_log_file_open(wide_path, OPEN_ALWAYS, &security, - &size); + file = win_private_log_file_open(wide_path, OPEN_ALWAYS, &security, &size); ok = file != INVALID_HANDLE_VALUE; } } @@ -4407,8 +4150,7 @@ FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, ok = SetFilePointerEx(file, end, NULL, FILE_END) != 0; } if (ok) { - int fd = _open_osfhandle((intptr_t)file, - _O_WRONLY | _O_APPEND | _O_BINARY); + int fd = _open_osfhandle((intptr_t)file, _O_WRONLY | _O_APPEND | _O_BINARY); if (fd >= 0) { file = INVALID_HANDLE_VALUE; stream = _fdopen(fd, "ab"); @@ -4493,9 +4235,7 @@ cbm_daemon_ipc_endpoint_t *cbm_daemon_ipc_endpoint_new(const char *instance_key, } DWORD sid_length = identity_security.get_length_sid(identity_security.user_sid); bool identity_ok = sid_length > 0 && - windows_sid_valid( - (const uint8_t *)identity_security.user_sid, - sid_length); + windows_sid_valid((const uint8_t *)identity_security.user_sid, sid_length); if (identity_ok) { endpoint->user_sid = identity_security.user_sid; endpoint->user_sid_length = sid_length; @@ -4509,23 +4249,20 @@ cbm_daemon_ipc_endpoint_t *cbm_daemon_ipc_endpoint_new(const char *instance_key, } char legacy_pipe[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]; char legacy_startup[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]; - bool legacy_names_ok = cbm_daemon_ipc_windows_legacy_names( - parent_utf8, instance_key, legacy_pipe, legacy_startup); + bool legacy_names_ok = + cbm_daemon_ipc_windows_legacy_names(parent_utf8, instance_key, legacy_pipe, legacy_startup); endpoint->runtime_dir = string_format("%s%scbm-daemon-%s", parent_utf8, has_separator ? "" : "/", instance_key); - endpoint->legacy_pipe_name = - legacy_names_ok ? utf8_to_wide(legacy_pipe) : NULL; - endpoint->legacy_startup_mutex_name = - legacy_names_ok ? utf8_to_wide(legacy_startup) : NULL; - (void)memcpy(endpoint->instance_key, instance_key, - sizeof(endpoint->instance_key)); + endpoint->legacy_pipe_name = legacy_names_ok ? utf8_to_wide(legacy_pipe) : NULL; + endpoint->legacy_startup_mutex_name = legacy_names_ok ? utf8_to_wide(legacy_startup) : NULL; + (void)memcpy(endpoint->instance_key, instance_key, sizeof(endpoint->instance_key)); cbm_mutex_init(&endpoint->generations_lock); atomic_init(&endpoint->current_generation, NULL); free(parent_utf8); wchar_t *runtime_wide = utf8_to_wide(endpoint->runtime_dir); if (!endpoint->runtime_dir || !runtime_wide || !legacy_names_ok || !endpoint->legacy_pipe_name || !endpoint->legacy_startup_mutex_name || - !win_runtime_directory_secure(runtime_wide)) { + !win_private_directory_tree_secure(runtime_wide)) { free(runtime_wide); cbm_daemon_ipc_endpoint_free(endpoint); return NULL; @@ -4558,12 +4295,10 @@ void cbm_daemon_ipc_endpoint_free(cbm_daemon_ipc_endpoint_t *endpoint) { } const char *cbm_daemon_ipc_endpoint_address(const cbm_daemon_ipc_endpoint_t *endpoint) { - if (!endpoint || win_endpoint_refresh_rendezvous(endpoint) != - WIN_RENDEZVOUS_VALID) { + if (!endpoint || win_endpoint_refresh_rendezvous(endpoint) != WIN_RENDEZVOUS_VALID) { return NULL; } - win_generation_address_t *generation = - win_endpoint_generation_snapshot(endpoint); + win_generation_address_t *generation = win_endpoint_generation_snapshot(endpoint); return generation ? generation->address : NULL; } @@ -4572,8 +4307,7 @@ const char *cbm_daemon_ipc_endpoint_runtime_dir(const cbm_daemon_ipc_endpoint_t } cbm_private_file_lock_status_t cbm_daemon_ipc_private_lock_directory_new( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_private_lock_directory_t **directory_out) { + const cbm_daemon_ipc_endpoint_t *endpoint, cbm_private_lock_directory_t **directory_out) { if (directory_out) { *directory_out = NULL; } @@ -4584,19 +4318,17 @@ cbm_private_file_lock_status_t cbm_daemon_ipc_private_lock_directory_new( if (!runtime_wide) { return CBM_PRIVATE_FILE_LOCK_IO; } - HANDLE directory = CreateFileW( - runtime_wide, FILE_READ_ATTRIBUTES | READ_CONTROL, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, - OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + HANDLE directory = + CreateFileW(runtime_wide, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); free(runtime_wide); if (directory == INVALID_HANDLE_VALUE) { return CBM_PRIVATE_FILE_LOCK_IO; } (void)SetHandleInformation(directory, HANDLE_FLAG_INHERIT, 0); cbm_private_file_lock_status_t status = - cbm_private_lock_directory_adopt_windows(directory, endpoint->runtime_dir, - directory_out); + cbm_private_lock_directory_adopt_windows(directory, endpoint->runtime_dir, directory_out); if (status != CBM_PRIVATE_FILE_LOCK_OK) { (void)CloseHandle(directory); } @@ -4604,13 +4336,11 @@ cbm_private_file_lock_status_t cbm_daemon_ipc_private_lock_directory_new( } static const char WIN_STARTUP_V2_LOCK_NAME[] = "cbm-startup-v2.lock"; -static const char WIN_PARTICIPANT_GROUP_LOCK_NAME[] = - "cbm-participant-group-v1.lock"; +static const char WIN_PARTICIPANT_GROUP_LOCK_NAME[] = "cbm-participant-group-v1.lock"; static const char WIN_LIFETIME_LOCK_NAME[] = "cbm-lifetime.lock"; static void win_private_lock_release_complete(cbm_private_file_lock_t **lock_io) { - uint64_t deadline = - ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); + uint64_t deadline = ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); while (lock_io && *lock_io) { (void)cbm_private_file_lock_release(lock_io); if (!*lock_io) { @@ -4625,28 +4355,23 @@ static void win_private_lock_release_complete(cbm_private_file_lock_t **lock_io) static win_generation_address_t *win_endpoint_generation_snapshot( const cbm_daemon_ipc_endpoint_t *endpoint) { - return endpoint - ? atomic_load_explicit(&endpoint->current_generation, - memory_order_acquire) - : NULL; + return endpoint ? atomic_load_explicit(&endpoint->current_generation, memory_order_acquire) + : NULL; } -static void win_endpoint_generation_invalidate( - const cbm_daemon_ipc_endpoint_t *endpoint) { +static void win_endpoint_generation_invalidate(const cbm_daemon_ipc_endpoint_t *endpoint) { if (endpoint) { - atomic_store_explicit( - &((cbm_daemon_ipc_endpoint_t *)endpoint)->current_generation, - NULL, memory_order_release); + atomic_store_explicit(&((cbm_daemon_ipc_endpoint_t *)endpoint)->current_generation, NULL, + memory_order_release); } } -static bool win_endpoint_generation_install( - const cbm_daemon_ipc_endpoint_t *endpoint, const char *address) { +static bool win_endpoint_generation_install(const cbm_daemon_ipc_endpoint_t *endpoint, + const char *address) { if (!endpoint || !windows_pipe_address_valid(address)) { return false; } - win_generation_address_t *snapshot = - win_endpoint_generation_snapshot(endpoint); + win_generation_address_t *snapshot = win_endpoint_generation_snapshot(endpoint); if (snapshot && strcmp(snapshot->address, address) == 0) { return true; } @@ -4661,11 +4386,10 @@ static bool win_endpoint_generation_install( return false; } - cbm_daemon_ipc_endpoint_t *mutable_endpoint = - (cbm_daemon_ipc_endpoint_t *)endpoint; + cbm_daemon_ipc_endpoint_t *mutable_endpoint = (cbm_daemon_ipc_endpoint_t *)endpoint; cbm_mutex_lock(&mutable_endpoint->generations_lock); - win_generation_address_t *current = atomic_load_explicit( - &mutable_endpoint->current_generation, memory_order_acquire); + win_generation_address_t *current = + atomic_load_explicit(&mutable_endpoint->current_generation, memory_order_acquire); if (current && strcmp(current->address, address) == 0) { cbm_mutex_unlock(&mutable_endpoint->generations_lock); free(candidate->pipe_name); @@ -4674,8 +4398,7 @@ static bool win_endpoint_generation_install( } candidate->next = mutable_endpoint->generations; mutable_endpoint->generations = candidate; - atomic_store_explicit(&mutable_endpoint->current_generation, candidate, - memory_order_release); + atomic_store_explicit(&mutable_endpoint->current_generation, candidate, memory_order_release); cbm_mutex_unlock(&mutable_endpoint->generations_lock); return true; } @@ -4693,35 +4416,31 @@ static win_rendezvous_status_t win_endpoint_refresh_rendezvous( win_endpoint_generation_invalidate(endpoint); return WIN_RENDEZVOUS_ERROR; } - cbm_private_file_lock_status_t lock_status = - cbm_private_file_lock_try_acquire( - directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, - CBM_PRIVATE_FILE_LOCK_SH, &record_lock); + cbm_private_file_lock_status_t lock_status = cbm_private_file_lock_try_acquire( + directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, CBM_PRIVATE_FILE_LOCK_SH, &record_lock); if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) { win_private_lock_release_complete(&record_lock); cbm_private_lock_directory_close(directory); win_endpoint_generation_invalidate(endpoint); - return lock_status == CBM_PRIVATE_FILE_LOCK_BUSY - ? WIN_RENDEZVOUS_BUSY - : WIN_RENDEZVOUS_ERROR; + return lock_status == CBM_PRIVATE_FILE_LOCK_BUSY ? WIN_RENDEZVOUS_BUSY + : WIN_RENDEZVOUS_ERROR; } uint8_t record[CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE]; size_t record_length = 0; cbm_private_file_lock_status_t read_status = - cbm_private_file_lock_payload_read(record_lock, record, sizeof(record), - &record_length); + cbm_private_file_lock_payload_read(record_lock, record, sizeof(record), &record_length); uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE]; char address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]; - bool decoded = read_status == CBM_PRIVATE_FILE_LOCK_OK && - cbm_daemon_ipc_windows_rendezvous_record_decode( - record, record_length, nonce, address); + bool decoded = + read_status == CBM_PRIVATE_FILE_LOCK_OK && + cbm_daemon_ipc_windows_rendezvous_record_decode(record, record_length, nonce, address); char expected[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]; - bool bound = decoded && cbm_daemon_ipc_windows_generation_address( - endpoint->user_sid, - endpoint->user_sid_length, - endpoint->instance_key, nonce, expected) && - strcmp(expected, address) == 0; + bool bound = + decoded && + cbm_daemon_ipc_windows_generation_address(endpoint->user_sid, endpoint->user_sid_length, + endpoint->instance_key, nonce, expected) && + strcmp(expected, address) == 0; win_rendezvous_status_t result; if (read_status != CBM_PRIVATE_FILE_LOCK_OK) { win_endpoint_generation_invalidate(endpoint); @@ -4746,33 +4465,26 @@ static win_rendezvous_status_t win_endpoint_refresh_rendezvous( return result; } -typedef LONG(WINAPI *bcrypt_gen_random_fn)(void *, unsigned char *, ULONG, - ULONG); +typedef LONG(WINAPI *bcrypt_gen_random_fn)(void *, unsigned char *, ULONG, ULONG); static bool win_generation_nonce(uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE]) { enum { WIN_BCRYPT_USE_SYSTEM_PREFERRED_RNG = 0x00000002 }; HMODULE bcrypt = LoadLibraryW(L"bcrypt.dll"); bcrypt_gen_random_fn generate = - bcrypt ? (bcrypt_gen_random_fn)GetProcAddress(bcrypt, - "BCryptGenRandom") - : NULL; - LONG status = generate - ? generate(NULL, nonce, - CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE, - WIN_BCRYPT_USE_SYSTEM_PREFERRED_RNG) - : (LONG)-1; + bcrypt ? (bcrypt_gen_random_fn)GetProcAddress(bcrypt, "BCryptGenRandom") : NULL; + LONG status = generate ? generate(NULL, nonce, CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE, + WIN_BCRYPT_USE_SYSTEM_PREFERRED_RNG) + : (LONG)-1; if (bcrypt) { (void)FreeLibrary(bcrypt); } return status >= 0; } -static int win_private_lock_probe(cbm_private_lock_directory_t *directory, - const char *base_name) { +static int win_private_lock_probe(cbm_private_lock_directory_t *directory, const char *base_name) { cbm_private_file_lock_t *probe = NULL; cbm_private_file_lock_status_t status = - cbm_private_file_lock_try_acquire(directory, base_name, - CBM_PRIVATE_FILE_LOCK_SH, &probe); + cbm_private_file_lock_try_acquire(directory, base_name, CBM_PRIVATE_FILE_LOCK_SH, &probe); if (status == CBM_PRIVATE_FILE_LOCK_BUSY) { return 1; } @@ -4784,22 +4496,18 @@ static int win_private_lock_probe(cbm_private_lock_directory_t *directory, return 0; } -static bool win_startup_publish_generation_locked( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_private_file_lock_t *record_lock) { +static bool win_startup_publish_generation_locked(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_private_file_lock_t *record_lock) { uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE]; char address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]; uint8_t record[CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE]; if (!record_lock || !win_generation_nonce(nonce) || - !cbm_daemon_ipc_windows_generation_address( - endpoint->user_sid, endpoint->user_sid_length, - endpoint->instance_key, nonce, address) || - !cbm_daemon_ipc_windows_rendezvous_record_encode(nonce, address, - record)) { + !cbm_daemon_ipc_windows_generation_address(endpoint->user_sid, endpoint->user_sid_length, + endpoint->instance_key, nonce, address) || + !cbm_daemon_ipc_windows_rendezvous_record_encode(nonce, address, record)) { return false; } - bool written = cbm_private_file_lock_payload_write( - record_lock, record, sizeof(record)) == + bool written = cbm_private_file_lock_payload_write(record_lock, record, sizeof(record)) == CBM_PRIVATE_FILE_LOCK_OK; return written && win_endpoint_generation_install(endpoint, address); } @@ -4809,26 +4517,21 @@ static bool win_startup_publish_generation_locked( * rendezvous SH refresh before this probe can declare lifetime free. If it * acquires lifetime after the probe, its final refresh waits for this publish * and therefore binds the newly advertised generation. */ -static int win_startup_prepare_generation( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_private_lock_directory_t *directory) { +static int win_startup_prepare_generation(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_private_lock_directory_t *directory) { uint64_t now = ipc_now_ms(); - uint64_t deadline = - now > UINT64_MAX - CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RETRY_MS - ? UINT64_MAX - : now + CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RETRY_MS; + uint64_t deadline = now > UINT64_MAX - CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RETRY_MS + ? UINT64_MAX + : now + CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RETRY_MS; for (;;) { cbm_private_file_lock_t *record_lock = NULL; cbm_private_file_lock_status_t status = - cbm_private_file_lock_try_acquire( - directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, - CBM_PRIVATE_FILE_LOCK_EX, &record_lock); + cbm_private_file_lock_try_acquire(directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &record_lock); if (status == CBM_PRIVATE_FILE_LOCK_OK) { - int lifetime = - win_private_lock_probe(directory, WIN_LIFETIME_LOCK_NAME); + int lifetime = win_private_lock_probe(directory, WIN_LIFETIME_LOCK_NAME); bool published = - lifetime == 0 && win_startup_publish_generation_locked( - endpoint, record_lock); + lifetime == 0 && win_startup_publish_generation_locked(endpoint, record_lock); win_private_lock_release_complete(&record_lock); if (lifetime != 0) { return lifetime == 1 ? 0 : -1; @@ -4856,15 +4559,11 @@ int cbm_daemon_ipc_lifetime_reservation_try_acquire( return -1; } - win_rendezvous_status_t rendezvous = - win_endpoint_refresh_rendezvous(endpoint); - if (rendezvous == WIN_RENDEZVOUS_ABSENT || - rendezvous == WIN_RENDEZVOUS_CORRUPT) { + win_rendezvous_status_t rendezvous = win_endpoint_refresh_rendezvous(endpoint); + if (rendezvous == WIN_RENDEZVOUS_ABSENT || rendezvous == WIN_RENDEZVOUS_CORRUPT) { cbm_daemon_ipc_startup_lock_t *startup = NULL; - int startup_status = cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &startup); - bool prepared = startup_status == 1 && - cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + int startup_status = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup); + bool prepared = startup_status == 1 && cbm_daemon_ipc_startup_lock_prepare_handoff(startup); ipc_startup_lock_release_complete(&startup); if (!prepared) { return startup_status == 0 ? 0 : -1; @@ -4872,10 +4571,7 @@ int cbm_daemon_ipc_lifetime_reservation_try_acquire( rendezvous = win_endpoint_refresh_rendezvous(endpoint); } if (rendezvous != WIN_RENDEZVOUS_VALID) { - return rendezvous == WIN_RENDEZVOUS_ABSENT || - rendezvous == WIN_RENDEZVOUS_BUSY - ? 0 - : -1; + return rendezvous == WIN_RENDEZVOUS_ABSENT || rendezvous == WIN_RENDEZVOUS_BUSY ? 0 : -1; } cbm_private_lock_directory_t *directory = NULL; @@ -4884,10 +4580,8 @@ int cbm_daemon_ipc_lifetime_reservation_try_acquire( CBM_PRIVATE_FILE_LOCK_OK) { return -1; } - cbm_private_file_lock_status_t status = - cbm_private_file_lock_try_acquire( - directory, WIN_LIFETIME_LOCK_NAME, CBM_PRIVATE_FILE_LOCK_EX, - &lock); + cbm_private_file_lock_status_t status = cbm_private_file_lock_try_acquire( + directory, WIN_LIFETIME_LOCK_NAME, CBM_PRIVATE_FILE_LOCK_EX, &lock); if (status != CBM_PRIVATE_FILE_LOCK_OK) { win_private_lock_release_complete(&lock); cbm_private_lock_directory_close(directory); @@ -4902,8 +4596,7 @@ int cbm_daemon_ipc_lifetime_reservation_try_acquire( cbm_private_lock_directory_close(directory); return 0; } - cbm_daemon_ipc_lifetime_reservation_t *reservation = - calloc(1, sizeof(*reservation)); + cbm_daemon_ipc_lifetime_reservation_t *reservation = calloc(1, sizeof(*reservation)); if (!reservation) { win_private_lock_release_complete(&lock); cbm_private_lock_directory_close(directory); @@ -4929,16 +4622,13 @@ void cbm_daemon_ipc_lifetime_reservation_release( static bool lifetime_reservation_matches_endpoint( const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_ipc_lifetime_reservation_t *reservation) { - return endpoint && reservation && reservation->endpoint == endpoint && - reservation->lock; + return endpoint && reservation && reservation->endpoint == endpoint && reservation->lock; } -int cbm_daemon_ipc_lifetime_reservation_probe( - const cbm_daemon_ipc_endpoint_t *endpoint) { +int cbm_daemon_ipc_lifetime_reservation_probe(const cbm_daemon_ipc_endpoint_t *endpoint) { cbm_private_lock_directory_t *directory = NULL; - if (!endpoint || - cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory) != - CBM_PRIVATE_FILE_LOCK_OK) { + if (!endpoint || cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory) != + CBM_PRIVATE_FILE_LOCK_OK) { return -1; } int result = win_private_lock_probe(directory, WIN_LIFETIME_LOCK_NAME); @@ -4946,10 +4636,8 @@ int cbm_daemon_ipc_lifetime_reservation_probe( return result; } -static int win_legacy_pipe_probe( - const cbm_daemon_ipc_endpoint_t *endpoint) { - if (!endpoint || !endpoint->legacy_pipe_name || - !endpoint->legacy_startup_mutex_name) { +static int win_legacy_pipe_probe(const cbm_daemon_ipc_endpoint_t *endpoint) { + if (!endpoint || !endpoint->legacy_pipe_name || !endpoint->legacy_startup_mutex_name) { return -1; } @@ -4960,15 +4648,13 @@ static int win_legacy_pipe_probe( if (pipe_error == ERROR_SEM_TIMEOUT || pipe_error == ERROR_PIPE_BUSY) { return 1; } - if (pipe_error != ERROR_FILE_NOT_FOUND && - pipe_error != ERROR_PATH_NOT_FOUND) { + if (pipe_error != ERROR_FILE_NOT_FOUND && pipe_error != ERROR_PATH_NOT_FOUND) { return -1; } return 0; } -int cbm_daemon_ipc_legacy_generation_probe( - const cbm_daemon_ipc_endpoint_t *endpoint) { +int cbm_daemon_ipc_legacy_generation_probe(const cbm_daemon_ipc_endpoint_t *endpoint) { int pipe = win_legacy_pipe_probe(endpoint); if (pipe != 0) { return pipe; @@ -4977,12 +4663,12 @@ int cbm_daemon_ipc_legacy_generation_probe( /* Open-only observation is sufficient: the legacy startup owner retains * the sole named-object handle until it releases startup. Never create or * wait on the mutex, so this compatibility path cannot become authority. */ - HANDLE startup = OpenMutexW(SYNCHRONIZE | READ_CONTROL, FALSE, - endpoint->legacy_startup_mutex_name); + HANDLE startup = + OpenMutexW(SYNCHRONIZE | READ_CONTROL, FALSE, endpoint->legacy_startup_mutex_name); if (startup) { win_security_t security; - bool safe = win_security_init(&security) && - win_kernel_mutex_current_user_only(&security, startup); + bool safe = + win_security_init(&security) && win_kernel_mutex_current_user_only(&security, startup); win_security_destroy(&security); (void)SetHandleInformation(startup, HANDLE_FLAG_INHERIT, 0); (void)CloseHandle(startup); @@ -5017,9 +4703,8 @@ static HANDLE win_pipe_instance_new(const wchar_t *pipe_name, bool first_instanc * FILE_FLAG_FIRST_PIPE_INSTANCE; later participants use the same exact pipe * parameters without that flag. Never calling ConnectNamedPipe means no * sentinel thread or cancellable I/O survives a participant. */ -static HANDLE win_legacy_sentinel_new( - const cbm_daemon_ipc_endpoint_t *endpoint, bool first, - DWORD *error_out) { +static HANDLE win_legacy_sentinel_new(const cbm_daemon_ipc_endpoint_t *endpoint, bool first, + DWORD *error_out) { if (error_out) { *error_out = ERROR_INVALID_PARAMETER; } @@ -5036,14 +4721,11 @@ static HANDLE win_legacy_sentinel_new( if (first) { open_mode |= FILE_FLAG_FIRST_PIPE_INSTANCE; } - HANDLE sentinel = CreateNamedPipeW( - endpoint->legacy_pipe_name, open_mode, - PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | - PIPE_REJECT_REMOTE_CLIENTS, - PIPE_UNLIMITED_INSTANCES, 1, 1, 0, &security.attributes); - DWORD create_error = sentinel == INVALID_HANDLE_VALUE - ? GetLastError() - : ERROR_SUCCESS; + HANDLE sentinel = CreateNamedPipeW(endpoint->legacy_pipe_name, open_mode, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | + PIPE_REJECT_REMOTE_CLIENTS, + PIPE_UNLIMITED_INSTANCES, 1, 1, 0, &security.attributes); + DWORD create_error = sentinel == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS; win_security_destroy(&security); if (sentinel != INVALID_HANDLE_VALUE && SetHandleInformation(sentinel, HANDLE_FLAG_INHERIT, 0) == 0) { @@ -5055,19 +4737,16 @@ static HANDLE win_legacy_sentinel_new( return sentinel; } -static int win_startup_v2_try_acquire( - cbm_private_lock_directory_t *directory, - cbm_private_file_lock_t **lock_out) { +static int win_startup_v2_try_acquire(cbm_private_lock_directory_t *directory, + cbm_private_file_lock_t **lock_out) { if (lock_out) { *lock_out = NULL; } if (!directory || !lock_out) { return -1; } - cbm_private_file_lock_status_t status = - cbm_private_file_lock_try_acquire( - directory, WIN_STARTUP_V2_LOCK_NAME, - CBM_PRIVATE_FILE_LOCK_EX, lock_out); + cbm_private_file_lock_status_t status = cbm_private_file_lock_try_acquire( + directory, WIN_STARTUP_V2_LOCK_NAME, CBM_PRIVATE_FILE_LOCK_EX, lock_out); if (status == CBM_PRIVATE_FILE_LOCK_OK) { return 1; } @@ -5084,10 +4763,10 @@ static bool win_legacy_sentinel_conflict(DWORD error) { * observations. startup-v2 is retained by this process, or by the bootstrap * parent when a child joins. The SH lock is acquired before the mutex is * released, so group existence and at least one sentinel have no gap. */ -static int win_participant_group_join_locked( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_private_lock_directory_t *directory, bool allow_first, - cbm_private_file_lock_t **group_out, HANDLE *sentinel_out) { +static int win_participant_group_join_locked(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_private_lock_directory_t *directory, + bool allow_first, cbm_private_file_lock_t **group_out, + HANDLE *sentinel_out) { if (group_out) { *group_out = NULL; } @@ -5099,10 +4778,8 @@ static int win_participant_group_join_locked( } cbm_private_file_lock_t *exclusive_probe = NULL; - cbm_private_file_lock_status_t group_status = - cbm_private_file_lock_try_acquire( - directory, WIN_PARTICIPANT_GROUP_LOCK_NAME, - CBM_PRIVATE_FILE_LOCK_EX, &exclusive_probe); + cbm_private_file_lock_status_t group_status = cbm_private_file_lock_try_acquire( + directory, WIN_PARTICIPANT_GROUP_LOCK_NAME, CBM_PRIVATE_FILE_LOCK_EX, &exclusive_probe); bool first = group_status == CBM_PRIVATE_FILE_LOCK_OK; if (!first && group_status != CBM_PRIVATE_FILE_LOCK_BUSY) { win_private_lock_release_complete(&exclusive_probe); @@ -5119,8 +4796,7 @@ static int win_participant_group_join_locked( return pipe < 0 ? -1 : 0; } DWORD create_error = ERROR_SUCCESS; - HANDLE sentinel = - win_legacy_sentinel_new(endpoint, first, &create_error); + HANDLE sentinel = win_legacy_sentinel_new(endpoint, first, &create_error); if (sentinel == INVALID_HANDLE_VALUE) { win_private_lock_release_complete(&exclusive_probe); return win_legacy_sentinel_conflict(create_error) ? 0 : -1; @@ -5134,9 +4810,8 @@ static int win_participant_group_join_locked( } cbm_private_file_lock_t *group = NULL; - group_status = cbm_private_file_lock_try_acquire( - directory, WIN_PARTICIPANT_GROUP_LOCK_NAME, - CBM_PRIVATE_FILE_LOCK_SH, &group); + group_status = cbm_private_file_lock_try_acquire(directory, WIN_PARTICIPANT_GROUP_LOCK_NAME, + CBM_PRIVATE_FILE_LOCK_SH, &group); if (group_status != CBM_PRIVATE_FILE_LOCK_OK) { win_private_lock_release_complete(&group); (void)CloseHandle(sentinel); @@ -5150,14 +4825,12 @@ static int win_participant_group_join_locked( /* Called only while startup-v2 and the validated legacy mutex are retained. * Group SH disappears before this participant's sentinel; both gates exclude * joiners from observing that required teardown intermediate state. */ -static bool win_participant_group_release_locked( - cbm_private_file_lock_t **group_io, HANDLE *sentinel_io) { +static bool win_participant_group_release_locked(cbm_private_file_lock_t **group_io, + HANDLE *sentinel_io) { if (!group_io || !sentinel_io) { return false; } - if (*group_io && - cbm_private_file_lock_release(group_io) != - CBM_PRIVATE_FILE_LOCK_OK) { + if (*group_io && cbm_private_file_lock_release(group_io) != CBM_PRIVATE_FILE_LOCK_OK) { return false; } if (*sentinel_io != INVALID_HANDLE_VALUE) { @@ -5169,15 +4842,12 @@ static bool win_participant_group_release_locked( return true; } -static void win_participant_group_release_complete( - cbm_private_file_lock_t **group_io, HANDLE *sentinel_io) { - uint64_t deadline = - ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); - while ((group_io && *group_io) || - (sentinel_io && *sentinel_io != INVALID_HANDLE_VALUE)) { +static void win_participant_group_release_complete(cbm_private_file_lock_t **group_io, + HANDLE *sentinel_io) { + uint64_t deadline = ipc_deadline_after(CBM_DAEMON_IPC_COORDINATION_CLEANUP_MS); + while ((group_io && *group_io) || (sentinel_io && *sentinel_io != INVALID_HANDLE_VALUE)) { (void)win_participant_group_release_locked(group_io, sentinel_io); - if ((!group_io || !*group_io) && - (!sentinel_io || *sentinel_io == INVALID_HANDLE_VALUE)) { + if ((!group_io || !*group_io) && (!sentinel_io || *sentinel_io == INVALID_HANDLE_VALUE)) { return; } if (ipc_now_ms() >= deadline) { @@ -5187,14 +4857,13 @@ static void win_participant_group_release_complete( } } -static bool win_participant_state_release( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_private_lock_directory_t *directory, - cbm_private_file_lock_t **startup_v2_io, - win_legacy_mutex_guard_t **legacy_guard_io, - cbm_private_file_lock_t **group_io, HANDLE *sentinel_io) { - if (!endpoint || !directory || !startup_v2_io || !legacy_guard_io || - !group_io || !sentinel_io) { +static bool win_participant_state_release(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_private_lock_directory_t *directory, + cbm_private_file_lock_t **startup_v2_io, + win_legacy_mutex_guard_t **legacy_guard_io, + cbm_private_file_lock_t **group_io, HANDLE *sentinel_io) { + if (!endpoint || !directory || !startup_v2_io || !legacy_guard_io || !group_io || + !sentinel_io) { return false; } if (!*startup_v2_io) { @@ -5204,8 +4873,7 @@ static bool win_participant_state_release( } } if (!*legacy_guard_io) { - int legacy = - win_legacy_mutex_guard_try_acquire(endpoint, legacy_guard_io); + int legacy = win_legacy_mutex_guard_try_acquire(endpoint, legacy_guard_io); if (legacy != 1) { (void)cbm_private_file_lock_release(startup_v2_io); return false; @@ -5217,8 +4885,7 @@ static bool win_participant_state_release( if (!win_legacy_mutex_guard_release(legacy_guard_io)) { return false; } - return cbm_private_file_lock_release(startup_v2_io) == - CBM_PRIVATE_FILE_LOCK_OK; + return cbm_private_file_lock_release(startup_v2_io) == CBM_PRIVATE_FILE_LOCK_OK; } cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( @@ -5226,14 +4893,11 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( cbm_daemon_ipc_lifetime_reservation_t **reservation_io) { cbm_daemon_ipc_lifetime_reservation_t *lifetime_reservation = reservation_io ? *reservation_io : NULL; - if (!lifetime_reservation_matches_endpoint(endpoint, - lifetime_reservation) || - win_endpoint_refresh_rendezvous(endpoint) != - WIN_RENDEZVOUS_VALID) { + if (!lifetime_reservation_matches_endpoint(endpoint, lifetime_reservation) || + win_endpoint_refresh_rendezvous(endpoint) != WIN_RENDEZVOUS_VALID) { return NULL; } - win_generation_address_t *generation = - win_endpoint_generation_snapshot(endpoint); + win_generation_address_t *generation = win_endpoint_generation_snapshot(endpoint); if (!generation || !generation->pipe_name) { return NULL; } @@ -5261,24 +4925,20 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( return listener; } -cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen( - const cbm_daemon_ipc_endpoint_t *endpoint) { +cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen(const cbm_daemon_ipc_endpoint_t *endpoint) { cbm_daemon_ipc_startup_lock_t *startup = NULL; cbm_daemon_ipc_participant_guard_t *participant_guard = NULL; cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL; if (cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) != 1 || !cbm_daemon_ipc_startup_lock_prepare_handoff(startup) || - cbm_daemon_ipc_participant_guard_try_join( - endpoint, &participant_guard) != 1 || - cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, - &reservation) != 1) { + cbm_daemon_ipc_participant_guard_try_join(endpoint, &participant_guard) != 1 || + cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &reservation) != 1) { cbm_daemon_ipc_lifetime_reservation_release(reservation); ipc_participant_guard_release_complete(&participant_guard); ipc_startup_lock_release_complete(&startup); return NULL; } - cbm_daemon_ipc_listener_t *listener = - cbm_daemon_ipc_listen_reserved(endpoint, &reservation); + cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen_reserved(endpoint, &reservation); if (listener) { listener->participant_guard = participant_guard; participant_guard = NULL; @@ -5298,8 +4958,7 @@ void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener) { (void)DisconnectNamedPipe(listener->pipe); (void)CloseHandle(listener->pipe); } - cbm_daemon_ipc_lifetime_reservation_release( - listener->lifetime_reservation); + cbm_daemon_ipc_lifetime_reservation_release(listener->lifetime_reservation); ipc_participant_guard_release_complete(&listener->participant_guard); free(listener->pipe_name); free(listener); @@ -5471,30 +5130,25 @@ static bool win_pipe_server_is_current_user(HANDLE pipe) { return same_user; } -static int win_current_generation_transport_probe( - const cbm_daemon_ipc_endpoint_t *endpoint) { - win_rendezvous_status_t rendezvous = - win_endpoint_refresh_rendezvous(endpoint); +static int win_current_generation_transport_probe(const cbm_daemon_ipc_endpoint_t *endpoint) { + win_rendezvous_status_t rendezvous = win_endpoint_refresh_rendezvous(endpoint); if (rendezvous == WIN_RENDEZVOUS_ABSENT) { return 0; } if (rendezvous != WIN_RENDEZVOUS_VALID) { return -1; } - win_generation_address_t *generation = - win_endpoint_generation_snapshot(endpoint); + win_generation_address_t *generation = win_endpoint_generation_snapshot(endpoint); if (!generation || !generation->pipe_name) { return -1; } - HANDLE pipe = CreateFileW(generation->pipe_name, - GENERIC_READ | GENERIC_WRITE, 0, NULL, + HANDLE pipe = CreateFileW(generation->pipe_name, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); if (pipe != INVALID_HANDLE_VALUE) { (void)SetHandleInformation(pipe, HANDLE_FLAG_INHERIT, 0); DWORD mode = PIPE_READMODE_BYTE; - bool authenticated = - SetNamedPipeHandleState(pipe, &mode, NULL, NULL) != 0 && - win_pipe_server_is_current_user(pipe); + bool authenticated = SetNamedPipeHandleState(pipe, &mode, NULL, NULL) != 0 && + win_pipe_server_is_current_user(pipe); (void)CloseHandle(pipe); return authenticated ? 1 : -1; } @@ -5502,22 +5156,17 @@ static int win_current_generation_transport_probe( if (error == ERROR_PIPE_BUSY) { return 1; } - return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND - ? 0 - : -1; + return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND ? 0 : -1; } -int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, - uint32_t timeout_ms) { +int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, uint32_t timeout_ms) { (void)timeout_ms; if (!endpoint) { return -1; } - win_rendezvous_status_t rendezvous = - win_endpoint_refresh_rendezvous(endpoint); + win_rendezvous_status_t rendezvous = win_endpoint_refresh_rendezvous(endpoint); if (rendezvous != WIN_RENDEZVOUS_VALID) { - if (rendezvous == WIN_RENDEZVOUS_CORRUPT || - rendezvous == WIN_RENDEZVOUS_ERROR) { + if (rendezvous == WIN_RENDEZVOUS_CORRUPT || rendezvous == WIN_RENDEZVOUS_ERROR) { return -1; } cbm_private_lock_directory_t *directory = NULL; @@ -5525,13 +5174,11 @@ int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, CBM_PRIVATE_FILE_LOCK_OK) { return -1; } - int startup = - win_private_lock_probe(directory, WIN_STARTUP_V2_LOCK_NAME); + int startup = win_private_lock_probe(directory, WIN_STARTUP_V2_LOCK_NAME); cbm_private_lock_directory_close(directory); return startup; } - win_generation_address_t *generation = - win_endpoint_generation_snapshot(endpoint); + win_generation_address_t *generation = win_endpoint_generation_snapshot(endpoint); if (!generation) { return -1; } @@ -5561,8 +5208,7 @@ int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, CBM_PRIVATE_FILE_LOCK_OK) { return -1; } - int startup = - win_private_lock_probe(directory, WIN_STARTUP_V2_LOCK_NAME); + int startup = win_private_lock_probe(directory, WIN_STARTUP_V2_LOCK_NAME); cbm_private_lock_directory_close(directory); return startup; } @@ -5577,16 +5223,12 @@ cbm_daemon_ipc_connection_t *cbm_daemon_ipc_connect(const cbm_daemon_ipc_endpoin uint64_t deadline_ms = ipc_deadline_after(timeout_ms); HANDLE pipe = INVALID_HANDLE_VALUE; for (;;) { - win_rendezvous_status_t rendezvous = - win_endpoint_refresh_rendezvous(endpoint); - if (rendezvous == WIN_RENDEZVOUS_CORRUPT || - rendezvous == WIN_RENDEZVOUS_ERROR) { + win_rendezvous_status_t rendezvous = win_endpoint_refresh_rendezvous(endpoint); + if (rendezvous == WIN_RENDEZVOUS_CORRUPT || rendezvous == WIN_RENDEZVOUS_ERROR) { return NULL; } win_generation_address_t *generation = - rendezvous == WIN_RENDEZVOUS_VALID - ? win_endpoint_generation_snapshot(endpoint) - : NULL; + rendezvous == WIN_RENDEZVOUS_VALID ? win_endpoint_generation_snapshot(endpoint) : NULL; if (!generation) { if (win_retry_pause(deadline_ms)) { continue; @@ -5658,8 +5300,7 @@ void cbm_daemon_ipc_connection_interrupt(cbm_daemon_ipc_connection_t *connection } } -uint64_t cbm_daemon_ipc_connection_peer_pid( - const cbm_daemon_ipc_connection_t *connection) { +uint64_t cbm_daemon_ipc_connection_peer_pid(const cbm_daemon_ipc_connection_t *connection) { if (!connection || !connection->handle || connection->handle == INVALID_HANDLE_VALUE) { return 0; } @@ -5778,15 +5419,13 @@ int cbm_daemon_ipc_startup_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *end return -1; } cbm_private_file_lock_t *startup_v2 = NULL; - int startup_status = - win_startup_v2_try_acquire(directory, &startup_v2); + int startup_status = win_startup_v2_try_acquire(directory, &startup_v2); if (startup_status != 1) { cbm_private_lock_directory_close(directory); return startup_status; } win_legacy_mutex_guard_t *legacy_guard = NULL; - int legacy_status = - win_legacy_mutex_guard_try_acquire(endpoint, &legacy_guard); + int legacy_status = win_legacy_mutex_guard_try_acquire(endpoint, &legacy_guard); if (legacy_status != 1) { win_private_lock_release_complete(&startup_v2); cbm_private_lock_directory_close(directory); @@ -5810,12 +5449,10 @@ int cbm_daemon_ipc_startup_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *end } int cbm_daemon_ipc_generation_probe_under_startup_lock( - const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_ipc_startup_lock_t *startup_lock) { + const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_ipc_startup_lock_t *startup_lock) { if (!endpoint || !startup_lock || startup_lock->endpoint != endpoint || - !startup_lock->directory || !startup_lock->startup_v2_lock || - !startup_lock->legacy_guard || startup_lock->prepared || - startup_lock->group_lock || + !startup_lock->directory || !startup_lock->startup_v2_lock || !startup_lock->legacy_guard || + startup_lock->prepared || startup_lock->group_lock || startup_lock->legacy_sentinel != INVALID_HANDLE_VALUE) { return -1; } @@ -5830,32 +5467,25 @@ int cbm_daemon_ipc_generation_probe_under_startup_lock( return win_current_generation_transport_probe(endpoint); } -bool cbm_daemon_ipc_startup_lock_prepare_handoff( - cbm_daemon_ipc_startup_lock_t *lock) { - if (!lock || !lock->endpoint || !lock->directory || - !lock->startup_v2_lock) { +bool cbm_daemon_ipc_startup_lock_prepare_handoff(cbm_daemon_ipc_startup_lock_t *lock) { + if (!lock || !lock->endpoint || !lock->directory || !lock->startup_v2_lock) { return false; } if (lock->prepared) { - return lock->group_lock && - lock->legacy_sentinel != INVALID_HANDLE_VALUE && + return lock->group_lock && lock->legacy_sentinel != INVALID_HANDLE_VALUE && !lock->legacy_guard; } - if (!lock->legacy_guard || lock->group_lock || - lock->legacy_sentinel != INVALID_HANDLE_VALUE) { + if (!lock->legacy_guard || lock->group_lock || lock->legacy_sentinel != INVALID_HANDLE_VALUE) { return false; } - int joined = win_participant_group_join_locked( - lock->endpoint, lock->directory, true, &lock->group_lock, - &lock->legacy_sentinel); + int joined = win_participant_group_join_locked(lock->endpoint, lock->directory, true, + &lock->group_lock, &lock->legacy_sentinel); if (joined != 1) { return false; } - int generation = - win_startup_prepare_generation(lock->endpoint, lock->directory); + int generation = win_startup_prepare_generation(lock->endpoint, lock->directory); if (generation != 1) { - (void)win_participant_group_release_locked( - &lock->group_lock, &lock->legacy_sentinel); + (void)win_participant_group_release_locked(&lock->group_lock, &lock->legacy_sentinel); return false; } if (!win_legacy_mutex_guard_release(&lock->legacy_guard)) { @@ -5865,8 +5495,7 @@ bool cbm_daemon_ipc_startup_lock_prepare_handoff( return true; } -bool cbm_daemon_ipc_startup_lock_release( - cbm_daemon_ipc_startup_lock_t **lock_io) { +bool cbm_daemon_ipc_startup_lock_release(cbm_daemon_ipc_startup_lock_t **lock_io) { if (!lock_io) { return false; } @@ -5874,10 +5503,9 @@ bool cbm_daemon_ipc_startup_lock_release( if (!lock) { return true; } - if (!win_participant_state_release( - lock->endpoint, lock->directory, &lock->startup_v2_lock, - &lock->legacy_guard, &lock->group_lock, - &lock->legacy_sentinel)) { + if (!win_participant_state_release(lock->endpoint, lock->directory, &lock->startup_v2_lock, + &lock->legacy_guard, &lock->group_lock, + &lock->legacy_sentinel)) { return false; } cbm_private_lock_directory_close(lock->directory); @@ -5886,17 +5514,15 @@ bool cbm_daemon_ipc_startup_lock_release( return true; } -int cbm_daemon_ipc_participant_guard_try_join( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_daemon_ipc_participant_guard_t **guard_out) { +int cbm_daemon_ipc_participant_guard_try_join(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t **guard_out) { if (guard_out) { *guard_out = NULL; } if (!endpoint || !guard_out) { return -1; } - cbm_daemon_ipc_participant_guard_t *guard = - calloc(1, sizeof(*guard)); + cbm_daemon_ipc_participant_guard_t *guard = calloc(1, sizeof(*guard)); if (!guard) { return -1; } @@ -5907,8 +5533,7 @@ int cbm_daemon_ipc_participant_guard_try_join( return -1; } win_legacy_mutex_guard_t *legacy_guard = NULL; - int legacy = - win_legacy_mutex_guard_try_acquire(endpoint, &legacy_guard); + int legacy = win_legacy_mutex_guard_try_acquire(endpoint, &legacy_guard); if (legacy != 1) { cbm_private_lock_directory_close(directory); free(guard); @@ -5916,10 +5541,8 @@ int cbm_daemon_ipc_participant_guard_try_join( } cbm_private_file_lock_t *group = NULL; HANDLE sentinel = INVALID_HANDLE_VALUE; - int joined = win_participant_group_join_locked( - endpoint, directory, false, &group, &sentinel); - bool mutex_released = - win_legacy_mutex_guard_release(&legacy_guard); + int joined = win_participant_group_join_locked(endpoint, directory, false, &group, &sentinel); + bool mutex_released = win_legacy_mutex_guard_release(&legacy_guard); if (joined != 1 || !mutex_released) { if (group || sentinel != INVALID_HANDLE_VALUE) { win_participant_group_release_complete(&group, &sentinel); @@ -5937,8 +5560,7 @@ int cbm_daemon_ipc_participant_guard_try_join( return 1; } -bool cbm_daemon_ipc_participant_guard_release( - cbm_daemon_ipc_participant_guard_t **guard_io) { +bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t **guard_io) { if (!guard_io) { return false; } @@ -5947,10 +5569,8 @@ bool cbm_daemon_ipc_participant_guard_release( return true; } if (!win_participant_state_release( - guard->endpoint, guard->directory, - &guard->teardown_startup_v2_lock, - &guard->teardown_legacy_guard, &guard->group_lock, - &guard->legacy_sentinel)) { + guard->endpoint, guard->directory, &guard->teardown_startup_v2_lock, + &guard->teardown_legacy_guard, &guard->group_lock, &guard->legacy_sentinel)) { return false; } cbm_private_lock_directory_close(guard->directory); @@ -5960,8 +5580,7 @@ bool cbm_daemon_ipc_participant_guard_release( } int cbm_daemon_ipc_local_transition_try_acquire( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_daemon_ipc_local_transition_t **transition_out) { + const cbm_daemon_ipc_endpoint_t *endpoint, cbm_daemon_ipc_local_transition_t **transition_out) { if (transition_out) { *transition_out = NULL; } @@ -5979,8 +5598,7 @@ int cbm_daemon_ipc_local_transition_try_acquire( cbm_private_lock_directory_close(directory); return startup; } - cbm_daemon_ipc_local_transition_t *transition = - calloc(1, sizeof(*transition)); + cbm_daemon_ipc_local_transition_t *transition = calloc(1, sizeof(*transition)); if (!transition) { win_private_lock_release_complete(&startup_v2); cbm_private_lock_directory_close(directory); @@ -5994,29 +5612,24 @@ int cbm_daemon_ipc_local_transition_try_acquire( return 1; } -int cbm_daemon_ipc_local_transition_seal_legacy( - cbm_daemon_ipc_local_transition_t *transition) { +int cbm_daemon_ipc_local_transition_seal_legacy(cbm_daemon_ipc_local_transition_t *transition) { if (!transition || !transition->endpoint || !transition->directory || !transition->startup_v2_lock || transition->work_begun) { return -1; } if (transition->sealed) { - return transition->group_lock && - transition->legacy_sentinel != INVALID_HANDLE_VALUE - ? 1 - : -1; + return transition->group_lock && transition->legacy_sentinel != INVALID_HANDLE_VALUE ? 1 + : -1; } win_legacy_mutex_guard_t *legacy_guard = NULL; - int legacy = win_legacy_mutex_guard_try_acquire( - transition->endpoint, &legacy_guard); + int legacy = win_legacy_mutex_guard_try_acquire(transition->endpoint, &legacy_guard); if (legacy != 1) { return legacy; } - int joined = win_participant_group_join_locked( - transition->endpoint, transition->directory, true, - &transition->group_lock, &transition->legacy_sentinel); - bool mutex_released = - win_legacy_mutex_guard_release(&legacy_guard); + int joined = + win_participant_group_join_locked(transition->endpoint, transition->directory, true, + &transition->group_lock, &transition->legacy_sentinel); + bool mutex_released = win_legacy_mutex_guard_release(&legacy_guard); if (joined != 1 || !mutex_released) { if (legacy_guard || transition->group_lock || transition->legacy_sentinel != INVALID_HANDLE_VALUE) { @@ -6031,34 +5644,28 @@ int cbm_daemon_ipc_local_transition_seal_legacy( int cbm_daemon_ipc_local_transition_lifetime_probe( const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_ipc_local_transition_t *transition) { - if (!endpoint || !transition || transition->endpoint != endpoint || - !transition->directory || !transition->startup_v2_lock || - !transition->sealed || transition->work_begun || - !transition->group_lock || - transition->legacy_sentinel == INVALID_HANDLE_VALUE) { + if (!endpoint || !transition || transition->endpoint != endpoint || !transition->directory || + !transition->startup_v2_lock || !transition->sealed || transition->work_begun || + !transition->group_lock || transition->legacy_sentinel == INVALID_HANDLE_VALUE) { return -1; } return cbm_daemon_ipc_lifetime_reservation_probe(endpoint); } -bool cbm_daemon_ipc_local_transition_begin_work( - cbm_daemon_ipc_local_transition_t *transition) { +bool cbm_daemon_ipc_local_transition_begin_work(cbm_daemon_ipc_local_transition_t *transition) { if (!transition || !transition->endpoint || !transition->directory || - !transition->startup_v2_lock || !transition->sealed || - transition->work_begun || !transition->group_lock || - transition->legacy_sentinel == INVALID_HANDLE_VALUE) { + !transition->startup_v2_lock || !transition->sealed || transition->work_begun || + !transition->group_lock || transition->legacy_sentinel == INVALID_HANDLE_VALUE) { return false; } - if (cbm_private_file_lock_release(&transition->startup_v2_lock) != - CBM_PRIVATE_FILE_LOCK_OK) { + if (cbm_private_file_lock_release(&transition->startup_v2_lock) != CBM_PRIVATE_FILE_LOCK_OK) { return false; } transition->work_begun = true; return true; } -bool cbm_daemon_ipc_local_transition_release( - cbm_daemon_ipc_local_transition_t **transition_io) { +bool cbm_daemon_ipc_local_transition_release(cbm_daemon_ipc_local_transition_t **transition_io) { if (!transition_io) { return false; } @@ -6069,20 +5676,16 @@ bool cbm_daemon_ipc_local_transition_release( if (!transition->endpoint || !transition->directory) { return false; } - if (transition->group_lock || - transition->legacy_sentinel != INVALID_HANDLE_VALUE || + if (transition->group_lock || transition->legacy_sentinel != INVALID_HANDLE_VALUE || transition->teardown_legacy_guard) { - if (!win_participant_state_release( - transition->endpoint, transition->directory, - &transition->startup_v2_lock, - &transition->teardown_legacy_guard, - &transition->group_lock, - &transition->legacy_sentinel)) { + if (!win_participant_state_release(transition->endpoint, transition->directory, + &transition->startup_v2_lock, + &transition->teardown_legacy_guard, + &transition->group_lock, &transition->legacy_sentinel)) { return false; } } else if (transition->startup_v2_lock && - cbm_private_file_lock_release( - &transition->startup_v2_lock) != + cbm_private_file_lock_release(&transition->startup_v2_lock) != CBM_PRIVATE_FILE_LOCK_OK) { return false; } @@ -6119,10 +5722,9 @@ bool cbm_daemon_ipc_send_frame(cbm_daemon_ipc_connection_t *connection, return true; } -int cbm_daemon_ipc_receive_frame_bounded( - cbm_daemon_ipc_connection_t *connection, uint32_t timeout_ms, - uint32_t max_payload_length, cbm_daemon_frame_t *frame_out, - uint8_t **payload_out) { +int cbm_daemon_ipc_receive_frame_bounded(cbm_daemon_ipc_connection_t *connection, + uint32_t timeout_ms, uint32_t max_payload_length, + cbm_daemon_frame_t *frame_out, uint8_t **payload_out) { if (payload_out) { *payload_out = NULL; } @@ -6146,8 +5748,7 @@ int cbm_daemon_ipc_receive_frame_bounded( /* The fixed unauthenticated envelope limit is enforced from the header * before allocating or reading attacker-controlled payload bytes. The * unread stream is necessarily unusable and is closed by its owner. */ - atomic_store_explicit(&connection->poisoned, true, - memory_order_release); + atomic_store_explicit(&connection->poisoned, true, memory_order_release); return -1; } uint8_t *payload = NULL; @@ -6169,11 +5770,8 @@ int cbm_daemon_ipc_receive_frame_bounded( return 1; } -int cbm_daemon_ipc_receive_frame(cbm_daemon_ipc_connection_t *connection, - uint32_t timeout_ms, - cbm_daemon_frame_t *frame_out, - uint8_t **payload_out) { - return cbm_daemon_ipc_receive_frame_bounded( - connection, timeout_ms, CBM_DAEMON_MAX_FRAME_SIZE, frame_out, - payload_out); +int cbm_daemon_ipc_receive_frame(cbm_daemon_ipc_connection_t *connection, uint32_t timeout_ms, + cbm_daemon_frame_t *frame_out, uint8_t **payload_out) { + return cbm_daemon_ipc_receive_frame_bounded(connection, timeout_ms, CBM_DAEMON_MAX_FRAME_SIZE, + frame_out, payload_out); } diff --git a/src/daemon/ipc.h b/src/daemon/ipc.h index 4e94150b9..10df02dda 100644 --- a/src/daemon/ipc.h +++ b/src/daemon/ipc.h @@ -22,12 +22,9 @@ typedef struct cbm_daemon_ipc_endpoint cbm_daemon_ipc_endpoint_t; typedef struct cbm_daemon_ipc_listener cbm_daemon_ipc_listener_t; typedef struct cbm_daemon_ipc_connection cbm_daemon_ipc_connection_t; typedef struct cbm_daemon_ipc_startup_lock cbm_daemon_ipc_startup_lock_t; -typedef struct cbm_daemon_ipc_local_transition - cbm_daemon_ipc_local_transition_t; -typedef struct cbm_daemon_ipc_participant_guard - cbm_daemon_ipc_participant_guard_t; -typedef struct cbm_daemon_ipc_lifetime_reservation - cbm_daemon_ipc_lifetime_reservation_t; +typedef struct cbm_daemon_ipc_local_transition cbm_daemon_ipc_local_transition_t; +typedef struct cbm_daemon_ipc_participant_guard cbm_daemon_ipc_participant_guard_t; +typedef struct cbm_daemon_ipc_lifetime_reservation cbm_daemon_ipc_lifetime_reservation_t; /* A receive wait with no wall-clock expiry. The wait remains interruptible by * peer EOF and cbm_daemon_ipc_connection_interrupt(). This is intentionally a @@ -51,8 +48,7 @@ const char *cbm_daemon_ipc_endpoint_runtime_dir(const cbm_daemon_ipc_endpoint_t /* Duplicate the endpoint's already-validated owner-only runtime-directory * handle for native lock files. Callers never reopen the path themselves. */ cbm_private_file_lock_status_t cbm_daemon_ipc_private_lock_directory_new( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_private_lock_directory_t **directory_out); + const cbm_daemon_ipc_endpoint_t *endpoint, cbm_private_lock_directory_t **directory_out); /* Listener and connection handles are non-inheritable. */ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen(const cbm_daemon_ipc_endpoint_t *endpoint); @@ -80,14 +76,24 @@ cbm_daemon_ipc_listener_t *cbm_daemon_ipc_listen_reserved( cbm_daemon_ipc_lifetime_reservation_t **reservation_io); void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener); +/* Create or validate one private current-user directory at an already + * canonical local path. Ancestors are handle-validated without mutation: + * POSIX permits only root/current-user owners and no group/other write (apart + * from a root-owned sticky parent selecting a trusted-owned child); Windows + * permits current user/SYSTEM/Administrators/TrustedInstaller and rejects + * untrusted mutation ACEs. The final directory is hardened to owner-only + * access. Symlink, reparse-point, foreign-owner, and unsafe filesystem states + * fail closed. This is the common trust boundary for the canonical account + * cache root as well as daemon-private artifacts. */ +bool cbm_daemon_ipc_private_directory_secure(const char *directory_path); + /* Create/validate an owner-only directory and securely open one regular * owner-only append log within it. User-controlled path components may not be * symlinks, junctions, or other reparse points; trusted root-owned macOS /tmp * and /var aliases are normalized before validation. A file larger than * rotate_cap_bytes is moved to .1 before a new stream is opened. * The caller owns the returned FILE and must fclose it. */ -FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, - const char *base_name, +FILE *cbm_daemon_ipc_private_log_open(const char *directory_path, const char *base_name, size_t rotate_cap_bytes); /* Tri-state operations return 1 on success, 0 on timeout, and -1 on error. @@ -102,8 +108,7 @@ int cbm_daemon_ipc_accept(cbm_daemon_ipc_listener_t *listener, uint32_t timeout_ * use ECONNREFUSED for a full listen queue. * It never sends a protocol frame, so exact and conflicting builds are both * reported as active. */ -int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, - uint32_t timeout_ms); +int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, uint32_t timeout_ms); /* Observe the daemon-generation lifetime reservation without spawning a * daemon or retaining the reservation. Returns 1 while a host is constructing, @@ -111,8 +116,7 @@ int cbm_daemon_ipc_endpoint_probe(const cbm_daemon_ipc_endpoint_t *endpoint, * and -1 when ownership/safety cannot be proven. Stale cleanup holds both the * startup lock and a temporary lifetime reservation; listener construction * then transfers a lifetime reservation through listener teardown. */ -int cbm_daemon_ipc_lifetime_reservation_probe( - const cbm_daemon_ipc_endpoint_t *endpoint); +int cbm_daemon_ipc_lifetime_reservation_probe(const cbm_daemon_ipc_endpoint_t *endpoint); #ifndef _WIN32 /* Remove only a provably current-generation stale Unix socket identity. The @@ -126,9 +130,8 @@ int cbm_daemon_ipc_lifetime_reservation_probe( * artifacts are absent or were removed, 0 when cleanup is refused for a live, * legacy, unknown, or mismatched identity, and -1 for an invalid lock or * unsafe/I/O state. This operation never connects to the endpoint. */ -int cbm_daemon_ipc_stale_generation_cleanup( - const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_ipc_startup_lock_t *startup_lock); +int cbm_daemon_ipc_stale_generation_cleanup(const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_ipc_startup_lock_t *startup_lock); #endif /* Migration-only observation of transport/startup primitives used by builds @@ -136,8 +139,7 @@ int cbm_daemon_ipc_stale_generation_cleanup( * endpoint and never claims a legacy primitive as ownership authority. * Returns 1 when a legacy generation is present or starting, 0 when absent, * and -1 when ownership/safety cannot be proven. */ -int cbm_daemon_ipc_legacy_generation_probe( - const cbm_daemon_ipc_endpoint_t *endpoint); +int cbm_daemon_ipc_legacy_generation_probe(const cbm_daemon_ipc_endpoint_t *endpoint); cbm_daemon_ipc_connection_t *cbm_daemon_ipc_connect(const cbm_daemon_ipc_endpoint_t *endpoint, uint32_t timeout_ms); @@ -152,8 +154,7 @@ void cbm_daemon_ipc_connection_interrupt(cbm_daemon_ipc_connection_t *connection * zero when the platform cannot provide an authenticated peer PID. The daemon * uses this as the anchor for executable identity verification; protocol * payloads are never trusted to declare their own process identity. */ -uint64_t cbm_daemon_ipc_connection_peer_pid( - const cbm_daemon_ipc_connection_t *connection); +uint64_t cbm_daemon_ipc_connection_peer_pid(const cbm_daemon_ipc_connection_t *connection); /* send_frame uses a bounded internal write timeout. receive_frame returns a * malloc-owned payload (NULL for an empty frame) through payload_out. */ @@ -178,21 +179,18 @@ int cbm_daemon_ipc_startup_lock_try_acquire(const cbm_daemon_ipc_endpoint_t *end * Returns 1 when active, 0 when authoritatively absent, and -1 when the lock, * endpoint, transport, or ownership cannot be validated. */ int cbm_daemon_ipc_generation_probe_under_startup_lock( - const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_ipc_startup_lock_t *startup_lock); + const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_ipc_startup_lock_t *startup_lock); /* Prepare a serialized daemon handoff. POSIX validates/removes only a * provably current-generation stale socket identity while retaining both * startup-v2 EX and frozen-legacy SH. Windows creates/joins the authenticated * current-participant group, publishes the generation, then releases only the * legacy mutex. Both platforms retain startup-v2 and a temporary participant * claim through child readiness. */ -bool cbm_daemon_ipc_startup_lock_prepare_handoff( - cbm_daemon_ipc_startup_lock_t *lock); +bool cbm_daemon_ipc_startup_lock_prepare_handoff(cbm_daemon_ipc_startup_lock_t *lock); /* Retry-safe teardown. Success consumes and clears *lock_io. A native cleanup * failure returns false and retains every unreleased claim in *lock_io; the * caller must retry within a finite deadline or fail-stop its process. */ -bool cbm_daemon_ipc_startup_lock_release( - cbm_daemon_ipc_startup_lock_t **lock_io); +bool cbm_daemon_ipc_startup_lock_release(cbm_daemon_ipc_startup_lock_t **lock_io); /* Join the current participant group while a daemon bootstrap parent retains * its prepared startup lock. The child takes no startup-v2 claim of its own; @@ -202,11 +200,9 @@ bool cbm_daemon_ipc_startup_lock_release( * legacy transition won, and -1 on an unsafe/I/O state. Release is retry-safe * where the platform exposes retryable teardown and consumes *guard_io only * after the participant claim is completely gone. */ -int cbm_daemon_ipc_participant_guard_try_join( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_daemon_ipc_participant_guard_t **guard_out); -bool cbm_daemon_ipc_participant_guard_release( - cbm_daemon_ipc_participant_guard_t **guard_io); +int cbm_daemon_ipc_participant_guard_try_join(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_participant_guard_t **guard_out); +bool cbm_daemon_ipc_participant_guard_release(cbm_daemon_ipc_participant_guard_t **guard_io); /* Standalone CLI work joins the legacy-compatible current participant group * without becoming a daemon client. Acquisition retains startup-v2 only for @@ -220,29 +216,24 @@ bool cbm_daemon_ipc_participant_guard_release( * crash-left socket identity. Tri-state operations return 1 on success, 0 * when another transition or legacy/unknown generation won, and -1 on an * unsafe/I/O state. The endpoint must outlive the transition. */ -int cbm_daemon_ipc_local_transition_try_acquire( - const cbm_daemon_ipc_endpoint_t *endpoint, - cbm_daemon_ipc_local_transition_t **transition_out); -int cbm_daemon_ipc_local_transition_seal_legacy( - cbm_daemon_ipc_local_transition_t *transition); +int cbm_daemon_ipc_local_transition_try_acquire(const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_ipc_local_transition_t **transition_out); +int cbm_daemon_ipc_local_transition_seal_legacy(cbm_daemon_ipc_local_transition_t *transition); /* Observe daemon lifetime only through a successfully sealed, matching, * retained transition. Returns 1 while a daemon lifetime is active, 0 when * its absence is authoritative under the transition, and -1 when the guard, * endpoint, or underlying primitive cannot be verified. */ int cbm_daemon_ipc_local_transition_lifetime_probe( - const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_ipc_local_transition_t *transition); + const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_ipc_local_transition_t *transition); /* Commit the classified transition and begin standalone work. This releases * startup-v2 but retains the participant claim. It is valid exactly once after * sealing/presence classification. */ -bool cbm_daemon_ipc_local_transition_begin_work( - cbm_daemon_ipc_local_transition_t *transition); +bool cbm_daemon_ipc_local_transition_begin_work(cbm_daemon_ipc_local_transition_t *transition); /* Release ends protection after the caller has stopped all local work. On * Windows teardown reacquires startup-v2 and the legacy mutex, releases group * SH, closes this participant's sentinel last, then releases mutex and v2. * Success consumes and clears *transition_io; a retryable native cleanup * failure returns false and retains it. */ -bool cbm_daemon_ipc_local_transition_release( - cbm_daemon_ipc_local_transition_t **transition_io); +bool cbm_daemon_ipc_local_transition_release(cbm_daemon_ipc_local_transition_t **transition_io); #endif /* CBM_DAEMON_IPC_H */ diff --git a/src/daemon/ipc_internal.h b/src/daemon/ipc_internal.h index 96d398bed..4b358ef16 100644 --- a/src/daemon/ipc_internal.h +++ b/src/daemon/ipc_internal.h @@ -17,9 +17,8 @@ * supplies a BCryptGenRandom nonce. */ #define CBM_DAEMON_IPC_WINDOWS_NAME_CAP 256U #define CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE 32U -#define CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE \ - (8U + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE + \ - CBM_DAEMON_IPC_WINDOWS_NAME_CAP) +#define CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE \ + (8U + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE + CBM_DAEMON_IPC_WINDOWS_NAME_CAP) #define CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE "cbm-rendezvous.lock" bool cbm_daemon_ipc_windows_generation_address( @@ -31,14 +30,13 @@ bool cbm_daemon_ipc_windows_generation_address( * generations never publish the deterministic pipe and never treat either * object as current authority; startup/lifetime may hold the old startup mutex * solely as a compatibility guard against an overlapping pre-cohort process. */ -bool cbm_daemon_ipc_windows_legacy_names( - const char *canonical_runtime_parent, const char *instance_key, - char pipe_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP], - char startup_mutex_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]); +bool cbm_daemon_ipc_windows_legacy_names(const char *canonical_runtime_parent, + const char *instance_key, + char pipe_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP], + char startup_mutex_out[CBM_DAEMON_IPC_WINDOWS_NAME_CAP]); bool cbm_daemon_ipc_windows_rendezvous_record_encode( - const uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE], - const char *address, + const uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE], const char *address, uint8_t record_out[CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE]); bool cbm_daemon_ipc_windows_rendezvous_record_decode( const uint8_t *record, size_t record_length, @@ -73,10 +71,9 @@ int cbm_daemon_ipc_wait_pending(const cbm_ipc_pending_ops_t *ops, uint32_t timeo /* Internal receive path for fixed-size unauthenticated protocol envelopes. * Payloads above max_payload_length poison the stream. The implementation must * reject from the decoded header before allocating or reading that payload. */ -int cbm_daemon_ipc_receive_frame_bounded( - cbm_daemon_ipc_connection_t *connection, uint32_t timeout_ms, - uint32_t max_payload_length, cbm_daemon_frame_t *frame_out, - uint8_t **payload_out); +int cbm_daemon_ipc_receive_frame_bounded(cbm_daemon_ipc_connection_t *connection, + uint32_t timeout_ms, uint32_t max_payload_length, + cbm_daemon_frame_t *frame_out, uint8_t **payload_out); /* Narrow crash/fault seams for publication-state and retry-state tests. They * are inert unless a test installs a hook/failure count in its own process. */ @@ -97,7 +94,6 @@ typedef void (*cbm_daemon_ipc_posix_publication_hook_fn)( void cbm_daemon_ipc_posix_publication_hook_set_for_test( cbm_daemon_ipc_posix_publication_hook_fn hook, void *context); -void cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test( - unsigned int count); +void cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test(unsigned int count); #endif /* CBM_DAEMON_IPC_INTERNAL_H */ diff --git a/src/daemon/project_lock.c b/src/daemon/project_lock.c index 1df76e00d..77548131f 100644 --- a/src/daemon/project_lock.c +++ b/src/daemon/project_lock.c @@ -34,8 +34,7 @@ static bool project_lock_key(const char *project, char out[PROJECT_LOCK_KEY_CAP] memcpy(out, prefix, prefix_length); for (size_t index = 0; index < project_length; index++) { unsigned char ch = (unsigned char)project[index]; - out[prefix_length + index] = - (char)(ch >= 'A' && ch <= 'Z' ? ch + ('a' - 'A') : ch); + out[prefix_length + index] = (char)(ch >= 'A' && ch <= 'Z' ? ch + ('a' - 'A') : ch); } out[prefix_length + project_length] = '\0'; return true; @@ -65,8 +64,7 @@ cbm_project_lock_manager_t *cbm_project_lock_manager_new( return manager; } -cbm_private_file_lock_status_t cbm_project_lock_lease_release( - cbm_project_lock_lease_t **lease_io) { +cbm_private_file_lock_status_t cbm_project_lock_lease_release(cbm_project_lock_lease_t **lease_io) { if (!lease_io || !*lease_io) { return CBM_PRIVATE_FILE_LOCK_IO; } @@ -79,8 +77,7 @@ cbm_private_file_lock_status_t cbm_project_lock_lease_release( } } if (lease->project_set) { - cbm_private_file_lock_status_t set_status = - cbm_lock_lease_release(&lease->project_set); + cbm_private_file_lock_status_t set_status = cbm_lock_lease_release(&lease->project_set); if (set_status != CBM_PRIVATE_FILE_LOCK_OK) { result = set_status; } @@ -101,20 +98,18 @@ static cbm_private_file_lock_status_t project_lock_failed_acquire( return status; } cbm_project_lock_lease_t *cleanup = lease; - cbm_private_file_lock_status_t cleanup_status = - cbm_project_lock_lease_release(&cleanup); + cbm_private_file_lock_status_t cleanup_status = cbm_project_lock_lease_release(&cleanup); if (cleanup) { *lease_out = cleanup; return CBM_PRIVATE_FILE_LOCK_IO; } - return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK ? status - : CBM_PRIVATE_FILE_LOCK_IO; + return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK ? status : CBM_PRIVATE_FILE_LOCK_IO; } static cbm_private_file_lock_status_t project_lock_acquire_internal( - cbm_project_lock_manager_t *manager, const char *project, - uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, - bool try_once, cbm_project_lock_lease_t **lease_out) { + cbm_project_lock_manager_t *manager, const char *project, uint64_t deadline_ms, + const cbm_lock_cancel_token_t *cancel_token, bool try_once, + cbm_project_lock_lease_t **lease_out) { if (lease_out) { *lease_out = NULL; } @@ -131,29 +126,23 @@ static cbm_private_file_lock_status_t project_lock_acquire_internal( return CBM_PRIVATE_FILE_LOCK_IO; } cbm_private_file_lock_status_t status = - try_once - ? cbm_lock_registry_try_acquire( - manager->registry, PROJECT_SET_KEY, - wildcard ? CBM_PRIVATE_FILE_LOCK_EX - : CBM_PRIVATE_FILE_LOCK_SH, - &lease->project_set) - : cbm_lock_registry_acquire( - manager->registry, PROJECT_SET_KEY, - wildcard ? CBM_PRIVATE_FILE_LOCK_EX - : CBM_PRIVATE_FILE_LOCK_SH, - deadline_ms, cancel_token, &lease->project_set); + try_once ? cbm_lock_registry_try_acquire(manager->registry, PROJECT_SET_KEY, + wildcard ? CBM_PRIVATE_FILE_LOCK_EX + : CBM_PRIVATE_FILE_LOCK_SH, + &lease->project_set) + : cbm_lock_registry_acquire(manager->registry, PROJECT_SET_KEY, + wildcard ? CBM_PRIVATE_FILE_LOCK_EX + : CBM_PRIVATE_FILE_LOCK_SH, + deadline_ms, cancel_token, &lease->project_set); if (status != CBM_PRIVATE_FILE_LOCK_OK) { return project_lock_failed_acquire(lease, status, lease_out); } if (!wildcard) { - status = try_once - ? cbm_lock_registry_try_acquire( - manager->registry, project_key, - CBM_PRIVATE_FILE_LOCK_EX, &lease->project) - : cbm_lock_registry_acquire( - manager->registry, project_key, - CBM_PRIVATE_FILE_LOCK_EX, deadline_ms, - cancel_token, &lease->project); + status = try_once ? cbm_lock_registry_try_acquire(manager->registry, project_key, + CBM_PRIVATE_FILE_LOCK_EX, &lease->project) + : cbm_lock_registry_acquire(manager->registry, project_key, + CBM_PRIVATE_FILE_LOCK_EX, deadline_ms, + cancel_token, &lease->project); if (status != CBM_PRIVATE_FILE_LOCK_OK) { return project_lock_failed_acquire(lease, status, lease_out); } @@ -162,23 +151,22 @@ static cbm_private_file_lock_status_t project_lock_acquire_internal( return CBM_PRIVATE_FILE_LOCK_OK; } -cbm_private_file_lock_status_t cbm_project_lock_acquire( - cbm_project_lock_manager_t *manager, const char *project, - uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, - cbm_project_lock_lease_t **lease_out) { - return project_lock_acquire_internal(manager, project, deadline_ms, - cancel_token, false, lease_out); +cbm_private_file_lock_status_t cbm_project_lock_acquire(cbm_project_lock_manager_t *manager, + const char *project, uint64_t deadline_ms, + const cbm_lock_cancel_token_t *cancel_token, + cbm_project_lock_lease_t **lease_out) { + return project_lock_acquire_internal(manager, project, deadline_ms, cancel_token, false, + lease_out); } -cbm_private_file_lock_status_t cbm_project_lock_try_acquire( - cbm_project_lock_manager_t *manager, const char *project, - cbm_project_lock_lease_t **lease_out) { - return project_lock_acquire_internal(manager, project, UINT64_MAX, NULL, - true, lease_out); +cbm_private_file_lock_status_t cbm_project_lock_try_acquire(cbm_project_lock_manager_t *manager, + const char *project, + cbm_project_lock_lease_t **lease_out) { + return project_lock_acquire_internal(manager, project, UINT64_MAX, NULL, true, lease_out); } -cbm_private_file_lock_status_t cbm_project_lock_request_cancel( - cbm_project_lock_manager_t *manager, cbm_lock_cancel_token_t *token) { +cbm_private_file_lock_status_t cbm_project_lock_request_cancel(cbm_project_lock_manager_t *manager, + cbm_lock_cancel_token_t *token) { return manager ? cbm_lock_registry_request_cancel(manager->registry, token) : CBM_PRIVATE_FILE_LOCK_IO; } @@ -189,8 +177,7 @@ cbm_private_file_lock_status_t cbm_project_lock_manager_free( return CBM_PRIVATE_FILE_LOCK_IO; } cbm_project_lock_manager_t *manager = *manager_io; - cbm_private_file_lock_status_t status = - cbm_lock_registry_free(&manager->registry); + cbm_private_file_lock_status_t status = cbm_lock_registry_free(&manager->registry); if (status != CBM_PRIVATE_FILE_LOCK_OK) { return status; } diff --git a/src/daemon/project_lock.h b/src/daemon/project_lock.h index 122e1f7ea..cc65e919f 100644 --- a/src/daemon/project_lock.h +++ b/src/daemon/project_lock.h @@ -13,27 +13,25 @@ typedef struct cbm_project_lock_lease cbm_project_lock_lease_t; /* Each manager is an independent process-local registry over the endpoint's * owner-only runtime directory. Separate CBM processes therefore coordinate * through the same native lock files without sharing memory. */ -cbm_project_lock_manager_t *cbm_project_lock_manager_new( - const cbm_daemon_ipc_endpoint_t *endpoint); +cbm_project_lock_manager_t *cbm_project_lock_manager_new(const cbm_daemon_ipc_endpoint_t *endpoint); /* Normal projects hold SH(project-set) + EX(project). "*" holds * EX(project-set), blocking every named project. Project lock keys are ASCII * case-folded to cover filename aliases on case-insensitive filesystems. */ -cbm_private_file_lock_status_t cbm_project_lock_acquire( - cbm_project_lock_manager_t *manager, const char *project, - uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, - cbm_project_lock_lease_t **lease_out); +cbm_private_file_lock_status_t cbm_project_lock_acquire(cbm_project_lock_manager_t *manager, + const char *project, uint64_t deadline_ms, + const cbm_lock_cancel_token_t *cancel_token, + cbm_project_lock_lease_t **lease_out); /* One fair, nonblocking attempt for UI/watcher paths. */ -cbm_private_file_lock_status_t cbm_project_lock_try_acquire( - cbm_project_lock_manager_t *manager, const char *project, - cbm_project_lock_lease_t **lease_out); +cbm_private_file_lock_status_t cbm_project_lock_try_acquire(cbm_project_lock_manager_t *manager, + const char *project, + cbm_project_lock_lease_t **lease_out); -cbm_private_file_lock_status_t cbm_project_lock_lease_release( - cbm_project_lock_lease_t **lease_io); +cbm_private_file_lock_status_t cbm_project_lock_lease_release(cbm_project_lock_lease_t **lease_io); -cbm_private_file_lock_status_t cbm_project_lock_request_cancel( - cbm_project_lock_manager_t *manager, cbm_lock_cancel_token_t *token); +cbm_private_file_lock_status_t cbm_project_lock_request_cancel(cbm_project_lock_manager_t *manager, + cbm_lock_cancel_token_t *token); /* Refuses teardown while any lease/cleanup state remains. */ cbm_private_file_lock_status_t cbm_project_lock_manager_free( diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index 269f7b68d..6eeb25012 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -45,8 +45,7 @@ enum { RENDEZVOUS_REQUEST_ABI_OFFSET = 0, RENDEZVOUS_REQUEST_VERSION_OFFSET = 4, RENDEZVOUS_REQUEST_BUILD_OFFSET = - RENDEZVOUS_REQUEST_VERSION_OFFSET + - CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + RENDEZVOUS_REQUEST_VERSION_OFFSET + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, RENDEZVOUS_RESPONSE_CONNECT_STATUS_OFFSET = 0, RENDEZVOUS_RESPONSE_HELLO_STATUS_OFFSET = 4, @@ -55,17 +54,13 @@ enum { RENDEZVOUS_RESPONSE_CONFLICT_STATUS_OFFSET = 24, RENDEZVOUS_RESPONSE_ACTIVE_VERSION_OFFSET = 28, RENDEZVOUS_RESPONSE_ACTIVE_BUILD_OFFSET = - RENDEZVOUS_RESPONSE_ACTIVE_VERSION_OFFSET + - CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + RENDEZVOUS_RESPONSE_ACTIVE_VERSION_OFFSET + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, RENDEZVOUS_RESPONSE_REQUESTED_VERSION_OFFSET = - RENDEZVOUS_RESPONSE_ACTIVE_BUILD_OFFSET + - CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + RENDEZVOUS_RESPONSE_ACTIVE_BUILD_OFFSET + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, RENDEZVOUS_RESPONSE_REQUESTED_BUILD_OFFSET = - RENDEZVOUS_RESPONSE_REQUESTED_VERSION_OFFSET + - CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + RENDEZVOUS_RESPONSE_REQUESTED_VERSION_OFFSET + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, RENDEZVOUS_RESPONSE_MESSAGE_OFFSET = - RENDEZVOUS_RESPONSE_REQUESTED_BUILD_OFFSET + - CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + RENDEZVOUS_RESPONSE_REQUESTED_BUILD_OFFSET + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, ACTIVATION_REQUEST_ACTION_OFFSET = 0, ACTIVATION_REQUEST_IDENTITY_OFFSET = 4, @@ -83,46 +78,35 @@ enum { APPLICATION_RESPONSE_PREFIX_SIZE = 16, }; -_Static_assert(RENDEZVOUS_REQUEST_BUILD_OFFSET + - CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP == +_Static_assert(RENDEZVOUS_REQUEST_BUILD_OFFSET + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP == CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE, "rendezvous request layout changed"); -_Static_assert(RENDEZVOUS_RESPONSE_MESSAGE_OFFSET + - CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP == +_Static_assert(RENDEZVOUS_RESPONSE_MESSAGE_OFFSET + CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP == CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE, "rendezvous response layout changed"); -_Static_assert(ACTIVATION_REQUEST_IDENTITY_OFFSET + - CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE == - CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE && +_Static_assert(ACTIVATION_REQUEST_IDENTITY_OFFSET + CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE == + CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE && ACTIVATION_RESPONSE_CONNECTIONS_OFFSET + 8 == CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE, "activation shutdown layout changed"); -_Static_assert(CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP == - CBM_DAEMON_VERSION_TEXT_SIZE, +_Static_assert(CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP == CBM_DAEMON_VERSION_TEXT_SIZE, "service version capacity requires a new rendezvous strategy"); -_Static_assert(CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP == - CBM_DAEMON_BUILD_FINGERPRINT_SIZE, +_Static_assert(CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP == CBM_DAEMON_BUILD_FINGERPRINT_SIZE, "service fingerprint capacity requires a new rendezvous strategy"); -_Static_assert(CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP == - CBM_DAEMON_CONFLICT_MESSAGE_SIZE, +_Static_assert(CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP == CBM_DAEMON_CONFLICT_MESSAGE_SIZE, "service message capacity requires a new rendezvous strategy"); -_Static_assert(CBM_DAEMON_RUNTIME_CONNECT_ERROR == 0 && - CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED == 1 && +_Static_assert(CBM_DAEMON_RUNTIME_CONNECT_ERROR == 0 && CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED == 1 && CBM_DAEMON_RUNTIME_CONNECT_CONFLICT == 2 && CBM_DAEMON_RUNTIME_CONNECT_REJECTED == 3, "rendezvous connect status values changed"); -_Static_assert(CBM_DAEMON_HELLO_INVALID == 0 && - CBM_DAEMON_HELLO_COMPATIBLE == 1 && - CBM_DAEMON_HELLO_VERSION_CONFLICT == 2 && - CBM_DAEMON_HELLO_BUILD_CONFLICT == 3 && +_Static_assert(CBM_DAEMON_HELLO_INVALID == 0 && CBM_DAEMON_HELLO_COMPATIBLE == 1 && + CBM_DAEMON_HELLO_VERSION_CONFLICT == 2 && CBM_DAEMON_HELLO_BUILD_CONFLICT == 3 && CBM_DAEMON_HELLO_PROTOCOL_ABI_CONFLICT == 4 && CBM_DAEMON_HELLO_STORE_ABI_CONFLICT == 5 && CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT == 6, "rendezvous hello status values changed"); -_Static_assert(CBM_DAEMON_RENDEZVOUS_FRAME_VERSION == 1 && - CBM_DAEMON_RENDEZVOUS_ABI == 1 && - CBM_DAEMON_FRAME_REQUEST == 1 && - CBM_DAEMON_FRAME_RESPONSE == 2 && +_Static_assert(CBM_DAEMON_RENDEZVOUS_FRAME_VERSION == 1 && CBM_DAEMON_RENDEZVOUS_ABI == 1 && + CBM_DAEMON_FRAME_REQUEST == 1 && CBM_DAEMON_FRAME_RESPONSE == 2 && CBM_DAEMON_RUNTIME_OP_HELLO == 1 && CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN == 8 && CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL == 1 && @@ -249,8 +233,7 @@ static void runtime_wait_tick(uint64_t deadline_ms) { uint64_t remaining_ms = deadline_ms - now_ms; struct timespec pause = { .tv_sec = 0, - .tv_nsec = remaining_ms > 1 ? RUNTIME_WAIT_POLL_NS - : (long)(remaining_ms * 1000000ULL), + .tv_nsec = remaining_ms > 1 ? RUNTIME_WAIT_POLL_NS : (long)(remaining_ms * 1000000ULL), }; (void)cbm_nanosleep(&pause, NULL); } @@ -263,8 +246,8 @@ static void runtime_put_u32(uint8_t *out, uint32_t value) { } static uint32_t runtime_get_u32(const uint8_t *in) { - return ((uint32_t)in[0] << 24) | ((uint32_t)in[1] << 16) | - ((uint32_t)in[2] << 8) | (uint32_t)in[3]; + return ((uint32_t)in[0] << 24) | ((uint32_t)in[1] << 16) | ((uint32_t)in[2] << 8) | + (uint32_t)in[3]; } static void runtime_put_u64(uint8_t *out, uint64_t value) { @@ -281,8 +264,7 @@ static uint64_t runtime_get_u64(const uint8_t *in) { return value; } -static bool runtime_bounded_length(const char *value, size_t capacity, - size_t *length_out) { +static bool runtime_bounded_length(const char *value, size_t capacity, size_t *length_out) { if (!value || capacity == 0) { return false; } @@ -297,8 +279,8 @@ static bool runtime_bounded_length(const char *value, size_t capacity, return false; } -static bool runtime_wire_string_encode(uint8_t *out, size_t capacity, - const char *value, bool allow_empty) { +static bool runtime_wire_string_encode(uint8_t *out, size_t capacity, const char *value, + bool allow_empty) { size_t length = 0; if (!out || !runtime_bounded_length(value, capacity, &length) || (!allow_empty && length == 0)) { @@ -337,10 +319,11 @@ static bool runtime_wire_string_decode(const uint8_t *in, size_t capacity, char /* Reuse the service layer's canonical printable-version and lowercase-SHA256 * validation without importing detailed ABI values into rendezvous. Fixed * nonzero sentinels on both sides make only version/build ordering observable. */ -static cbm_daemon_hello_status_t runtime_rendezvous_compare( - const char *active_version, const char *active_build, - const char *requested_version, const char *requested_build, - cbm_daemon_conflict_t *conflict_out) { +static cbm_daemon_hello_status_t runtime_rendezvous_compare(const char *active_version, + const char *active_build, + const char *requested_version, + const char *requested_build, + cbm_daemon_conflict_t *conflict_out) { cbm_daemon_build_identity_t active = { .semantic_version = active_version, .build_fingerprint = active_build, @@ -355,12 +338,9 @@ static cbm_daemon_hello_status_t runtime_rendezvous_compare( .store_abi = 1, .feature_abi = 1, }; - cbm_daemon_hello_status_t status = - cbm_daemon_hello_compare(&active, &requested, conflict_out); - if (status != CBM_DAEMON_HELLO_INVALID && - status != CBM_DAEMON_HELLO_COMPATIBLE && - status != CBM_DAEMON_HELLO_VERSION_CONFLICT && - status != CBM_DAEMON_HELLO_BUILD_CONFLICT) { + cbm_daemon_hello_status_t status = cbm_daemon_hello_compare(&active, &requested, conflict_out); + if (status != CBM_DAEMON_HELLO_INVALID && status != CBM_DAEMON_HELLO_COMPATIBLE && + status != CBM_DAEMON_HELLO_VERSION_CONFLICT && status != CBM_DAEMON_HELLO_BUILD_CONFLICT) { if (conflict_out) { memset(conflict_out, 0, sizeof(*conflict_out)); conflict_out->status = CBM_DAEMON_HELLO_INVALID; @@ -370,62 +350,50 @@ static cbm_daemon_hello_status_t runtime_rendezvous_compare( return status; } -bool cbm_daemon_runtime_hello_request_encode( - uint8_t out[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE], - const cbm_daemon_build_identity_t *identity) { +bool cbm_daemon_runtime_hello_request_encode(uint8_t out[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE], + const cbm_daemon_build_identity_t *identity) { if (!out || !identity) { return false; } cbm_daemon_conflict_t validation; - if (runtime_rendezvous_compare( - identity->semantic_version, identity->build_fingerprint, - identity->semantic_version, identity->build_fingerprint, - &validation) != CBM_DAEMON_HELLO_COMPATIBLE) { + if (runtime_rendezvous_compare(identity->semantic_version, identity->build_fingerprint, + identity->semantic_version, identity->build_fingerprint, + &validation) != CBM_DAEMON_HELLO_COMPATIBLE) { return false; } memset(out, 0, CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE); - runtime_put_u32(out + RENDEZVOUS_REQUEST_ABI_OFFSET, - CBM_DAEMON_RENDEZVOUS_ABI); - return runtime_wire_string_encode( - out + RENDEZVOUS_REQUEST_VERSION_OFFSET, - CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, - identity->semantic_version, false) && - runtime_wire_string_encode( - out + RENDEZVOUS_REQUEST_BUILD_OFFSET, - CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, - identity->build_fingerprint, false); + runtime_put_u32(out + RENDEZVOUS_REQUEST_ABI_OFFSET, CBM_DAEMON_RENDEZVOUS_ABI); + return runtime_wire_string_encode(out + RENDEZVOUS_REQUEST_VERSION_OFFSET, + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + identity->semantic_version, false) && + runtime_wire_string_encode(out + RENDEZVOUS_REQUEST_BUILD_OFFSET, + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + identity->build_fingerprint, false); } static bool runtime_rendezvous_request_decode( const uint8_t payload[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE], - char version[CBM_DAEMON_VERSION_TEXT_SIZE], - char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + char version[CBM_DAEMON_VERSION_TEXT_SIZE], char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { if (!payload || !version || !build || - runtime_get_u32(payload + RENDEZVOUS_REQUEST_ABI_OFFSET) != - CBM_DAEMON_RENDEZVOUS_ABI || - !runtime_wire_string_decode( - payload + RENDEZVOUS_REQUEST_VERSION_OFFSET, - CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, version, false) || - !runtime_wire_string_decode( - payload + RENDEZVOUS_REQUEST_BUILD_OFFSET, - CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, build, false)) { + runtime_get_u32(payload + RENDEZVOUS_REQUEST_ABI_OFFSET) != CBM_DAEMON_RENDEZVOUS_ABI || + !runtime_wire_string_decode(payload + RENDEZVOUS_REQUEST_VERSION_OFFSET, + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, version, false) || + !runtime_wire_string_decode(payload + RENDEZVOUS_REQUEST_BUILD_OFFSET, + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, build, false)) { return false; } cbm_daemon_conflict_t validation; - return runtime_rendezvous_compare(version, build, version, build, - &validation) == + return runtime_rendezvous_compare(version, build, version, build, &validation) == CBM_DAEMON_HELLO_COMPATIBLE; } -static bool runtime_activation_action_valid( - cbm_daemon_runtime_activation_action_t action) { +static bool runtime_activation_action_valid(cbm_daemon_runtime_activation_action_t action) { return action == CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL || action == CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE || action == CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL; } -static const char *runtime_activation_action_text( - cbm_daemon_runtime_activation_action_t action) { +static const char *runtime_activation_action_text(cbm_daemon_runtime_activation_action_t action) { switch (action) { case CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL: return "install"; @@ -440,32 +408,28 @@ static const char *runtime_activation_action_text( static bool runtime_activation_request_encode( uint8_t out[CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE], - cbm_daemon_runtime_activation_action_t action, - const cbm_daemon_build_identity_t *identity) { + cbm_daemon_runtime_activation_action_t action, const cbm_daemon_build_identity_t *identity) { if (!out || !identity || !runtime_activation_action_valid(action)) { return false; } memset(out, 0, CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE); - runtime_put_u32(out + ACTIVATION_REQUEST_ACTION_OFFSET, - (uint32_t)action); - return cbm_daemon_runtime_hello_request_encode( - out + ACTIVATION_REQUEST_IDENTITY_OFFSET, identity); + runtime_put_u32(out + ACTIVATION_REQUEST_ACTION_OFFSET, (uint32_t)action); + return cbm_daemon_runtime_hello_request_encode(out + ACTIVATION_REQUEST_IDENTITY_OFFSET, + identity); } static bool runtime_activation_request_decode( const uint8_t payload[CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE], - cbm_daemon_runtime_activation_action_t *action_out, - char version[CBM_DAEMON_VERSION_TEXT_SIZE], + cbm_daemon_runtime_activation_action_t *action_out, char version[CBM_DAEMON_VERSION_TEXT_SIZE], char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { if (!payload || !action_out || !version || !build) { return false; } - uint32_t raw_action = runtime_get_u32( - payload + ACTIVATION_REQUEST_ACTION_OFFSET); + uint32_t raw_action = runtime_get_u32(payload + ACTIVATION_REQUEST_ACTION_OFFSET); if (raw_action < (uint32_t)CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL || raw_action > (uint32_t)CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL || - !runtime_rendezvous_request_decode( - payload + ACTIVATION_REQUEST_IDENTITY_OFFSET, version, build)) { + !runtime_rendezvous_request_decode(payload + ACTIVATION_REQUEST_IDENTITY_OFFSET, version, + build)) { return false; } cbm_daemon_runtime_activation_action_t action = @@ -481,14 +445,10 @@ static bool runtime_activation_response_encode( return false; } memset(out, 0, CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE); - runtime_put_u32(out + ACTIVATION_RESPONSE_ABI_OFFSET, - CBM_DAEMON_RENDEZVOUS_ABI); - runtime_put_u32(out + ACTIVATION_RESPONSE_ACCEPTED_OFFSET, - result->accepted ? 1U : 0U); - runtime_put_u64(out + ACTIVATION_RESPONSE_CLIENTS_OFFSET, - result->active_clients); - runtime_put_u64(out + ACTIVATION_RESPONSE_CONNECTIONS_OFFSET, - result->active_connections); + runtime_put_u32(out + ACTIVATION_RESPONSE_ABI_OFFSET, CBM_DAEMON_RENDEZVOUS_ABI); + runtime_put_u32(out + ACTIVATION_RESPONSE_ACCEPTED_OFFSET, result->accepted ? 1U : 0U); + runtime_put_u64(out + ACTIVATION_RESPONSE_CLIENTS_OFFSET, result->active_clients); + runtime_put_u64(out + ACTIVATION_RESPONSE_CONNECTIONS_OFFSET, result->active_connections); return true; } @@ -496,18 +456,15 @@ static bool runtime_activation_response_decode( const uint8_t payload[CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE], cbm_daemon_runtime_activation_result_t *result_out) { if (!payload || !result_out || - runtime_get_u32(payload + ACTIVATION_RESPONSE_ABI_OFFSET) != - CBM_DAEMON_RENDEZVOUS_ABI) { + runtime_get_u32(payload + ACTIVATION_RESPONSE_ABI_OFFSET) != CBM_DAEMON_RENDEZVOUS_ABI) { return false; } - uint32_t accepted = - runtime_get_u32(payload + ACTIVATION_RESPONSE_ACCEPTED_OFFSET); + uint32_t accepted = runtime_get_u32(payload + ACTIVATION_RESPONSE_ACCEPTED_OFFSET); if (accepted > 1U) { return false; } result_out->accepted = accepted == 1U; - result_out->active_clients = - runtime_get_u64(payload + ACTIVATION_RESPONSE_CLIENTS_OFFSET); + result_out->active_clients = runtime_get_u64(payload + ACTIVATION_RESPONSE_CLIENTS_OFFSET); result_out->active_connections = runtime_get_u64(payload + ACTIVATION_RESPONSE_CONNECTIONS_OFFSET); return true; @@ -523,8 +480,7 @@ static uint64_t runtime_current_process_id(void) { #endif } -static void runtime_process_image_reference_init( - runtime_process_image_reference_t *reference) { +static void runtime_process_image_reference_init(runtime_process_image_reference_t *reference) { if (!reference) { return; } @@ -536,15 +492,13 @@ static void runtime_process_image_reference_init( #endif } -static bool runtime_process_image_reference_release( - runtime_process_image_reference_t *reference) { +static bool runtime_process_image_reference_release(runtime_process_image_reference_t *reference) { if (!reference) { return false; } bool ok = true; #ifdef _WIN32 - if (reference->file != INVALID_HANDLE_VALUE && - !CloseHandle(reference->file)) { + if (reference->file != INVALID_HANDLE_VALUE && !CloseHandle(reference->file)) { ok = false; } #elif defined(__APPLE__) || defined(__linux__) @@ -573,17 +527,13 @@ static bool runtime_windows_process_image_snapshot( FILETIME kernel_time; FILETIME user_time; DWORD exit_code = 0; - DWORD path_length = (DWORD)(sizeof(snapshot->path) / - sizeof(snapshot->path[0])); - bool ok = GetProcessTimes(process, &snapshot->creation_time, &exit_time, - &kernel_time, &user_time) != 0 && - GetExitCodeProcess(process, &exit_code) != 0 && - exit_code == STILL_ACTIVE && - QueryFullProcessImageNameW(process, 0, snapshot->path, - &path_length) != 0 && + DWORD path_length = (DWORD)(sizeof(snapshot->path) / sizeof(snapshot->path[0])); + bool ok = GetProcessTimes(process, &snapshot->creation_time, &exit_time, &kernel_time, + &user_time) != 0 && + GetExitCodeProcess(process, &exit_code) != 0 && exit_code == STILL_ACTIVE && + QueryFullProcessImageNameW(process, 0, snapshot->path, &path_length) != 0 && path_length > 0 && - path_length < (DWORD)(sizeof(snapshot->path) / - sizeof(snapshot->path[0])); + path_length < (DWORD)(sizeof(snapshot->path) / sizeof(snapshot->path[0])); if (ok) { snapshot->path[path_length] = L'\0'; } @@ -593,14 +543,12 @@ static bool runtime_windows_process_image_snapshot( static bool runtime_windows_process_image_snapshot_same( const runtime_windows_process_image_snapshot_t *first, const runtime_windows_process_image_snapshot_t *second) { - return first && second && - CompareFileTime(&first->creation_time, &second->creation_time) == 0 && - CompareStringOrdinal(first->path, -1, second->path, -1, TRUE) == - CSTR_EQUAL; + return first && second && CompareFileTime(&first->creation_time, &second->creation_time) == 0 && + CompareStringOrdinal(first->path, -1, second->path, -1, TRUE) == CSTR_EQUAL; } -static bool runtime_windows_file_snapshot( - HANDLE file, BY_HANDLE_FILE_INFORMATION *information, LARGE_INTEGER *size) { +static bool runtime_windows_file_snapshot(HANDLE file, BY_HANDLE_FILE_INFORMATION *information, + LARGE_INTEGER *size) { if (!information || !size) { return false; } @@ -613,14 +561,12 @@ static bool runtime_windows_file_snapshot( GetFileSizeEx(file, size) != 0 && size->QuadPart >= 0; } -static bool runtime_windows_file_snapshot_same( - const BY_HANDLE_FILE_INFORMATION *first_information, - const LARGE_INTEGER *first_size, - const BY_HANDLE_FILE_INFORMATION *second_information, - const LARGE_INTEGER *second_size) { +static bool runtime_windows_file_snapshot_same(const BY_HANDLE_FILE_INFORMATION *first_information, + const LARGE_INTEGER *first_size, + const BY_HANDLE_FILE_INFORMATION *second_information, + const LARGE_INTEGER *second_size) { return first_information && first_size && second_information && second_size && - first_information->dwVolumeSerialNumber == - second_information->dwVolumeSerialNumber && + first_information->dwVolumeSerialNumber == second_information->dwVolumeSerialNumber && first_information->nFileIndexHigh == second_information->nFileIndexHigh && first_information->nFileIndexLow == second_information->nFileIndexLow && first_size->QuadPart == second_size->QuadPart && @@ -630,14 +576,13 @@ static bool runtime_windows_file_snapshot_same( #elif defined(__APPLE__) -static bool runtime_mac_process_instance(int process_id, - struct proc_bsdinfo *info) { +static bool runtime_mac_process_instance(int process_id, struct proc_bsdinfo *info) { if (!info) { return false; } memset(info, 0, sizeof(*info)); - return proc_pidinfo(process_id, PROC_PIDTBSDINFO, 0, info, - (int)sizeof(*info)) == (int)sizeof(*info) && + return proc_pidinfo(process_id, PROC_PIDTBSDINFO, 0, info, (int)sizeof(*info)) == + (int)sizeof(*info) && info->pbi_pid == (uint32_t)process_id; } @@ -648,42 +593,38 @@ static bool runtime_mac_process_instance_same(const struct proc_bsdinfo *first, first->pbi_start_tvusec == second->pbi_start_tvusec; } -static bool runtime_mac_stat_matches_vnode( - const struct stat *status, const struct vinfo_stat *vnode) { +static bool runtime_mac_stat_matches_vnode(const struct stat *status, + const struct vinfo_stat *vnode) { return status && vnode && S_ISREG(vnode->vst_mode) && vnode->vst_dev == (uint32_t)status->st_dev && - vnode->vst_ino == (uint64_t)status->st_ino && - vnode->vst_size == status->st_size && + vnode->vst_ino == (uint64_t)status->st_ino && vnode->vst_size == status->st_size && vnode->vst_mtime == status->st_mtimespec.tv_sec && vnode->vst_mtimensec == status->st_mtimespec.tv_nsec && vnode->vst_ctime == status->st_ctimespec.tv_sec && vnode->vst_ctimensec == status->st_ctimespec.tv_nsec; } -static bool runtime_mac_stat_same(const struct stat *first, - const struct stat *second) { - return first && second && first->st_dev == second->st_dev && - first->st_ino == second->st_ino && first->st_size == second->st_size && +static bool runtime_mac_stat_same(const struct stat *first, const struct stat *second) { + return first && second && first->st_dev == second->st_dev && first->st_ino == second->st_ino && + first->st_size == second->st_size && first->st_mtimespec.tv_sec == second->st_mtimespec.tv_sec && first->st_mtimespec.tv_nsec == second->st_mtimespec.tv_nsec && first->st_ctimespec.tv_sec == second->st_ctimespec.tv_sec && first->st_ctimespec.tv_nsec == second->st_ctimespec.tv_nsec; } -static bool runtime_mac_process_maps_file_executable(int process_id, - const struct stat *status) { +static bool runtime_mac_process_maps_file_executable(int process_id, const struct stat *status) { uint64_t address = 0; for (size_t region_count = 0; region_count < 65536; region_count++) { struct proc_regionwithpathinfo region; memset(®ion, 0, sizeof(region)); - int received = proc_pidinfo(process_id, PROC_PIDREGIONPATHINFO, - address, ®ion, (int)sizeof(region)); + int received = + proc_pidinfo(process_id, PROC_PIDREGIONPATHINFO, address, ®ion, (int)sizeof(region)); if (received != (int)sizeof(region)) { return false; } if ((region.prp_prinfo.pri_protection & VM_PROT_EXECUTE) != 0 && - runtime_mac_stat_matches_vnode( - status, ®ion.prp_vip.vip_vi.vi_stat)) { + runtime_mac_stat_matches_vnode(status, ®ion.prp_vip.vip_vi.vi_stat)) { return true; } uint64_t region_address = region.prp_prinfo.pri_address; @@ -702,12 +643,10 @@ static bool runtime_mac_process_maps_file_executable(int process_id, #elif defined(__linux__) -static bool runtime_linux_stat_same_image(const struct stat *first, - const struct stat *second) { +static bool runtime_linux_stat_same_image(const struct stat *first, const struct stat *second) { return first && second && S_ISREG(first->st_mode) && S_ISREG(second->st_mode) && first->st_dev == second->st_dev && first->st_ino == second->st_ino && - first->st_size == second->st_size && - first->st_mtim.tv_sec == second->st_mtim.tv_sec && + first->st_size == second->st_size && first->st_mtim.tv_sec == second->st_mtim.tv_sec && first->st_mtim.tv_nsec == second->st_mtim.tv_nsec && first->st_ctim.tv_sec == second->st_ctim.tv_sec && first->st_ctim.tv_nsec == second->st_ctim.tv_nsec; @@ -733,8 +672,7 @@ static bool runtime_process_image_reference_acquire( if (process_id > MAXDWORD) { return false; } - HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, - (DWORD)process_id); + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, (DWORD)process_id); if (!process) { return false; } @@ -744,24 +682,17 @@ static bool runtime_process_image_reference_acquire( BY_HANDLE_FILE_INFORMATION file_after; LARGE_INTEGER size_before; LARGE_INTEGER size_after; - bool ok = - runtime_windows_process_image_snapshot(process, &process_before); + bool ok = runtime_windows_process_image_snapshot(process, &process_before); HANDLE file = ok ? CreateFileW(process_before.path, GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_DELETE, NULL, - OPEN_EXISTING, - FILE_FLAG_OPEN_REPARSE_POINT | - FILE_FLAG_SEQUENTIAL_SCAN, - NULL) + FILE_SHARE_READ | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, NULL) : INVALID_HANDLE_VALUE; ok = ok && runtime_windows_file_snapshot(file, &file_before, &size_before) && - (!fingerprint || cbm_daemon_build_fingerprint_native_file( - (uintptr_t)file, fingerprint)) && + (!fingerprint || cbm_daemon_build_fingerprint_native_file((uintptr_t)file, fingerprint)) && runtime_windows_file_snapshot(file, &file_after, &size_after) && - runtime_windows_file_snapshot_same(&file_before, &size_before, - &file_after, &size_after) && + runtime_windows_file_snapshot_same(&file_before, &size_before, &file_after, &size_after) && runtime_windows_process_image_snapshot(process, &process_after) && - runtime_windows_process_image_snapshot_same(&process_before, - &process_after); + runtime_windows_process_image_snapshot_same(&process_before, &process_after); if (!CloseHandle(process)) { ok = false; } @@ -788,17 +719,13 @@ static bool runtime_process_image_reference_acquire( if (ok) { path[path_length] = '\0'; } - int fd = ok ? open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) - : -1; + int fd = ok ? open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK) : -1; struct stat file_before; struct stat file_after; - ok = ok && fd >= 0 && fstat(fd, &file_before) == 0 && - S_ISREG(file_before.st_mode) && + ok = ok && fd >= 0 && fstat(fd, &file_before) == 0 && S_ISREG(file_before.st_mode) && runtime_mac_process_maps_file_executable(pid, &file_before) && - (!fingerprint || cbm_daemon_build_fingerprint_native_file( - (uintptr_t)fd, fingerprint)) && - fstat(fd, &file_after) == 0 && - runtime_mac_stat_same(&file_before, &file_after) && + (!fingerprint || cbm_daemon_build_fingerprint_native_file((uintptr_t)fd, fingerprint)) && + fstat(fd, &file_after) == 0 && runtime_mac_stat_same(&file_before, &file_after) && runtime_mac_process_maps_file_executable(pid, &file_after) && runtime_mac_process_instance(pid, &process_after) && runtime_mac_process_instance_same(&process_before, &process_after); @@ -811,22 +738,19 @@ static bool runtime_process_image_reference_acquire( } #elif defined(__linux__) char proc_path[64]; - int written = snprintf(proc_path, sizeof(proc_path), "/proc/%llu", - (unsigned long long)process_id); + int written = + snprintf(proc_path, sizeof(proc_path), "/proc/%llu", (unsigned long long)process_id); if (written <= 0 || written >= (int)sizeof(proc_path)) { return false; } - int process_fd = open(proc_path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | - O_NOFOLLOW | O_NONBLOCK); - int image_fd = process_fd >= 0 - ? openat(process_fd, "exe", O_RDONLY | O_CLOEXEC) - : -1; + int process_fd = open(proc_path, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); + int image_fd = process_fd >= 0 ? openat(process_fd, "exe", O_RDONLY | O_CLOEXEC) : -1; struct stat image_before; struct stat image_after; bool ok = image_fd >= 0 && fstat(image_fd, &image_before) == 0 && S_ISREG(image_before.st_mode) && - (!fingerprint || cbm_daemon_build_fingerprint_native_file( - (uintptr_t)image_fd, fingerprint)) && + (!fingerprint || + cbm_daemon_build_fingerprint_native_file((uintptr_t)image_fd, fingerprint)) && fstat(image_fd, &image_after) == 0 && runtime_linux_stat_same_image(&image_before, &image_after); int verify_fd = ok ? openat(process_fd, "exe", O_RDONLY | O_CLOEXEC) : -1; @@ -869,15 +793,11 @@ static bool runtime_process_image_reference_matches_process( bool same = runtime_process_image_reference_acquire(process_id, &peer, NULL); BY_HANDLE_FILE_INFORMATION active_now; LARGE_INTEGER active_size_now; - same = same && - runtime_windows_file_snapshot(active->file, &active_now, - &active_size_now) && - runtime_windows_file_snapshot_same( - &active->information, &active->size, &active_now, - &active_size_now) && - runtime_windows_file_snapshot_same( - &active->information, &active->size, &peer.information, - &peer.size); + same = same && runtime_windows_file_snapshot(active->file, &active_now, &active_size_now) && + runtime_windows_file_snapshot_same(&active->information, &active->size, &active_now, + &active_size_now) && + runtime_windows_file_snapshot_same(&active->information, &active->size, + &peer.information, &peer.size); bool released = runtime_process_image_reference_release(&peer); return same && released; #elif defined(__APPLE__) @@ -906,8 +826,8 @@ static bool runtime_process_image_reference_matches_process( #endif } -bool cbm_daemon_runtime_process_build_fingerprint( - uint64_t process_id, char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { +bool cbm_daemon_runtime_process_build_fingerprint(uint64_t process_id, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { if (!out) { return false; } @@ -924,43 +844,33 @@ bool cbm_daemon_runtime_process_build_fingerprint( return ok; } -static bool runtime_hello_response_encode( - uint8_t out[CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE], - const cbm_daemon_runtime_connect_result_t *result) { +static bool runtime_hello_response_encode(uint8_t out[CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE], + const cbm_daemon_runtime_connect_result_t *result) { if (!out || !result) { return false; } memset(out, 0, CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE); - runtime_put_u32(out + RENDEZVOUS_RESPONSE_CONNECT_STATUS_OFFSET, - (uint32_t)result->status); - runtime_put_u32(out + RENDEZVOUS_RESPONSE_HELLO_STATUS_OFFSET, - (uint32_t)result->hello_status); - runtime_put_u64(out + RENDEZVOUS_RESPONSE_CLIENT_ID_OFFSET, - result->client_id); - runtime_put_u64(out + RENDEZVOUS_RESPONSE_PROCESS_ID_OFFSET, - result->authenticated_process_id); + runtime_put_u32(out + RENDEZVOUS_RESPONSE_CONNECT_STATUS_OFFSET, (uint32_t)result->status); + runtime_put_u32(out + RENDEZVOUS_RESPONSE_HELLO_STATUS_OFFSET, (uint32_t)result->hello_status); + runtime_put_u64(out + RENDEZVOUS_RESPONSE_CLIENT_ID_OFFSET, result->client_id); + runtime_put_u64(out + RENDEZVOUS_RESPONSE_PROCESS_ID_OFFSET, result->authenticated_process_id); runtime_put_u32(out + RENDEZVOUS_RESPONSE_CONFLICT_STATUS_OFFSET, (uint32_t)result->conflict.status); - if (!runtime_wire_string_encode( - out + RENDEZVOUS_RESPONSE_ACTIVE_VERSION_OFFSET, - CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, - result->conflict.active_version, true) || - !runtime_wire_string_encode( - out + RENDEZVOUS_RESPONSE_ACTIVE_BUILD_OFFSET, - CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, - result->conflict.active_build_fingerprint, true) || - !runtime_wire_string_encode( - out + RENDEZVOUS_RESPONSE_REQUESTED_VERSION_OFFSET, - CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, - result->conflict.requested_version, true) || - !runtime_wire_string_encode( - out + RENDEZVOUS_RESPONSE_REQUESTED_BUILD_OFFSET, - CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, - result->conflict.requested_build_fingerprint, true) || - !runtime_wire_string_encode( - out + RENDEZVOUS_RESPONSE_MESSAGE_OFFSET, - CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP, result->message, true)) { + if (!runtime_wire_string_encode(out + RENDEZVOUS_RESPONSE_ACTIVE_VERSION_OFFSET, + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + result->conflict.active_version, true) || + !runtime_wire_string_encode(out + RENDEZVOUS_RESPONSE_ACTIVE_BUILD_OFFSET, + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + result->conflict.active_build_fingerprint, true) || + !runtime_wire_string_encode(out + RENDEZVOUS_RESPONSE_REQUESTED_VERSION_OFFSET, + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + result->conflict.requested_version, true) || + !runtime_wire_string_encode(out + RENDEZVOUS_RESPONSE_REQUESTED_BUILD_OFFSET, + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + result->conflict.requested_build_fingerprint, true) || + !runtime_wire_string_encode(out + RENDEZVOUS_RESPONSE_MESSAGE_OFFSET, + CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP, result->message, true)) { return false; } return true; @@ -973,38 +883,29 @@ static bool runtime_hello_response_decode( return false; } memset(result, 0, sizeof(*result)); - result->status = - (cbm_daemon_runtime_connect_status_t)runtime_get_u32( - payload + RENDEZVOUS_RESPONSE_CONNECT_STATUS_OFFSET); - result->hello_status = - (cbm_daemon_hello_status_t)runtime_get_u32( - payload + RENDEZVOUS_RESPONSE_HELLO_STATUS_OFFSET); - result->client_id = runtime_get_u64( - payload + RENDEZVOUS_RESPONSE_CLIENT_ID_OFFSET); + result->status = (cbm_daemon_runtime_connect_status_t)runtime_get_u32( + payload + RENDEZVOUS_RESPONSE_CONNECT_STATUS_OFFSET); + result->hello_status = (cbm_daemon_hello_status_t)runtime_get_u32( + payload + RENDEZVOUS_RESPONSE_HELLO_STATUS_OFFSET); + result->client_id = runtime_get_u64(payload + RENDEZVOUS_RESPONSE_CLIENT_ID_OFFSET); result->authenticated_process_id = runtime_get_u64(payload + RENDEZVOUS_RESPONSE_PROCESS_ID_OFFSET); - result->conflict.status = - (cbm_daemon_hello_status_t)runtime_get_u32( - payload + RENDEZVOUS_RESPONSE_CONFLICT_STATUS_OFFSET); - if (!runtime_wire_string_decode( - payload + RENDEZVOUS_RESPONSE_ACTIVE_VERSION_OFFSET, - CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, - result->conflict.active_version, true) || - !runtime_wire_string_decode( - payload + RENDEZVOUS_RESPONSE_ACTIVE_BUILD_OFFSET, - CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, - result->conflict.active_build_fingerprint, true) || - !runtime_wire_string_decode( - payload + RENDEZVOUS_RESPONSE_REQUESTED_VERSION_OFFSET, - CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, - result->conflict.requested_version, true) || - !runtime_wire_string_decode( - payload + RENDEZVOUS_RESPONSE_REQUESTED_BUILD_OFFSET, - CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, - result->conflict.requested_build_fingerprint, true) || - !runtime_wire_string_decode( - payload + RENDEZVOUS_RESPONSE_MESSAGE_OFFSET, - CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP, result->message, true)) { + result->conflict.status = (cbm_daemon_hello_status_t)runtime_get_u32( + payload + RENDEZVOUS_RESPONSE_CONFLICT_STATUS_OFFSET); + if (!runtime_wire_string_decode(payload + RENDEZVOUS_RESPONSE_ACTIVE_VERSION_OFFSET, + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + result->conflict.active_version, true) || + !runtime_wire_string_decode(payload + RENDEZVOUS_RESPONSE_ACTIVE_BUILD_OFFSET, + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + result->conflict.active_build_fingerprint, true) || + !runtime_wire_string_decode(payload + RENDEZVOUS_RESPONSE_REQUESTED_VERSION_OFFSET, + CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, + result->conflict.requested_version, true) || + !runtime_wire_string_decode(payload + RENDEZVOUS_RESPONSE_REQUESTED_BUILD_OFFSET, + CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, + result->conflict.requested_build_fingerprint, true) || + !runtime_wire_string_decode(payload + RENDEZVOUS_RESPONSE_MESSAGE_OFFSET, + CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP, result->message, true)) { return false; } if (result->status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED) { @@ -1020,8 +921,7 @@ static bool runtime_hello_response_decode( result->conflict.status == result->hello_status && result->client_id == CBM_DAEMON_CLIENT_ID_INVALID && result->authenticated_process_id == 0 && - cbm_daemon_conflict_format(&result->conflict, expected, - sizeof(expected)) && + cbm_daemon_conflict_format(&result->conflict, expected, sizeof(expected)) && strcmp(result->message, expected) == 0; } return result->status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED && @@ -1031,9 +931,8 @@ static bool runtime_hello_response_decode( result->authenticated_process_id == 0; } -static bool runtime_send_hello_response( - cbm_daemon_ipc_connection_t *connection, - const cbm_daemon_runtime_connect_result_t *result) { +static bool runtime_send_hello_response(cbm_daemon_ipc_connection_t *connection, + const cbm_daemon_runtime_connect_result_t *result) { uint8_t payload[CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE]; return runtime_hello_response_encode(payload, result) && cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_RESPONSE, @@ -1043,26 +942,24 @@ static bool runtime_send_hello_response( static bool runtime_worker_send_frame(cbm_daemon_runtime_worker_t *worker, cbm_daemon_frame_type_t type, - cbm_daemon_runtime_operation_t operation, - const void *payload, uint32_t length) { + cbm_daemon_runtime_operation_t operation, const void *payload, + uint32_t length) { if (!worker) { return false; } cbm_mutex_lock(&worker->send_mutex); - bool sent = worker->connection && - cbm_daemon_ipc_send_frame(worker->connection, type, - (uint16_t)operation, payload, length); + bool sent = + worker->connection && + cbm_daemon_ipc_send_frame(worker->connection, type, (uint16_t)operation, payload, length); cbm_mutex_unlock(&worker->send_mutex); return sent; } static bool runtime_worker_send_status(cbm_daemon_runtime_worker_t *worker, - cbm_daemon_runtime_operation_t operation, - bool success) { + cbm_daemon_runtime_operation_t operation, bool success) { uint8_t payload[STATUS_RESPONSE_SIZE]; runtime_put_u32(payload, success ? 1U : 0U); - return runtime_worker_send_frame(worker, CBM_DAEMON_FRAME_RESPONSE, - operation, payload, + return runtime_worker_send_frame(worker, CBM_DAEMON_FRAME_RESPONSE, operation, payload, (uint32_t)sizeof(payload)); } @@ -1075,21 +972,17 @@ static bool runtime_application_status_is_callback_result( } static bool runtime_worker_send_application_response( - cbm_daemon_runtime_worker_t *worker, - cbm_daemon_runtime_application_token_t request_token, - cbm_daemon_runtime_application_status_t status, - const uint8_t *response, uint32_t response_length, - bool suppress_when_disconnecting) { - if (!worker || - request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID || + cbm_daemon_runtime_worker_t *worker, cbm_daemon_runtime_application_token_t request_token, + cbm_daemon_runtime_application_status_t status, const uint8_t *response, + uint32_t response_length, bool suppress_when_disconnecting) { + if (!worker || request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID || status <= CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR || status > CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED || response_length > CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX || (response_length > 0 && !response)) { return false; } - uint64_t wire_length = APPLICATION_RESPONSE_PREFIX_SIZE + - (uint64_t)response_length; + uint64_t wire_length = APPLICATION_RESPONSE_PREFIX_SIZE + (uint64_t)response_length; if (wire_length > CBM_DAEMON_MAX_FRAME_SIZE || wire_length > UINT32_MAX) { return false; } @@ -1101,19 +994,16 @@ static bool runtime_worker_send_application_response( runtime_put_u32(wire + 8, (uint32_t)status); runtime_put_u32(wire + 12, response_length); if (response_length > 0) { - memcpy(wire + APPLICATION_RESPONSE_PREFIX_SIZE, response, - response_length); + memcpy(wire + APPLICATION_RESPONSE_PREFIX_SIZE, response, response_length); } cbm_mutex_lock(&worker->send_mutex); bool disconnected = suppress_when_disconnecting && - atomic_load_explicit(&worker->disconnecting, - memory_order_acquire); + atomic_load_explicit(&worker->disconnecting, memory_order_acquire); bool sent = !disconnected && worker->connection && - cbm_daemon_ipc_send_frame( - worker->connection, CBM_DAEMON_FRAME_RESPONSE, - CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST, wire, - (uint32_t)wire_length); + cbm_daemon_ipc_send_frame(worker->connection, CBM_DAEMON_FRAME_RESPONSE, + CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST, wire, + (uint32_t)wire_length); cbm_mutex_unlock(&worker->send_mutex); free(wire); return sent; @@ -1128,8 +1018,8 @@ static void runtime_result_rejected(cbm_daemon_runtime_connect_result_t *result, (void)snprintf(result->message, sizeof(result->message), "%s", message); } -static void runtime_service_begin_stopping_locked( - cbm_daemon_runtime_service_t *service, uint64_t deadline, bool emergency) { +static void runtime_service_begin_stopping_locked(cbm_daemon_runtime_service_t *service, + uint64_t deadline, bool emergency) { if (service->state == CBM_DAEMON_RUNTIME_SERVICE_RUNNING) { service->state = CBM_DAEMON_RUNTIME_SERVICE_STOPPING; service->stop_deadline_ms = deadline; @@ -1150,10 +1040,9 @@ static void runtime_service_begin_stopping(cbm_daemon_runtime_service_t *service cbm_mutex_unlock(&service->mutex); } -static void runtime_service_interrupt_connections_except( - cbm_daemon_runtime_service_t *service, - cbm_daemon_runtime_worker_t *except_worker, - bool activation_owner) { +static void runtime_service_interrupt_connections_except(cbm_daemon_runtime_service_t *service, + cbm_daemon_runtime_worker_t *except_worker, + bool activation_owner) { uint64_t now_ms = cbm_now_ms(); cbm_mutex_lock(&service->mutex); if (service->activation_response_inflight && !activation_owner) { @@ -1172,16 +1061,14 @@ static void runtime_service_interrupt_connections_except( cbm_mutex_unlock(&service->mutex); } -static void runtime_service_interrupt_connections( - cbm_daemon_runtime_service_t *service) { +static void runtime_service_interrupt_connections(cbm_daemon_runtime_service_t *service) { runtime_service_interrupt_connections_except(service, NULL, false); } static void runtime_worker_disconnect(cbm_daemon_runtime_worker_t *worker) { cbm_daemon_runtime_service_t *service = worker->service; cbm_daemon_client_id_t client_id = CBM_DAEMON_CLIENT_ID_INVALID; - uint64_t shutdown_deadline = - runtime_deadline_after(service->shutdown_timeout_ms); + uint64_t shutdown_deadline = runtime_deadline_after(service->shutdown_timeout_ms); atomic_store_explicit(&worker->disconnecting, true, memory_order_release); cbm_mutex_lock(&service->mutex); if (worker->admitted) { @@ -1197,8 +1084,7 @@ static void runtime_worker_disconnect(cbm_daemon_runtime_worker_t *worker) { /* A HELLO whose application session is still opening is only a * provisional coordinator client. It cannot keep the generation * alive after the final fully committed frontend disconnects. */ - runtime_service_begin_stopping_locked( - service, shutdown_deadline, false); + runtime_service_begin_stopping_locked(service, shutdown_deadline, false); } } cbm_mutex_unlock(&service->mutex); @@ -1208,11 +1094,10 @@ static void runtime_worker_disconnect(cbm_daemon_runtime_worker_t *worker) { (void)cbm_daemon_client_disconnected(service->coordinator, client_id, cbm_now_ms()); if (worker->application_session_opened && !worker->application_cancelled) { worker->application_cancelled = true; - service->application.session_cancel( - service->application.context, worker->application_session); + service->application.session_cancel(service->application.context, + worker->application_session); } - if (cbm_daemon_coordinator_state(service->coordinator) == - CBM_DAEMON_COORDINATOR_STOPPING) { + if (cbm_daemon_coordinator_state(service->coordinator) == CBM_DAEMON_COORDINATOR_STOPPING) { runtime_service_begin_stopping(service, service->shutdown_timeout_ms, false); } } @@ -1243,12 +1128,11 @@ static bool runtime_worker_admit(cbm_daemon_runtime_worker_t *worker, return client_id != CBM_DAEMON_CLIENT_ID_INVALID; } -static bool runtime_worker_commit_admission( - cbm_daemon_runtime_worker_t *worker) { +static bool runtime_worker_commit_admission(cbm_daemon_runtime_worker_t *worker) { cbm_daemon_runtime_service_t *service = worker->service; cbm_mutex_lock(&service->mutex); - bool committed = service->state == CBM_DAEMON_RUNTIME_SERVICE_RUNNING && - worker->admitted && !worker->admission_committed; + bool committed = service->state == CBM_DAEMON_RUNTIME_SERVICE_RUNNING && worker->admitted && + !worker->admission_committed; if (committed) { worker->admission_committed = true; service->committed_clients++; @@ -1274,31 +1158,26 @@ static bool runtime_worker_handle_subscribe(cbm_daemon_runtime_worker_t *worker, if (memchr(project_key, '\0', key_length) != NULL) { return false; } - cbm_daemon_subscription_id_t subscription_id = - CBM_DAEMON_SUBSCRIPTION_ID_INVALID; + cbm_daemon_subscription_id_t subscription_id = CBM_DAEMON_SUBSCRIPTION_ID_INVALID; cbm_daemon_subscription_result_t result = cbm_daemon_job_subscribe( - worker->service->coordinator, worker->client_id, project_key, - &subscription_id); + worker->service->coordinator, worker->client_id, project_key, &subscription_id); uint8_t response[SUBSCRIBE_RESPONSE_SIZE]; runtime_put_u32(response, (uint32_t)result); runtime_put_u64(response + 4, subscription_id); return runtime_worker_send_frame(worker, CBM_DAEMON_FRAME_RESPONSE, - CBM_DAEMON_RUNTIME_OP_JOB_SUBSCRIBE, - response, (uint32_t)sizeof(response)); + CBM_DAEMON_RUNTIME_OP_JOB_SUBSCRIBE, response, + (uint32_t)sizeof(response)); } static bool runtime_worker_handle_unsubscribe(cbm_daemon_runtime_worker_t *worker, - const uint8_t *payload, - uint32_t length) { - if (!runtime_worker_service_running(worker) || !payload || - length != UNSUBSCRIBE_REQUEST_SIZE) { + const uint8_t *payload, uint32_t length) { + if (!runtime_worker_service_running(worker) || !payload || length != UNSUBSCRIBE_REQUEST_SIZE) { return false; } cbm_daemon_subscription_id_t subscription_id = runtime_get_u64(payload); - bool removed = cbm_daemon_job_unsubscribe(worker->service->coordinator, - worker->client_id, subscription_id); - return runtime_worker_send_status( - worker, CBM_DAEMON_RUNTIME_OP_JOB_UNSUBSCRIBE, removed); + bool removed = cbm_daemon_job_unsubscribe(worker->service->coordinator, worker->client_id, + subscription_id); + return runtime_worker_send_status(worker, CBM_DAEMON_RUNTIME_OP_JOB_UNSUBSCRIBE, removed); } static void *runtime_application_worker(void *opaque) { @@ -1306,19 +1185,16 @@ static void *runtime_application_worker(void *opaque) { cbm_daemon_runtime_service_t *service = worker->service; uint8_t *response = NULL; uint32_t response_length = 0; - cbm_daemon_runtime_application_status_t status = - service->application.request( - service->application.context, worker->application_session, - worker->application_request_token, - worker->application_request, worker->application_request_length, - &response, &response_length); + cbm_daemon_runtime_application_status_t status = service->application.request( + service->application.context, worker->application_session, + worker->application_request_token, worker->application_request, + worker->application_request_length, &response, &response_length); bool valid_status = runtime_application_status_is_callback_result(status); bool valid_response = response_length <= CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX && (response_length == 0 || response != NULL) && - (status == CBM_DAEMON_RUNTIME_APPLICATION_OK || - (response == NULL && response_length == 0)); + (status == CBM_DAEMON_RUNTIME_APPLICATION_OK || (response == NULL && response_length == 0)); if (!valid_status || !valid_response) { free(response); response = NULL; @@ -1326,27 +1202,21 @@ static void *runtime_application_worker(void *opaque) { status = CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; } - bool sent = runtime_worker_send_application_response( - worker, worker->application_request_token, status, response, - response_length, true); + bool sent = runtime_worker_send_application_response(worker, worker->application_request_token, + status, response, response_length, true); free(response); - if (!sent && !atomic_load_explicit(&worker->disconnecting, - memory_order_acquire)) { + if (!sent && !atomic_load_explicit(&worker->disconnecting, memory_order_acquire)) { cbm_daemon_ipc_connection_interrupt(worker->connection); } - atomic_store_explicit(&worker->application_thread_done, true, - memory_order_release); + atomic_store_explicit(&worker->application_thread_done, true, memory_order_release); return NULL; } -static bool runtime_worker_reap_application( - cbm_daemon_runtime_worker_t *worker, bool wait) { +static bool runtime_worker_reap_application(cbm_daemon_runtime_worker_t *worker, bool wait) { if (!worker->application_thread_started) { return true; } - if (!wait && - !atomic_load_explicit(&worker->application_thread_done, - memory_order_acquire)) { + if (!wait && !atomic_load_explicit(&worker->application_thread_done, memory_order_acquire)) { return false; } if (cbm_thread_join(&worker->application_thread) != 0) { @@ -1356,21 +1226,17 @@ static bool runtime_worker_reap_application( free(worker->application_request); worker->application_request = NULL; worker->application_request_length = 0; - worker->application_request_token = - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; - atomic_store_explicit(&worker->application_thread_done, false, - memory_order_release); + worker->application_request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + atomic_store_explicit(&worker->application_thread_done, false, memory_order_release); return true; } -static bool runtime_worker_handle_application( - cbm_daemon_runtime_worker_t *worker, const uint8_t *payload, - uint32_t length) { +static bool runtime_worker_handle_application(cbm_daemon_runtime_worker_t *worker, + const uint8_t *payload, uint32_t length) { if (!payload || length < APPLICATION_REQUEST_PREFIX_SIZE) { return false; } - cbm_daemon_runtime_application_token_t request_token = - runtime_get_u64(payload); + cbm_daemon_runtime_application_token_t request_token = runtime_get_u64(payload); uint32_t request_length = runtime_get_u32(payload + 8); if (request_length > CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX || length != APPLICATION_REQUEST_PREFIX_SIZE + request_length || @@ -1385,14 +1251,12 @@ static bool runtime_worker_handle_application( cbm_daemon_runtime_service_t *service = worker->service; if (!service->application.request) { return runtime_worker_send_application_response( - worker, request_token, - CBM_DAEMON_RUNTIME_APPLICATION_UNAVAILABLE, NULL, 0, false); + worker, request_token, CBM_DAEMON_RUNTIME_APPLICATION_UNAVAILABLE, NULL, 0, false); } (void)runtime_worker_reap_application(worker, false); if (worker->application_thread_started) { return runtime_worker_send_application_response( - worker, request_token, CBM_DAEMON_RUNTIME_APPLICATION_BUSY, NULL, - 0, false); + worker, request_token, CBM_DAEMON_RUNTIME_APPLICATION_BUSY, NULL, 0, false); } uint8_t *request_copy = NULL; @@ -1400,57 +1264,47 @@ static bool runtime_worker_handle_application( request_copy = malloc(request_length); if (!request_copy) { return runtime_worker_send_application_response( - worker, request_token, - CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR, NULL, 0, false); + worker, request_token, CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR, NULL, 0, + false); } - memcpy(request_copy, payload + APPLICATION_REQUEST_PREFIX_SIZE, - request_length); + memcpy(request_copy, payload + APPLICATION_REQUEST_PREFIX_SIZE, request_length); } worker->application_request_token = request_token; worker->application_request = request_copy; worker->application_request_length = request_length; - atomic_store_explicit(&worker->application_thread_done, false, - memory_order_release); - if (cbm_thread_create(&worker->application_thread, - RUNTIME_WORKER_STACK_SIZE, + atomic_store_explicit(&worker->application_thread_done, false, memory_order_release); + if (cbm_thread_create(&worker->application_thread, RUNTIME_WORKER_STACK_SIZE, runtime_application_worker, worker) != 0) { free(worker->application_request); worker->application_request = NULL; worker->application_request_length = 0; return runtime_worker_send_application_response( - worker, request_token, - CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR, NULL, 0, false); + worker, request_token, CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR, NULL, 0, false); } worker->application_thread_started = true; return true; } -static bool runtime_worker_handle_application_cancel( - cbm_daemon_runtime_worker_t *worker, const uint8_t *payload, - uint32_t length) { +static bool runtime_worker_handle_application_cancel(cbm_daemon_runtime_worker_t *worker, + const uint8_t *payload, uint32_t length) { if (!worker || !payload || length != APPLICATION_CANCEL_REQUEST_SIZE) { return false; } - cbm_daemon_runtime_application_token_t request_token = - runtime_get_u64(payload); + cbm_daemon_runtime_application_token_t request_token = runtime_get_u64(payload); if (request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { return false; } - if (worker->application_thread_started && - worker->application_request_token == request_token && - !atomic_load_explicit(&worker->application_thread_done, - memory_order_acquire)) { - worker->service->application.request_cancel( - worker->service->application.context, - worker->application_session, request_token); + if (worker->application_thread_started && worker->application_request_token == request_token && + !atomic_load_explicit(&worker->application_thread_done, memory_order_acquire)) { + worker->service->application.request_cancel(worker->service->application.context, + worker->application_session, request_token); } /* Cancellation is deliberately one-way. A stale exact-sized token is a * harmless no-op and never changes stream response ordering. */ return true; } -static bool runtime_worker_handle_disconnect(cbm_daemon_runtime_worker_t *worker, - uint32_t length) { +static bool runtime_worker_handle_disconnect(cbm_daemon_runtime_worker_t *worker, uint32_t length) { if (length != 0) { return false; } @@ -1459,8 +1313,7 @@ static bool runtime_worker_handle_disconnect(cbm_daemon_runtime_worker_t *worker worker->final_response_inflight = true; cbm_mutex_unlock(&service->mutex); runtime_worker_disconnect(worker); - bool sent = runtime_worker_send_status( - worker, CBM_DAEMON_RUNTIME_OP_DISCONNECT, true); + bool sent = runtime_worker_send_status(worker, CBM_DAEMON_RUNTIME_OP_DISCONNECT, true); cbm_mutex_lock(&service->mutex); worker->final_response_inflight = false; cbm_mutex_unlock(&service->mutex); @@ -1494,27 +1347,23 @@ static void runtime_worker_finish(cbm_daemon_runtime_worker_t *worker) { cbm_mutex_unlock(&service->mutex); } -static bool runtime_activation_peer_matches_claim( - cbm_daemon_runtime_service_t *service, uint64_t process_id, - const char *claimed_build) { +static bool runtime_activation_peer_matches_claim(cbm_daemon_runtime_service_t *service, + uint64_t process_id, const char *claimed_build) { if (!service || process_id == 0 || !claimed_build) { return false; } - bool active_image = runtime_process_image_reference_matches_process( - &service->active_image, process_id); - if (active_image && - strcmp(claimed_build, service->identity.build_fingerprint) == 0) { + bool active_image = + runtime_process_image_reference_matches_process(&service->active_image, process_id); + if (active_image && strcmp(claimed_build, service->identity.build_fingerprint) == 0) { return true; } char peer_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; - return cbm_daemon_runtime_process_build_fingerprint( - process_id, peer_fingerprint) && + return cbm_daemon_runtime_process_build_fingerprint(process_id, peer_fingerprint) && strcmp(peer_fingerprint, claimed_build) == 0; } -static void runtime_worker_handle_activation_shutdown( - cbm_daemon_runtime_worker_t *worker, const uint8_t *payload, - uint32_t length) { +static void runtime_worker_handle_activation_shutdown(cbm_daemon_runtime_worker_t *worker, + const uint8_t *payload, uint32_t length) { cbm_daemon_runtime_service_t *service = worker->service; cbm_daemon_runtime_activation_result_t result = {0}; cbm_daemon_runtime_activation_action_t action = 0; @@ -1522,24 +1371,17 @@ static void runtime_worker_handle_activation_shutdown( char requested_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; bool request_valid = length == CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE && payload && - runtime_activation_request_decode(payload, &action, - requested_version, - requested_build); - bool peer_verified = request_valid && - runtime_activation_peer_matches_claim( - service, worker->peer_process_id, - requested_build); + runtime_activation_request_decode(payload, &action, requested_version, requested_build); + bool peer_verified = request_valid && runtime_activation_peer_matches_claim( + service, worker->peer_process_id, requested_build); if (peer_verified) { - uint64_t deadline = - runtime_deadline_after(service->shutdown_timeout_ms); + uint64_t deadline = runtime_deadline_after(service->shutdown_timeout_ms); cbm_mutex_lock(&service->mutex); result.active_clients = (uint64_t)service->committed_clients; /* Report only connections this request will drain. The authenticated * one-shot requester is never a session and excludes itself. */ result.active_connections = - service->active_connections > 0 - ? (uint64_t)(service->active_connections - 1U) - : 0; + service->active_connections > 0 ? (uint64_t)(service->active_connections - 1U) : 0; if (service->state == CBM_DAEMON_RUNTIME_SERVICE_RUNNING) { runtime_service_begin_stopping_locked(service, deadline, false); service->activation_shutdown_requested = true; @@ -1562,21 +1404,19 @@ static void runtime_worker_handle_activation_shutdown( (unsigned long long)result.active_clients); (void)snprintf(active_connections, sizeof(active_connections), "%llu", (unsigned long long)result.active_connections); - cbm_log_info("daemon.activation_shutdown", "requester_pid", - requester_pid, "requester_build", requested_build, - "action", runtime_activation_action_text(action), - "active_clients", active_clients, + cbm_log_info("daemon.activation_shutdown", "requester_pid", requester_pid, + "requester_build", requested_build, "action", + runtime_activation_action_text(action), "active_clients", active_clients, "active_connections", active_connections); } - bool response_sent = encoded && runtime_worker_send_frame( - worker, CBM_DAEMON_FRAME_RESPONSE, - CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN, - response, - (uint32_t)sizeof(response)); + bool response_sent = + encoded && runtime_worker_send_frame(worker, CBM_DAEMON_FRAME_RESPONSE, + CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN, response, + (uint32_t)sizeof(response)); if (result.accepted && !response_sent) { - cbm_log_error("daemon.activation_shutdown_ack_failed", "shutdown", - "accepted", "requester_build", requested_build, - "action", runtime_activation_action_text(action)); + cbm_log_error("daemon.activation_shutdown_ack_failed", "shutdown", "accepted", + "requester_build", requested_build, "action", + runtime_activation_action_text(action)); } if (result.accepted) { @@ -1612,15 +1452,13 @@ static void *runtime_connection_worker(void *opaque) { return NULL; } if (frame.flags == CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN) { - runtime_worker_handle_activation_shutdown(worker, payload, - frame.length); + runtime_worker_handle_activation_shutdown(worker, payload, frame.length); free(payload); return NULL; } if (frame.flags != CBM_DAEMON_RUNTIME_OP_HELLO || frame.length != CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE || - !runtime_rendezvous_request_decode(payload, requested_version, - requested_build)) { + !runtime_rendezvous_request_decode(payload, requested_version, requested_build)) { free(payload); runtime_worker_finish(worker); return NULL; @@ -1631,8 +1469,7 @@ static void *runtime_connection_worker(void *opaque) { cbm_daemon_runtime_connect_result_t hello_result; memset(&hello_result, 0, sizeof(hello_result)); cbm_daemon_hello_status_t hello_status = runtime_rendezvous_compare( - service->identity.semantic_version, - service->identity.build_fingerprint, requested_version, + service->identity.semantic_version, service->identity.build_fingerprint, requested_version, requested_build, &hello_result.conflict); if (hello_status != CBM_DAEMON_HELLO_COMPATIBLE) { if (hello_status == CBM_DAEMON_HELLO_INVALID) { @@ -1641,41 +1478,34 @@ static void *runtime_connection_worker(void *opaque) { } hello_result.status = CBM_DAEMON_RUNTIME_CONNECT_CONFLICT; hello_result.hello_status = hello_status; - (void)cbm_daemon_conflict_format(&hello_result.conflict, - hello_result.message, + (void)cbm_daemon_conflict_format(&hello_result.conflict, hello_result.message, sizeof(hello_result.message)); - if (!cbm_daemon_conflict_log_append(service->conflict_log_path, - &hello_result.conflict, + if (!cbm_daemon_conflict_log_append(service->conflict_log_path, &hello_result.conflict, service->conflict_log_cap_bytes)) { /* This goes through the daemon operation-log sink, never back * through the failed conflict-log path. Do not silently lose the * durable startup-conflict diagnostic. */ - cbm_log_error("daemon.conflict_log_append_failed", "path", - service->conflict_log_path); + cbm_log_error("daemon.conflict_log_append_failed", "path", service->conflict_log_path); } (void)runtime_send_hello_response(worker->connection, &hello_result); runtime_worker_finish(worker); return NULL; } - bool peer_image_verified = - runtime_process_image_reference_matches_process( - &service->active_image, worker->peer_process_id); + bool peer_image_verified = runtime_process_image_reference_matches_process( + &service->active_image, worker->peer_process_id); bool peer_image_fingerprinted = false; if (!peer_image_verified) { char peer_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; peer_image_fingerprinted = - cbm_daemon_runtime_process_build_fingerprint( - worker->peer_process_id, peer_fingerprint); + cbm_daemon_runtime_process_build_fingerprint(worker->peer_process_id, peer_fingerprint); peer_image_verified = peer_image_fingerprinted && strcmp(peer_fingerprint, requested_build) == 0 && - strcmp(peer_fingerprint, - service->identity.build_fingerprint) == 0; + strcmp(peer_fingerprint, service->identity.build_fingerprint) == 0; } if (!peer_image_verified) { cbm_log_error("daemon.client_image_rejected", "reason", - peer_image_fingerprinted ? "fingerprint_mismatch" - : "image_unverifiable"); + peer_image_fingerprinted ? "fingerprint_mismatch" : "image_unverifiable"); runtime_worker_finish(worker); return NULL; } @@ -1691,14 +1521,12 @@ static void *runtime_connection_worker(void *opaque) { worker->application_session = service->application.session_open( service->application.context, client_id, worker->peer_process_id); if (!worker->application_session) { - runtime_result_rejected(&hello_result, - "CBM daemon session initialization failed"); + runtime_result_rejected(&hello_result, "CBM daemon session initialization failed"); cbm_mutex_lock(&service->mutex); worker->final_response_inflight = true; cbm_mutex_unlock(&service->mutex); runtime_worker_disconnect(worker); - (void)runtime_send_hello_response(worker->connection, - &hello_result); + (void)runtime_send_hello_response(worker->connection, &hello_result); cbm_mutex_lock(&service->mutex); worker->final_response_inflight = false; cbm_mutex_unlock(&service->mutex); @@ -1738,47 +1566,39 @@ static void *runtime_connection_worker(void *opaque) { while (keep_running && runtime_worker_service_running(worker)) { memset(&frame, 0, sizeof(frame)); payload = NULL; - received = cbm_daemon_ipc_receive_frame(worker->connection, - CBM_DAEMON_IPC_WAIT_FOREVER, + received = cbm_daemon_ipc_receive_frame(worker->connection, CBM_DAEMON_IPC_WAIT_FOREVER, &frame, &payload); if (received != 1 || frame.type != CBM_DAEMON_FRAME_REQUEST) { free(payload); break; } switch (frame.flags) { - case CBM_DAEMON_RUNTIME_OP_HEARTBEAT: { - bool valid = frame.length == 0 && - cbm_daemon_client_heartbeat(service->coordinator, - client_id, cbm_now_ms()); - keep_running = valid && runtime_worker_send_status( - worker, - CBM_DAEMON_RUNTIME_OP_HEARTBEAT, - true); - break; - } - case CBM_DAEMON_RUNTIME_OP_JOB_SUBSCRIBE: - keep_running = runtime_worker_handle_subscribe( - worker, payload, frame.length); - break; - case CBM_DAEMON_RUNTIME_OP_JOB_UNSUBSCRIBE: - keep_running = runtime_worker_handle_unsubscribe( - worker, payload, frame.length); - break; - case CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST: - keep_running = runtime_worker_handle_application( - worker, payload, frame.length); - break; - case CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL: - keep_running = runtime_worker_handle_application_cancel( - worker, payload, frame.length); - break; - case CBM_DAEMON_RUNTIME_OP_DISCONNECT: - keep_running = false; - (void)runtime_worker_handle_disconnect(worker, frame.length); - break; - default: - keep_running = false; - break; + case CBM_DAEMON_RUNTIME_OP_HEARTBEAT: { + bool valid = frame.length == 0 && + cbm_daemon_client_heartbeat(service->coordinator, client_id, cbm_now_ms()); + keep_running = + valid && runtime_worker_send_status(worker, CBM_DAEMON_RUNTIME_OP_HEARTBEAT, true); + break; + } + case CBM_DAEMON_RUNTIME_OP_JOB_SUBSCRIBE: + keep_running = runtime_worker_handle_subscribe(worker, payload, frame.length); + break; + case CBM_DAEMON_RUNTIME_OP_JOB_UNSUBSCRIBE: + keep_running = runtime_worker_handle_unsubscribe(worker, payload, frame.length); + break; + case CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST: + keep_running = runtime_worker_handle_application(worker, payload, frame.length); + break; + case CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL: + keep_running = runtime_worker_handle_application_cancel(worker, payload, frame.length); + break; + case CBM_DAEMON_RUNTIME_OP_DISCONNECT: + keep_running = false; + (void)runtime_worker_handle_disconnect(worker, frame.length); + break; + default: + keep_running = false; + break; } free(payload); payload = NULL; @@ -1803,16 +1623,13 @@ static void runtime_worker_reset_after_join(cbm_daemon_runtime_worker_t *worker) worker->application_session_opened = false; worker->application_cancelled = false; worker->application_thread_started = false; - worker->application_request_token = - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; - worker->last_application_request_token = - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + worker->application_request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + worker->last_application_request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; worker->application_request = NULL; worker->application_request_length = 0; atomic_store_explicit(&worker->done, false, memory_order_release); atomic_store_explicit(&worker->disconnecting, false, memory_order_release); - atomic_store_explicit(&worker->application_thread_done, false, - memory_order_release); + atomic_store_explicit(&worker->application_thread_done, false, memory_order_release); } static void runtime_reap_completed_workers(cbm_daemon_runtime_service_t *service) { @@ -1849,8 +1666,7 @@ static cbm_daemon_runtime_worker_t *runtime_find_free_worker_locked( return NULL; } -static void runtime_reject_inline(cbm_daemon_ipc_connection_t *connection, - const char *message) { +static void runtime_reject_inline(cbm_daemon_ipc_connection_t *connection, const char *message) { cbm_daemon_runtime_connect_result_t result; runtime_result_rejected(&result, message); (void)runtime_send_hello_response(connection, &result); @@ -1887,13 +1703,10 @@ static void runtime_accept_connection(cbm_daemon_runtime_service_t *service, worker->joining = false; worker->in_use = true; atomic_store_explicit(&worker->done, false, memory_order_release); - atomic_store_explicit(&worker->disconnecting, false, - memory_order_release); - atomic_store_explicit(&worker->application_thread_done, false, - memory_order_release); + atomic_store_explicit(&worker->disconnecting, false, memory_order_release); + atomic_store_explicit(&worker->application_thread_done, false, memory_order_release); service->active_connections++; - int created = cbm_thread_create(&worker->thread, - RUNTIME_WORKER_STACK_SIZE, + int created = cbm_thread_create(&worker->thread, RUNTIME_WORKER_STACK_SIZE, runtime_connection_worker, worker); if (created == 0) { worker->thread_started = true; @@ -1912,12 +1725,10 @@ static void runtime_accept_connection(cbm_daemon_runtime_service_t *service, } } -static bool runtime_service_can_exit(cbm_daemon_runtime_service_t *service, - uint64_t now_ms) { +static bool runtime_service_can_exit(cbm_daemon_runtime_service_t *service, uint64_t now_ms) { bool can_exit = false; cbm_mutex_lock(&service->mutex); - if (service->state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING && - service->active_connections == 0) { + if (service->state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING && service->active_connections == 0) { bool drained = cbm_daemon_should_exit(service->coordinator, now_ms); if (!drained && service->activation_shutdown_requested) { /* Activation can arrive while the daemon has no committed @@ -1928,8 +1739,8 @@ static bool runtime_service_can_exit(cbm_daemon_runtime_service_t *service, cbm_daemon_active_jobs(service->coordinator) == 0 && cbm_daemon_active_watches(service->coordinator) == 0; } - bool deadline_reached = service->stop_deadline_ms != 0 && - now_ms >= service->stop_deadline_ms; + bool deadline_reached = + service->stop_deadline_ms != 0 && now_ms >= service->stop_deadline_ms; can_exit = drained || deadline_reached || service->emergency_stop; if (can_exit) { service->state = CBM_DAEMON_RUNTIME_SERVICE_EXITED; @@ -1960,9 +1771,8 @@ static void *runtime_accept_loop(void *opaque) { } cbm_daemon_ipc_connection_t *connection = NULL; - int accepted = cbm_daemon_ipc_accept(service->listener, - RUNTIME_ACCEPT_POLL_MS, - &connection); + int accepted = + cbm_daemon_ipc_accept(service->listener, RUNTIME_ACCEPT_POLL_MS, &connection); if (accepted == 1 && connection) { runtime_accept_connection(service, connection); } else if (accepted < 0) { @@ -1991,11 +1801,9 @@ static char *runtime_string_copy_bounded(const char *value, size_t capacity) { static bool runtime_application_callbacks_valid( const cbm_daemon_runtime_application_callbacks_t *application) { - bool any = application->session_open || application->request || - application->request_cancel || application->session_cancel || - application->session_close; - bool all = application->session_open && application->request && - application->request_cancel && + bool any = application->session_open || application->request || application->request_cancel || + application->session_cancel || application->session_close; + bool all = application->session_open && application->request && application->request_cancel && application->session_cancel && application->session_close; return !any || all; } @@ -2031,8 +1839,8 @@ static _Noreturn void runtime_cleanup_fail_stop(const char *component) { #endif } -static void runtime_startup_lock_release_complete( - cbm_daemon_ipc_startup_lock_t **lock_io, uint32_t timeout_ms) { +static void runtime_startup_lock_release_complete(cbm_daemon_ipc_startup_lock_t **lock_io, + uint32_t timeout_ms) { uint64_t deadline = runtime_deadline_after(timeout_ms); while (lock_io && *lock_io) { (void)cbm_daemon_ipc_startup_lock_release(lock_io); @@ -2046,8 +1854,7 @@ static void runtime_startup_lock_release_complete( } } -static void runtime_service_destroy_unstarted( - cbm_daemon_runtime_service_t *service) { +static void runtime_service_destroy_unstarted(cbm_daemon_runtime_service_t *service) { if (!service) { return; } @@ -2071,38 +1878,32 @@ cbm_daemon_runtime_service_t *cbm_daemon_runtime_service_start_reserved( runtime_process_image_reference_t active_image; runtime_process_image_reference_init(&active_image); if (!reservation_io || !*reservation_io || !config || !config->endpoint || - !config->conflict_log_path || - config->conflict_log_cap_bytes == 0 || config->max_clients == 0 || - config->max_clients > RUNTIME_MAX_CLIENTS_HARD || + !config->conflict_log_path || config->conflict_log_cap_bytes == 0 || + config->max_clients == 0 || config->max_clients > RUNTIME_MAX_CLIENTS_HARD || config->lease_timeout_ms == 0 || config->request_timeout_ms == 0 || config->request_timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || config->shutdown_timeout_ms == 0 || config->shutdown_timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || !runtime_application_callbacks_valid(&config->application) || !cbm_daemon_runtime_hello_request_encode(validation, &config->identity)) { - cbm_log_error("daemon.runtime.start_failed", "stage", - "config_validation"); + cbm_log_error("daemon.runtime.start_failed", "stage", "config_validation"); return NULL; } /* WHY: cppcheck evaluates the unsupported-platform fail-closed branch * because its invocation defines no host OS macro. Production compilers * select one of the Windows/macOS/Linux native-image implementations. */ // cppcheck-suppress knownConditionTrueFalse - if (!runtime_process_image_reference_acquire( - runtime_current_process_id(), &active_image, - active_process_fingerprint) || - strcmp(active_process_fingerprint, - config->identity.build_fingerprint) != 0) { - cbm_log_error("daemon.runtime.start_failed", "stage", - "active_image_identity"); + if (!runtime_process_image_reference_acquire(runtime_current_process_id(), &active_image, + active_process_fingerprint) || + strcmp(active_process_fingerprint, config->identity.build_fingerprint) != 0) { + cbm_log_error("daemon.runtime.start_failed", "stage", "active_image_identity"); (void)runtime_process_image_reference_release(&active_image); return NULL; } cbm_daemon_runtime_service_t *service = calloc(1, sizeof(*service)); if (!service) { - cbm_log_error("daemon.runtime.start_failed", "stage", - "service_allocation"); + cbm_log_error("daemon.runtime.start_failed", "stage", "service_allocation"); (void)runtime_process_image_reference_release(&active_image); return NULL; } @@ -2117,17 +1918,14 @@ cbm_daemon_runtime_service_t *cbm_daemon_runtime_service_start_reserved( runtime_string_copy_bounded(config->conflict_log_path, RUNTIME_PATH_CAP); size_t version_length = 0; size_t build_length = 0; - bool copied_identity = runtime_bounded_length(config->identity.semantic_version, - sizeof(service->semantic_version), - &version_length) && - runtime_bounded_length(config->identity.build_fingerprint, - sizeof(service->build_fingerprint), - &build_length); + bool copied_identity = + runtime_bounded_length(config->identity.semantic_version, sizeof(service->semantic_version), + &version_length) && + runtime_bounded_length(config->identity.build_fingerprint, + sizeof(service->build_fingerprint), &build_length); if (copied_identity) { - memcpy(service->semantic_version, config->identity.semantic_version, - version_length + 1); - memcpy(service->build_fingerprint, config->identity.build_fingerprint, - build_length + 1); + memcpy(service->semantic_version, config->identity.semantic_version, version_length + 1); + memcpy(service->build_fingerprint, config->identity.build_fingerprint, build_length + 1); } service->identity.semantic_version = service->semantic_version; service->identity.build_fingerprint = service->build_fingerprint; @@ -2143,8 +1941,7 @@ cbm_daemon_runtime_service_t *cbm_daemon_runtime_service_start_reserved( if (!service->workers || !service->coordinator || !service->conflict_log_path || !copied_identity) { - cbm_log_error("daemon.runtime.start_failed", "stage", - "service_initialization"); + cbm_log_error("daemon.runtime.start_failed", "stage", "service_initialization"); runtime_service_destroy_unstarted(service); return NULL; } @@ -2156,19 +1953,16 @@ cbm_daemon_runtime_service_t *cbm_daemon_runtime_service_start_reserved( atomic_init(&service->workers[i].disconnecting, false); atomic_init(&service->workers[i].application_thread_done, false); } - service->listener = - cbm_daemon_ipc_listen_reserved(config->endpoint, reservation_io); + service->listener = cbm_daemon_ipc_listen_reserved(config->endpoint, reservation_io); if (!service->listener) { - cbm_log_error("daemon.runtime.start_failed", "stage", - "listener_handoff"); + cbm_log_error("daemon.runtime.start_failed", "stage", "listener_handoff"); runtime_service_destroy_unstarted(service); return NULL; } service->state = CBM_DAEMON_RUNTIME_SERVICE_RUNNING; - if (cbm_thread_create(&service->accept_thread, RUNTIME_WORKER_STACK_SIZE, - runtime_accept_loop, service) != 0) { - cbm_log_error("daemon.runtime.start_failed", "stage", - "accept_thread"); + if (cbm_thread_create(&service->accept_thread, RUNTIME_WORKER_STACK_SIZE, runtime_accept_loop, + service) != 0) { + cbm_log_error("daemon.runtime.start_failed", "stage", "accept_thread"); service->state = CBM_DAEMON_RUNTIME_SERVICE_EXITED; runtime_service_destroy_unstarted(service); return NULL; @@ -2183,56 +1977,47 @@ cbm_daemon_runtime_service_t *cbm_daemon_runtime_service_start( return NULL; } cbm_daemon_ipc_startup_lock_t *startup_lock = NULL; - if (cbm_daemon_ipc_startup_lock_try_acquire( - config->endpoint, &startup_lock) != 1) { + if (cbm_daemon_ipc_startup_lock_try_acquire(config->endpoint, &startup_lock) != 1) { return NULL; } - int generation = cbm_daemon_ipc_generation_probe_under_startup_lock( - config->endpoint, startup_lock); - if (generation != 0 || - !cbm_daemon_ipc_startup_lock_prepare_handoff(startup_lock)) { - runtime_startup_lock_release_complete( - &startup_lock, config->shutdown_timeout_ms); + int generation = + cbm_daemon_ipc_generation_probe_under_startup_lock(config->endpoint, startup_lock); + if (generation != 0 || !cbm_daemon_ipc_startup_lock_prepare_handoff(startup_lock)) { + runtime_startup_lock_release_complete(&startup_lock, config->shutdown_timeout_ms); return NULL; } cbm_daemon_ipc_participant_guard_t *participant_guard = NULL; - if (cbm_daemon_ipc_participant_guard_try_join( - config->endpoint, &participant_guard) != 1) { - runtime_startup_lock_release_complete( - &startup_lock, config->shutdown_timeout_ms); + if (cbm_daemon_ipc_participant_guard_try_join(config->endpoint, &participant_guard) != 1) { + runtime_startup_lock_release_complete(&startup_lock, config->shutdown_timeout_ms); return NULL; } cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL; - if (cbm_daemon_ipc_lifetime_reservation_try_acquire( - config->endpoint, &reservation) != 1) { - if (!runtime_participant_guard_release_complete( - &participant_guard, config->shutdown_timeout_ms)) { + if (cbm_daemon_ipc_lifetime_reservation_try_acquire(config->endpoint, &reservation) != 1) { + if (!runtime_participant_guard_release_complete(&participant_guard, + config->shutdown_timeout_ms)) { runtime_cleanup_fail_stop("participant_guard_cleanup"); } - runtime_startup_lock_release_complete( - &startup_lock, config->shutdown_timeout_ms); + runtime_startup_lock_release_complete(&startup_lock, config->shutdown_timeout_ms); return NULL; } cbm_daemon_runtime_service_t *service = cbm_daemon_runtime_service_start_reserved(config, &reservation); cbm_daemon_ipc_lifetime_reservation_release(reservation); if (!service) { - if (!runtime_participant_guard_release_complete( - &participant_guard, config->shutdown_timeout_ms)) { + if (!runtime_participant_guard_release_complete(&participant_guard, + config->shutdown_timeout_ms)) { runtime_cleanup_fail_stop("participant_guard_cleanup"); } - runtime_startup_lock_release_complete( - &startup_lock, config->shutdown_timeout_ms); + runtime_startup_lock_release_complete(&startup_lock, config->shutdown_timeout_ms); return NULL; } service->owned_participant_guard = participant_guard; - runtime_startup_lock_release_complete( - &startup_lock, config->shutdown_timeout_ms); + runtime_startup_lock_release_complete(&startup_lock, config->shutdown_timeout_ms); return service; } -cbm_daemon_runtime_service_state_t -cbm_daemon_runtime_service_state(cbm_daemon_runtime_service_t *service) { +cbm_daemon_runtime_service_state_t cbm_daemon_runtime_service_state( + cbm_daemon_runtime_service_t *service) { if (!service) { return CBM_DAEMON_RUNTIME_SERVICE_EXITED; } @@ -2242,8 +2027,7 @@ cbm_daemon_runtime_service_state(cbm_daemon_runtime_service_t *service) { return state; } -size_t cbm_daemon_runtime_service_active_clients( - cbm_daemon_runtime_service_t *service) { +size_t cbm_daemon_runtime_service_active_clients(cbm_daemon_runtime_service_t *service) { if (!service) { return 0; } @@ -2253,8 +2037,7 @@ size_t cbm_daemon_runtime_service_active_clients( return count; } -size_t cbm_daemon_runtime_service_active_connections( - cbm_daemon_runtime_service_t *service) { +size_t cbm_daemon_runtime_service_active_connections(cbm_daemon_runtime_service_t *service) { if (!service) { return 0; } @@ -2264,13 +2047,13 @@ size_t cbm_daemon_runtime_service_active_connections( return count; } -size_t cbm_daemon_runtime_service_job_subscribers( - cbm_daemon_runtime_service_t *service, const char *project_key) { +size_t cbm_daemon_runtime_service_job_subscribers(cbm_daemon_runtime_service_t *service, + const char *project_key) { return service ? cbm_daemon_job_subscribers(service->coordinator, project_key) : 0; } -uint64_t cbm_daemon_runtime_service_client_process_id( - cbm_daemon_runtime_service_t *service, cbm_daemon_client_id_t client_id) { +uint64_t cbm_daemon_runtime_service_client_process_id(cbm_daemon_runtime_service_t *service, + cbm_daemon_client_id_t client_id) { if (!service || client_id == CBM_DAEMON_CLIENT_ID_INVALID) { return 0; } @@ -2278,8 +2061,8 @@ uint64_t cbm_daemon_runtime_service_client_process_id( cbm_mutex_lock(&service->mutex); for (size_t i = 0; i < service->worker_capacity; i++) { cbm_daemon_runtime_worker_t *worker = &service->workers[i]; - if (worker->in_use && worker->admitted && - worker->admission_committed && worker->client_id == client_id) { + if (worker->in_use && worker->admitted && worker->admission_committed && + worker->client_id == client_id) { process_id = worker->peer_process_id; break; } @@ -2288,17 +2071,15 @@ uint64_t cbm_daemon_runtime_service_client_process_id( return process_id; } -static bool runtime_wait_for_count(cbm_daemon_runtime_service_t *service, - size_t expected, uint32_t timeout_ms, - bool connections) { +static bool runtime_wait_for_count(cbm_daemon_runtime_service_t *service, size_t expected, + uint32_t timeout_ms, bool connections) { if (!service) { return false; } uint64_t deadline = runtime_deadline_after(timeout_ms); for (;;) { - size_t actual = connections - ? cbm_daemon_runtime_service_active_connections(service) - : cbm_daemon_runtime_service_active_clients(service); + size_t actual = connections ? cbm_daemon_runtime_service_active_connections(service) + : cbm_daemon_runtime_service_active_clients(service); if (actual == expected) { return true; } @@ -2309,27 +2090,25 @@ static bool runtime_wait_for_count(cbm_daemon_runtime_service_t *service, } } -bool cbm_daemon_runtime_service_wait_for_clients( - cbm_daemon_runtime_service_t *service, size_t expected, uint32_t timeout_ms) { +bool cbm_daemon_runtime_service_wait_for_clients(cbm_daemon_runtime_service_t *service, + size_t expected, uint32_t timeout_ms) { return runtime_wait_for_count(service, expected, timeout_ms, false); } -bool cbm_daemon_runtime_service_wait_for_connections( - cbm_daemon_runtime_service_t *service, size_t expected, uint32_t timeout_ms) { +bool cbm_daemon_runtime_service_wait_for_connections(cbm_daemon_runtime_service_t *service, + size_t expected, uint32_t timeout_ms) { return runtime_wait_for_count(service, expected, timeout_ms, true); } -bool cbm_daemon_runtime_service_wait_exited( - cbm_daemon_runtime_service_t *service, uint32_t timeout_ms) { +bool cbm_daemon_runtime_service_wait_exited(cbm_daemon_runtime_service_t *service, + uint32_t timeout_ms) { if (!service) { return false; } uint64_t deadline = runtime_deadline_after(timeout_ms); for (;;) { - if (cbm_daemon_runtime_service_state(service) == - CBM_DAEMON_RUNTIME_SERVICE_EXITED && - atomic_load_explicit(&service->accept_thread_done, - memory_order_acquire)) { + if (cbm_daemon_runtime_service_state(service) == CBM_DAEMON_RUNTIME_SERVICE_EXITED && + atomic_load_explicit(&service->accept_thread_done, memory_order_acquire)) { return true; } if (cbm_now_ms() >= deadline) { @@ -2339,19 +2118,16 @@ bool cbm_daemon_runtime_service_wait_exited( } } -bool cbm_daemon_runtime_service_job_reaped( - cbm_daemon_runtime_service_t *service, const char *project_key) { - return service && cbm_daemon_job_reaped(service->coordinator, project_key, - cbm_now_ms()); +bool cbm_daemon_runtime_service_job_reaped(cbm_daemon_runtime_service_t *service, + const char *project_key) { + return service && cbm_daemon_job_reaped(service->coordinator, project_key, cbm_now_ms()); } -bool cbm_daemon_runtime_service_stop(cbm_daemon_runtime_service_t *service, - uint32_t timeout_ms) { +bool cbm_daemon_runtime_service_stop(cbm_daemon_runtime_service_t *service, uint32_t timeout_ms) { if (!service) { return false; } - if (cbm_daemon_runtime_service_state(service) == - CBM_DAEMON_RUNTIME_SERVICE_EXITED) { + if (cbm_daemon_runtime_service_state(service) == CBM_DAEMON_RUNTIME_SERVICE_EXITED) { return cbm_daemon_runtime_service_wait_exited(service, timeout_ms); } runtime_service_begin_stopping(service, timeout_ms, true); @@ -2360,8 +2136,8 @@ bool cbm_daemon_runtime_service_stop(cbm_daemon_runtime_service_t *service, } bool cbm_daemon_runtime_service_free(cbm_daemon_runtime_service_t *service) { - if (!service || cbm_daemon_runtime_service_state(service) != - CBM_DAEMON_RUNTIME_SERVICE_EXITED) { + if (!service || + cbm_daemon_runtime_service_state(service) != CBM_DAEMON_RUNTIME_SERVICE_EXITED) { return false; } if (service->accept_thread_started && !service->accept_thread_joined) { @@ -2382,9 +2158,8 @@ bool cbm_daemon_runtime_service_free(cbm_daemon_runtime_service_t *service) { } cbm_daemon_ipc_listener_close(service->listener); service->listener = NULL; - if (!runtime_participant_guard_release_complete( - &service->owned_participant_guard, - service->shutdown_timeout_ms)) { + if (!runtime_participant_guard_release_complete(&service->owned_participant_guard, + service->shutdown_timeout_ms)) { /* Preserve the service allocation so the caller can retry or force * process exit without dropping the only live cleanup handle. */ return false; @@ -2402,37 +2177,31 @@ bool cbm_daemon_runtime_service_free(cbm_daemon_runtime_service_t *service) { } bool cbm_daemon_runtime_request_activation_shutdown( - const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_build_identity_t *identity, + const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_build_identity_t *identity, cbm_daemon_runtime_activation_action_t action, uint32_t timeout_ms, cbm_daemon_runtime_activation_result_t *result_out) { if (result_out) { memset(result_out, 0, sizeof(*result_out)); } uint8_t request[CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE]; - if (!endpoint || !identity || !result_out || - timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || + if (!endpoint || !identity || !result_out || timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || !runtime_activation_request_encode(request, action, identity)) { return false; } - cbm_daemon_ipc_connection_t *connection = - cbm_daemon_ipc_connect(endpoint, timeout_ms); - bool sent = connection && cbm_daemon_ipc_send_frame( - connection, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN, - request, (uint32_t)sizeof(request)); + cbm_daemon_ipc_connection_t *connection = cbm_daemon_ipc_connect(endpoint, timeout_ms); + bool sent = connection && cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN, + request, (uint32_t)sizeof(request)); cbm_daemon_frame_t frame = {0}; uint8_t *payload = NULL; int received = sent ? cbm_daemon_ipc_receive_frame_bounded( - connection, timeout_ms, - CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE, + connection, timeout_ms, CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE, &frame, &payload) : 0; - bool valid = - received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && - frame.flags == CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN && - frame.length == CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE && - runtime_activation_response_decode(payload, result_out); + bool valid = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN && + frame.length == CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE && + runtime_activation_response_decode(payload, result_out); free(payload); cbm_daemon_ipc_connection_close(connection); if (!valid) { @@ -2442,32 +2211,27 @@ bool cbm_daemon_runtime_request_activation_shutdown( } cbm_daemon_runtime_client_t *cbm_daemon_runtime_client_connect( - const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_build_identity_t *identity, uint32_t timeout_ms, - cbm_daemon_runtime_connect_result_t *result_out) { + const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_build_identity_t *identity, + uint32_t timeout_ms, cbm_daemon_runtime_connect_result_t *result_out) { if (result_out) { memset(result_out, 0, sizeof(*result_out)); } uint8_t request[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE]; - if (!endpoint || !identity || !result_out || - timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || + if (!endpoint || !identity || !result_out || timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER || !cbm_daemon_runtime_hello_request_encode(request, identity)) { return NULL; } - cbm_daemon_ipc_connection_t *connection = - cbm_daemon_ipc_connect(endpoint, timeout_ms); - if (!connection || - !cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_HELLO, request, - (uint32_t)sizeof(request))) { + cbm_daemon_ipc_connection_t *connection = cbm_daemon_ipc_connect(endpoint, timeout_ms); + if (!connection || !cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_HELLO, request, + (uint32_t)sizeof(request))) { cbm_daemon_ipc_connection_close(connection); return NULL; } cbm_daemon_frame_t frame = {0}; uint8_t *payload = NULL; int received = cbm_daemon_ipc_receive_frame_bounded( - connection, timeout_ms, CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE, - &frame, &payload); + connection, timeout_ms, CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE, &frame, &payload); bool valid = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && frame.flags == CBM_DAEMON_RUNTIME_OP_HELLO && frame.length == CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE && @@ -2494,21 +2258,18 @@ cbm_daemon_runtime_client_t *cbm_daemon_runtime_client_connect( return client; } -cbm_daemon_client_id_t -cbm_daemon_runtime_client_id(const cbm_daemon_runtime_client_t *client) { +cbm_daemon_client_id_t cbm_daemon_runtime_client_id(const cbm_daemon_runtime_client_t *client) { return client ? client->client_id : CBM_DAEMON_CLIENT_ID_INVALID; } -uint64_t cbm_daemon_runtime_client_process_id( - const cbm_daemon_runtime_client_t *client) { +uint64_t cbm_daemon_runtime_client_process_id(const cbm_daemon_runtime_client_t *client) { return client ? client->authenticated_process_id : 0; } static bool runtime_client_exchange(cbm_daemon_runtime_client_t *client, - cbm_daemon_runtime_operation_t operation, - const void *request, uint32_t request_length, - uint32_t timeout_ms, uint8_t **response_out, - uint32_t expected_response_length) { + cbm_daemon_runtime_operation_t operation, const void *request, + uint32_t request_length, uint32_t timeout_ms, + uint8_t **response_out, uint32_t expected_response_length) { *response_out = NULL; if (timeout_ms == CBM_DAEMON_IPC_WAIT_FOREVER) { return false; @@ -2525,19 +2286,15 @@ static bool runtime_client_exchange(cbm_daemon_runtime_client_t *client, cbm_mutex_unlock(&client->state_mutex); cbm_mutex_lock(&client->send_mutex); - bool sent = cbm_daemon_ipc_send_frame( - connection, CBM_DAEMON_FRAME_REQUEST, (uint16_t)operation, request, - request_length); + bool sent = cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, (uint16_t)operation, + request, request_length); cbm_mutex_unlock(&client->send_mutex); cbm_daemon_frame_t frame = {0}; uint8_t *payload = NULL; - int received = sent ? cbm_daemon_ipc_receive_frame( - connection, timeout_ms, &frame, &payload) - : -1; - bool valid = sent && received == 1 && - frame.type == CBM_DAEMON_FRAME_RESPONSE && - frame.flags == (uint16_t)operation && - frame.length == expected_response_length; + int received = + sent ? cbm_daemon_ipc_receive_frame(connection, timeout_ms, &frame, &payload) : -1; + bool valid = sent && received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == (uint16_t)operation && frame.length == expected_response_length; cbm_mutex_lock(&client->state_mutex); client->exchange_active = false; if (!valid) { @@ -2561,29 +2318,24 @@ cbm_daemon_subscription_result_t cbm_daemon_runtime_client_job_subscribe( } size_t key_length = 0; if (!client || !subscription_id_out || - !runtime_bounded_length(project_key, - CBM_DAEMON_RUNTIME_PROJECT_KEY_MAX + 1, - &key_length) || + !runtime_bounded_length(project_key, CBM_DAEMON_RUNTIME_PROJECT_KEY_MAX + 1, &key_length) || key_length == 0 || key_length > CBM_DAEMON_RUNTIME_PROJECT_KEY_MAX) { return CBM_DAEMON_SUBSCRIPTION_REJECTED; } - uint8_t request[SUBSCRIBE_REQUEST_PREFIX_SIZE + - CBM_DAEMON_RUNTIME_PROJECT_KEY_MAX]; + uint8_t request[SUBSCRIBE_REQUEST_PREFIX_SIZE + CBM_DAEMON_RUNTIME_PROJECT_KEY_MAX]; runtime_put_u32(request, (uint32_t)key_length); memcpy(request + SUBSCRIBE_REQUEST_PREFIX_SIZE, project_key, key_length); uint8_t *response = NULL; - if (!runtime_client_exchange(client, CBM_DAEMON_RUNTIME_OP_JOB_SUBSCRIBE, - request, - (uint32_t)(SUBSCRIBE_REQUEST_PREFIX_SIZE + key_length), - timeout_ms, &response, SUBSCRIBE_RESPONSE_SIZE)) { + if (!runtime_client_exchange(client, CBM_DAEMON_RUNTIME_OP_JOB_SUBSCRIBE, request, + (uint32_t)(SUBSCRIBE_REQUEST_PREFIX_SIZE + key_length), timeout_ms, + &response, SUBSCRIBE_RESPONSE_SIZE)) { return CBM_DAEMON_SUBSCRIPTION_REJECTED; } cbm_daemon_subscription_result_t result = (cbm_daemon_subscription_result_t)runtime_get_u32(response); cbm_daemon_subscription_id_t subscription_id = runtime_get_u64(response + 4); free(response); - if ((result != CBM_DAEMON_SUBSCRIPTION_STARTED && - result != CBM_DAEMON_SUBSCRIPTION_JOINED) || + if ((result != CBM_DAEMON_SUBSCRIPTION_STARTED && result != CBM_DAEMON_SUBSCRIPTION_JOINED) || subscription_id == CBM_DAEMON_SUBSCRIPTION_ID_INVALID) { return CBM_DAEMON_SUBSCRIPTION_REJECTED; } @@ -2591,19 +2343,18 @@ cbm_daemon_subscription_result_t cbm_daemon_runtime_client_job_subscribe( return result; } -bool cbm_daemon_runtime_client_job_unsubscribe( - cbm_daemon_runtime_client_t *client, - cbm_daemon_subscription_id_t subscription_id, uint32_t timeout_ms) { +bool cbm_daemon_runtime_client_job_unsubscribe(cbm_daemon_runtime_client_t *client, + cbm_daemon_subscription_id_t subscription_id, + uint32_t timeout_ms) { if (!client || subscription_id == CBM_DAEMON_SUBSCRIPTION_ID_INVALID) { return false; } uint8_t request[UNSUBSCRIBE_REQUEST_SIZE]; runtime_put_u64(request, subscription_id); uint8_t *response = NULL; - if (!runtime_client_exchange(client, - CBM_DAEMON_RUNTIME_OP_JOB_UNSUBSCRIBE, - request, (uint32_t)sizeof(request), timeout_ms, - &response, STATUS_RESPONSE_SIZE)) { + if (!runtime_client_exchange(client, CBM_DAEMON_RUNTIME_OP_JOB_UNSUBSCRIBE, request, + (uint32_t)sizeof(request), timeout_ms, &response, + STATUS_RESPONSE_SIZE)) { return false; } bool removed = runtime_get_u32(response) == 1; @@ -2611,15 +2362,13 @@ bool cbm_daemon_runtime_client_job_unsubscribe( return removed; } -bool cbm_daemon_runtime_client_heartbeat(cbm_daemon_runtime_client_t *client, - uint32_t timeout_ms) { +bool cbm_daemon_runtime_client_heartbeat(cbm_daemon_runtime_client_t *client, uint32_t timeout_ms) { if (!client) { return false; } uint8_t *response = NULL; - if (!runtime_client_exchange(client, CBM_DAEMON_RUNTIME_OP_HEARTBEAT, - NULL, 0, timeout_ms, &response, - STATUS_RESPONSE_SIZE)) { + if (!runtime_client_exchange(client, CBM_DAEMON_RUNTIME_OP_HEARTBEAT, NULL, 0, timeout_ms, + &response, STATUS_RESPONSE_SIZE)) { return false; } bool valid = runtime_get_u32(response) == 1; @@ -2628,8 +2377,7 @@ bool cbm_daemon_runtime_client_heartbeat(cbm_daemon_runtime_client_t *client, } bool cbm_daemon_runtime_client_application_token_reserve( - cbm_daemon_runtime_client_t *client, - cbm_daemon_runtime_application_token_t *token_out) { + cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t *token_out) { if (token_out) { *token_out = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; } @@ -2638,10 +2386,8 @@ bool cbm_daemon_runtime_client_application_token_reserve( } cbm_mutex_lock(&client->state_mutex); bool valid = client->usable && !client->closing && client->connection && - client->active_application_token == - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && - client->next_application_token == - client->last_started_application_token && + client->active_application_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && + client->next_application_token == client->last_started_application_token && client->next_application_token != UINT64_MAX; if (valid) { client->next_application_token++; @@ -2651,12 +2397,9 @@ bool cbm_daemon_runtime_client_application_token_reserve( return valid; } -cbm_daemon_runtime_cancel_result_t -cbm_daemon_runtime_client_application_cancel( - cbm_daemon_runtime_client_t *client, - cbm_daemon_runtime_application_token_t request_token) { - if (!client || - request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { +cbm_daemon_runtime_cancel_result_t cbm_daemon_runtime_client_application_cancel( + cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token) { + if (!client || request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { return CBM_DAEMON_RUNTIME_CANCEL_ERROR; } @@ -2668,10 +2411,8 @@ cbm_daemon_runtime_client_application_cancel( } if (client->active_application_token == request_token) { client->pending_cancel_token = request_token; - dispatch = client->application_request_sent && - !client->application_cancel_sent; - } else if (client->active_application_token == - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && + dispatch = client->application_request_sent && !client->application_cancel_sent; + } else if (client->active_application_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID && request_token == client->next_application_token && request_token > client->last_started_application_token) { /* The frontend publishes its active identity immediately after token @@ -2696,33 +2437,28 @@ cbm_daemon_runtime_client_application_cancel( cbm_mutex_lock(&client->state_mutex); dispatch = client->usable && !client->closing && client->connection && client->active_application_token == request_token && - client->application_request_sent && - !client->application_cancel_sent; + client->application_request_sent && !client->application_cancel_sent; if (dispatch) { client->application_cancel_sent = true; connection = client->connection; } else { - unavailable = !client->usable || client->closing || - !client->connection; - already_sent = client->usable && !client->closing && - client->connection && + unavailable = !client->usable || client->closing || !client->connection; + already_sent = client->usable && !client->closing && client->connection && client->active_application_token == request_token && client->application_cancel_sent; } cbm_mutex_unlock(&client->state_mutex); - bool sent = !dispatch || cbm_daemon_ipc_send_frame( - connection, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, - wire, (uint32_t)sizeof(wire)); + bool sent = !dispatch || cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, + wire, (uint32_t)sizeof(wire)); cbm_mutex_unlock(&client->send_mutex); if (!dispatch) { /* The request path may have observed pending_cancel_token and emitted * the control while this caller waited for send_mutex. That is still * an accepted cancellation, not a stale target. */ - return already_sent - ? CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED - : (unavailable ? CBM_DAEMON_RUNTIME_CANCEL_ERROR - : CBM_DAEMON_RUNTIME_CANCEL_STALE); + return already_sent ? CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED + : (unavailable ? CBM_DAEMON_RUNTIME_CANCEL_ERROR + : CBM_DAEMON_RUNTIME_CANCEL_STALE); } if (!sent) { cbm_mutex_lock(&client->state_mutex); @@ -2734,10 +2470,8 @@ cbm_daemon_runtime_client_application_cancel( return CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED; } -cbm_daemon_runtime_application_status_t -cbm_daemon_runtime_client_application_request_tagged( - cbm_daemon_runtime_client_t *client, - cbm_daemon_runtime_application_token_t request_token, +cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_request_tagged( + cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, const void *request, uint32_t request_length, uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms) { if (response_out) { @@ -2753,8 +2487,7 @@ cbm_daemon_runtime_client_application_request_tagged( (request_length > 0 && !request)) { return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; } - uint64_t wire_length = APPLICATION_REQUEST_PREFIX_SIZE + - (uint64_t)request_length; + uint64_t wire_length = APPLICATION_REQUEST_PREFIX_SIZE + (uint64_t)request_length; if (wire_length > CBM_DAEMON_MAX_FRAME_SIZE || wire_length > UINT32_MAX) { return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; } @@ -2765,8 +2498,7 @@ cbm_daemon_runtime_client_application_request_tagged( runtime_put_u64(wire, request_token); runtime_put_u32(wire + 8, request_length); if (request_length > 0) { - memcpy(wire + APPLICATION_REQUEST_PREFIX_SIZE, request, - request_length); + memcpy(wire + APPLICATION_REQUEST_PREFIX_SIZE, request, request_length); } cbm_mutex_lock(&client->exchange_mutex); @@ -2774,8 +2506,7 @@ cbm_daemon_runtime_client_application_request_tagged( if (!client->usable || client->closing || !client->connection || request_token != client->next_application_token || request_token <= client->last_started_application_token || - client->active_application_token != - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { + client->active_application_token != CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID) { cbm_mutex_unlock(&client->state_mutex); cbm_mutex_unlock(&client->exchange_mutex); free(wire); @@ -2788,33 +2519,29 @@ cbm_daemon_runtime_client_application_request_tagged( client->application_request_sent = false; client->application_cancel_sent = false; if (client->pending_cancel_token != request_token) { - client->pending_cancel_token = - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + client->pending_cancel_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; } cbm_mutex_unlock(&client->state_mutex); uint8_t cancel_wire[APPLICATION_CANCEL_REQUEST_SIZE]; runtime_put_u64(cancel_wire, request_token); cbm_mutex_lock(&client->send_mutex); - bool sent = cbm_daemon_ipc_send_frame( - connection, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST, wire, - (uint32_t)wire_length); + bool sent = cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST, wire, + (uint32_t)wire_length); bool send_cancel = false; cbm_mutex_lock(&client->state_mutex); client->application_request_sent = sent; - send_cancel = sent && !client->closing && - client->pending_cancel_token == request_token && + send_cancel = sent && !client->closing && client->pending_cancel_token == request_token && !client->application_cancel_sent; if (send_cancel) { client->application_cancel_sent = true; } cbm_mutex_unlock(&client->state_mutex); if (send_cancel) { - sent = cbm_daemon_ipc_send_frame( - connection, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, cancel_wire, - (uint32_t)sizeof(cancel_wire)); + sent = cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, cancel_wire, + (uint32_t)sizeof(cancel_wire)); if (!sent) { cbm_mutex_lock(&client->state_mutex); client->usable = false; @@ -2825,29 +2552,23 @@ cbm_daemon_runtime_client_application_request_tagged( free(wire); cbm_daemon_frame_t frame = {0}; uint8_t *payload = NULL; - int received = sent ? cbm_daemon_ipc_receive_frame( - connection, timeout_ms, &frame, &payload) - : -1; - bool protocol_valid = - sent && received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && - frame.flags == CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST && - frame.length >= APPLICATION_RESPONSE_PREFIX_SIZE && payload; - cbm_daemon_runtime_application_status_t status = - CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + int received = + sent ? cbm_daemon_ipc_receive_frame(connection, timeout_ms, &frame, &payload) : -1; + bool protocol_valid = sent && received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST && + frame.length >= APPLICATION_RESPONSE_PREFIX_SIZE && payload; + cbm_daemon_runtime_application_status_t status = CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; uint32_t response_length = 0; if (protocol_valid) { - cbm_daemon_runtime_application_token_t response_token = - runtime_get_u64(payload); - status = - (cbm_daemon_runtime_application_status_t)runtime_get_u32(payload + 8); + cbm_daemon_runtime_application_token_t response_token = runtime_get_u64(payload); + status = (cbm_daemon_runtime_application_status_t)runtime_get_u32(payload + 8); response_length = runtime_get_u32(payload + 12); - protocol_valid = - response_token == request_token && - status >= CBM_DAEMON_RUNTIME_APPLICATION_OK && - status <= CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && - response_length <= CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX && - frame.length == APPLICATION_RESPONSE_PREFIX_SIZE + response_length && - (status == CBM_DAEMON_RUNTIME_APPLICATION_OK || response_length == 0); + protocol_valid = response_token == request_token && + status >= CBM_DAEMON_RUNTIME_APPLICATION_OK && + status <= CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && + response_length <= CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX && + frame.length == APPLICATION_RESPONSE_PREFIX_SIZE + response_length && + (status == CBM_DAEMON_RUNTIME_APPLICATION_OK || response_length == 0); } uint8_t *response_copy = NULL; @@ -2856,8 +2577,7 @@ cbm_daemon_runtime_client_application_request_tagged( response_copy = malloc(response_length); copied = response_copy != NULL; if (copied) { - memcpy(response_copy, payload + APPLICATION_RESPONSE_PREFIX_SIZE, - response_length); + memcpy(response_copy, payload + APPLICATION_RESPONSE_PREFIX_SIZE, response_length); } } free(payload); @@ -2865,14 +2585,12 @@ cbm_daemon_runtime_client_application_request_tagged( cbm_mutex_lock(&client->state_mutex); client->exchange_active = false; if (client->active_application_token == request_token) { - client->active_application_token = - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + client->active_application_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; client->application_request_sent = false; client->application_cancel_sent = false; } if (client->pending_cancel_token == request_token) { - client->pending_cancel_token = - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; + client->pending_cancel_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; } if (!protocol_valid) { client->usable = false; @@ -2889,11 +2607,9 @@ cbm_daemon_runtime_client_application_request_tagged( return status; } -cbm_daemon_runtime_application_status_t -cbm_daemon_runtime_client_application_request( - cbm_daemon_runtime_client_t *client, const void *request, - uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms) { +cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_request( + cbm_daemon_runtime_client_t *client, const void *request, uint32_t request_length, + uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms) { if (response_out) { *response_out = NULL; } @@ -2908,13 +2624,12 @@ cbm_daemon_runtime_client_application_request( } cbm_daemon_runtime_application_token_t request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; - if (!cbm_daemon_runtime_client_application_token_reserve( - client, &request_token)) { + if (!cbm_daemon_runtime_client_application_token_reserve(client, &request_token)) { return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; } - return cbm_daemon_runtime_client_application_request_tagged( - client, request_token, request, request_length, response_out, - response_length_out, timeout_ms); + return cbm_daemon_runtime_client_application_request_tagged(client, request_token, request, + request_length, response_out, + response_length_out, timeout_ms); } bool cbm_daemon_runtime_client_close_begin(cbm_daemon_runtime_client_t *client) { @@ -2959,21 +2674,18 @@ bool cbm_daemon_runtime_client_close_finish(cbm_daemon_runtime_client_t *client, bool disconnect_sent = false; if (can_disconnect) { cbm_mutex_lock(&client->send_mutex); - disconnect_sent = cbm_daemon_ipc_send_frame( - connection, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_DISCONNECT, NULL, 0); + disconnect_sent = cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_DISCONNECT, NULL, 0); cbm_mutex_unlock(&client->send_mutex); } if (disconnect_sent) { cbm_daemon_frame_t frame = {0}; uint8_t *response = NULL; - int received = cbm_daemon_ipc_receive_frame( - connection, timeout_ms, &frame, &response); - acknowledged = - received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && - frame.flags == CBM_DAEMON_RUNTIME_OP_DISCONNECT && - frame.length == STATUS_RESPONSE_SIZE && response && - runtime_get_u32(response) == 1; + int received = cbm_daemon_ipc_receive_frame(connection, timeout_ms, &frame, &response); + acknowledged = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && + frame.flags == CBM_DAEMON_RUNTIME_OP_DISCONNECT && + frame.length == STATUS_RESPONSE_SIZE && response && + runtime_get_u32(response) == 1; free(response); } @@ -2992,8 +2704,7 @@ bool cbm_daemon_runtime_client_close_finish(cbm_daemon_runtime_client_t *client, return acknowledged; } -bool cbm_daemon_runtime_client_close(cbm_daemon_runtime_client_t *client, - uint32_t timeout_ms) { +bool cbm_daemon_runtime_client_close(cbm_daemon_runtime_client_t *client, uint32_t timeout_ms) { if (!cbm_daemon_runtime_client_close_begin(client)) { return false; } diff --git a/src/daemon/runtime.h b/src/daemon/runtime.h index 838a18a98..793c224b8 100644 --- a/src/daemon/runtime.h +++ b/src/daemon/runtime.h @@ -47,8 +47,7 @@ #define CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE 137U #define CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE 24U #define CBM_DAEMON_RUNTIME_PROJECT_KEY_MAX 4096U -#define CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX \ - (CBM_DAEMON_MAX_FRAME_SIZE - 16U) +#define CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX (CBM_DAEMON_MAX_FRAME_SIZE - 16U) /* Frame flags are operation codes. Every operation has one exact payload * length (or an explicitly length-prefixed payload); trailing bytes are a @@ -112,21 +111,17 @@ typedef enum { typedef void cbm_daemon_runtime_application_session_t; -typedef cbm_daemon_runtime_application_session_t * -(*cbm_daemon_runtime_application_session_open_fn)( - void *context, cbm_daemon_client_id_t client_id, - uint64_t authenticated_process_id); +typedef cbm_daemon_runtime_application_session_t *(*cbm_daemon_runtime_application_session_open_fn)( + void *context, cbm_daemon_client_id_t client_id, uint64_t authenticated_process_id); /* For OK, response_out may receive a malloc-owned binary buffer which the * runtime frees after sending; NULL is valid only for a zero-length response. * Non-OK results must leave an empty response. The request buffer is an owned * runtime copy and remains valid only for the duration of this callback. */ -typedef cbm_daemon_runtime_application_status_t -(*cbm_daemon_runtime_application_request_fn)( +typedef cbm_daemon_runtime_application_status_t (*cbm_daemon_runtime_application_request_fn)( void *context, cbm_daemon_runtime_application_session_t *session, - cbm_daemon_runtime_application_token_t request_token, - const uint8_t *request, uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out); + cbm_daemon_runtime_application_token_t request_token, const uint8_t *request, + uint32_t request_length, uint8_t **response_out, uint32_t *response_length_out); /* Request cancellation is non-terminal and may arrive before request() enters. * The exact token must therefore remain sticky until that request observes it. @@ -199,9 +194,8 @@ typedef struct { * endpoint fields. Strings are NUL-terminated and zero padded. Encoding and * validation depend only on these stable fields; detailed identity fields may * be zero or unknown without changing the bytes. */ -bool cbm_daemon_runtime_hello_request_encode( - uint8_t out[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE], - const cbm_daemon_build_identity_t *identity); +bool cbm_daemon_runtime_hello_request_encode(uint8_t out[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE], + const cbm_daemon_build_identity_t *identity); /* Resolve process_id through the OS kernel's process metadata and hash the * executable image rather than reopening an unbound pathname. Linux hashes an @@ -219,8 +213,8 @@ bool cbm_daemon_runtime_hello_request_encode( * byte-identical copies remain compatible while changed copies are rejected. * Stateful HELLO admission requires the proven or computed fingerprint to * match both the claimed and active build fingerprints. */ -bool cbm_daemon_runtime_process_build_fingerprint( - uint64_t process_id, char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]); +bool cbm_daemon_runtime_process_build_fingerprint(uint64_t process_id, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]); /* Ask any current daemon generation to drain before install/update/uninstall. * This is not a normal HELLO and never creates an application session. The @@ -229,8 +223,7 @@ bool cbm_daemon_runtime_process_build_fingerprint( * return means a fixed response was received; inspect result_out->accepted. * timeout_ms must be finite. */ bool cbm_daemon_runtime_request_activation_shutdown( - const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_build_identity_t *identity, + const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_build_identity_t *identity, cbm_daemon_runtime_activation_action_t action, uint32_t timeout_ms, cbm_daemon_runtime_activation_result_t *result_out); @@ -252,23 +245,22 @@ cbm_daemon_runtime_service_t *cbm_daemon_runtime_service_start_reserved( const cbm_daemon_runtime_service_config_t *config, cbm_daemon_ipc_lifetime_reservation_t **reservation_io); -cbm_daemon_runtime_service_state_t -cbm_daemon_runtime_service_state(cbm_daemon_runtime_service_t *service); +cbm_daemon_runtime_service_state_t cbm_daemon_runtime_service_state( + cbm_daemon_runtime_service_t *service); size_t cbm_daemon_runtime_service_active_clients(cbm_daemon_runtime_service_t *service); /* Includes accepted connections still waiting for HELLO. Never exceeds the * configured max_clients; over-cap peers receive REJECTED before close. */ -size_t cbm_daemon_runtime_service_active_connections( - cbm_daemon_runtime_service_t *service); +size_t cbm_daemon_runtime_service_active_connections(cbm_daemon_runtime_service_t *service); size_t cbm_daemon_runtime_service_job_subscribers(cbm_daemon_runtime_service_t *service, const char *project_key); -uint64_t cbm_daemon_runtime_service_client_process_id( - cbm_daemon_runtime_service_t *service, cbm_daemon_client_id_t client_id); +uint64_t cbm_daemon_runtime_service_client_process_id(cbm_daemon_runtime_service_t *service, + cbm_daemon_client_id_t client_id); /* Wait functions use a monotonic deadline and never sleep past timeout_ms. */ bool cbm_daemon_runtime_service_wait_for_clients(cbm_daemon_runtime_service_t *service, size_t expected, uint32_t timeout_ms); -bool cbm_daemon_runtime_service_wait_for_connections( - cbm_daemon_runtime_service_t *service, size_t expected, uint32_t timeout_ms); +bool cbm_daemon_runtime_service_wait_for_connections(cbm_daemon_runtime_service_t *service, + size_t expected, uint32_t timeout_ms); bool cbm_daemon_runtime_service_wait_exited(cbm_daemon_runtime_service_t *service, uint32_t timeout_ms); @@ -280,8 +272,7 @@ bool cbm_daemon_runtime_service_job_reaped(cbm_daemon_runtime_service_t *service /* Emergency/test teardown only. Normal lifetime is connection-owned: the * final disconnect makes STOPPING terminal, drains/reaps within the configured * bound, and exits automatically. stop is itself bounded by timeout_ms. */ -bool cbm_daemon_runtime_service_stop(cbm_daemon_runtime_service_t *service, - uint32_t timeout_ms); +bool cbm_daemon_runtime_service_stop(cbm_daemon_runtime_service_t *service, uint32_t timeout_ms); /* Before wait_exited or a successful stop this returns false without teardown. * Otherwise it returns true only after every thread is joined, the owned * participant guard is released, and the service allocation is destroyed. A @@ -293,57 +284,46 @@ bool cbm_daemon_runtime_service_free(cbm_daemon_runtime_service_t *service); * mismatch result (and persists it) before closing a rejected connection. * Client exchange timeouts must be finite, not CBM_DAEMON_IPC_WAIT_FOREVER. */ cbm_daemon_runtime_client_t *cbm_daemon_runtime_client_connect( - const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_build_identity_t *identity, uint32_t timeout_ms, - cbm_daemon_runtime_connect_result_t *result_out); + const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_build_identity_t *identity, + uint32_t timeout_ms, cbm_daemon_runtime_connect_result_t *result_out); -cbm_daemon_client_id_t -cbm_daemon_runtime_client_id(const cbm_daemon_runtime_client_t *client); -uint64_t cbm_daemon_runtime_client_process_id( - const cbm_daemon_runtime_client_t *client); +cbm_daemon_client_id_t cbm_daemon_runtime_client_id(const cbm_daemon_runtime_client_t *client); +uint64_t cbm_daemon_runtime_client_process_id(const cbm_daemon_runtime_client_t *client); cbm_daemon_subscription_result_t cbm_daemon_runtime_client_job_subscribe( cbm_daemon_runtime_client_t *client, const char *project_key, cbm_daemon_subscription_id_t *subscription_id_out, uint32_t timeout_ms); -bool cbm_daemon_runtime_client_job_unsubscribe( - cbm_daemon_runtime_client_t *client, - cbm_daemon_subscription_id_t subscription_id, uint32_t timeout_ms); -bool cbm_daemon_runtime_client_heartbeat(cbm_daemon_runtime_client_t *client, - uint32_t timeout_ms); +bool cbm_daemon_runtime_client_job_unsubscribe(cbm_daemon_runtime_client_t *client, + cbm_daemon_subscription_id_t subscription_id, + uint32_t timeout_ms); +bool cbm_daemon_runtime_client_heartbeat(cbm_daemon_runtime_client_t *client, uint32_t timeout_ms); /* Reserve a monotonically increasing token for the next application request. * Only one unstarted reservation may exist per client. It must be consumed by * request_tagged() (or the client must be closed) before another reservation. * Tokens are never reused within a client connection. */ bool cbm_daemon_runtime_client_application_token_reserve( - cbm_daemon_runtime_client_t *client, - cbm_daemon_runtime_application_token_t *token_out); + cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t *token_out); /* Send a one-way cancellation control for an exact reserved/active request. * ACCEPTED means the control was queued or written; callback completion is the * final cancellation linearization point. Stale/wrong tokens are harmless. */ -cbm_daemon_runtime_cancel_result_t -cbm_daemon_runtime_client_application_cancel( - cbm_daemon_runtime_client_t *client, - cbm_daemon_runtime_application_token_t request_token); +cbm_daemon_runtime_cancel_result_t cbm_daemon_runtime_client_application_cancel( + cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token); /* Executes one binary application request. Calls on one client are serialized. * response_out is malloc-owned on OK and must be freed by the caller; an empty * OK response is represented by NULL/0. Invalid/oversized requests are rejected * locally without poisoning an otherwise usable client. */ -cbm_daemon_runtime_application_status_t -cbm_daemon_runtime_client_application_request( - cbm_daemon_runtime_client_t *client, const void *request, - uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out, uint32_t timeout_ms); +cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_request( + cbm_daemon_runtime_client_t *client, const void *request, uint32_t request_length, + uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms); /* Execute using the sole outstanding token returned by token_reserve(). This * is the cancellable frontend path; the legacy helper above reserves * internally. */ -cbm_daemon_runtime_application_status_t -cbm_daemon_runtime_client_application_request_tagged( - cbm_daemon_runtime_client_t *client, - cbm_daemon_runtime_application_token_t request_token, +cbm_daemon_runtime_application_status_t cbm_daemon_runtime_client_application_request_tagged( + cbm_daemon_runtime_client_t *client, cbm_daemon_runtime_application_token_t request_token, const void *request, uint32_t request_length, uint8_t **response_out, uint32_t *response_length_out, uint32_t timeout_ms); @@ -364,7 +344,6 @@ bool cbm_daemon_runtime_client_close_finish(cbm_daemon_runtime_client_t *client, * safe with one exchange that was already in flight, but callers with a worker * that may be immediately about to enter an exchange must use the two-phase API * and join that worker before close_finish. */ -bool cbm_daemon_runtime_client_close(cbm_daemon_runtime_client_t *client, - uint32_t timeout_ms); +bool cbm_daemon_runtime_client_close(cbm_daemon_runtime_client_t *client, uint32_t timeout_ms); #endif /* CBM_DAEMON_RUNTIME_H */ diff --git a/src/daemon/service.c b/src/daemon/service.c index 6219c38f4..284922641 100644 --- a/src/daemon/service.c +++ b/src/daemon/service.c @@ -20,16 +20,15 @@ enum { DAEMON_SERVICE_PATH_CAP = 4096, DAEMON_SERVICE_IO_CAP = 64 * 1024, - DAEMON_SERVICE_ESCAPED_VERSION_CAP = - (CBM_DAEMON_VERSION_TEXT_SIZE - 1) * 6 + 1, + DAEMON_SERVICE_ESCAPED_VERSION_CAP = (CBM_DAEMON_VERSION_TEXT_SIZE - 1) * 6 + 1, DAEMON_SERVICE_LOG_RECORD_CAP = 1536, }; static cbm_daemon_conflict_log_test_hook_fn g_conflict_log_test_hook; static void *g_conflict_log_test_context; -void cbm_daemon_conflict_log_set_test_hook( - cbm_daemon_conflict_log_test_hook_fn hook, void *context) { +void cbm_daemon_conflict_log_set_test_hook(cbm_daemon_conflict_log_test_hook_fn hook, + void *context) { g_conflict_log_test_context = context; g_conflict_log_test_hook = hook; } @@ -87,15 +86,19 @@ static bool fingerprint_valid(const char *fingerprint) { return true; } +static bool optional_fingerprint_valid(const char *fingerprint) { + return !fingerprint || !fingerprint[0] || fingerprint_valid(fingerprint); +} + static bool identity_valid(const cbm_daemon_build_identity_t *identity) { return identity && version_valid(identity->semantic_version) && - fingerprint_valid(identity->build_fingerprint) && identity->protocol_abi != 0 && + fingerprint_valid(identity->build_fingerprint) && + optional_fingerprint_valid(identity->cache_fingerprint) && identity->protocol_abi != 0 && identity->store_abi != 0 && identity->feature_abi != 0; } static bool conflict_status_valid(cbm_daemon_hello_status_t status) { - return status >= CBM_DAEMON_HELLO_VERSION_CONFLICT && - status <= CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT; + return status >= CBM_DAEMON_HELLO_VERSION_CONFLICT && status <= CBM_DAEMON_HELLO_CACHE_CONFLICT; } static bool conflict_valid(const cbm_daemon_conflict_t *conflict) { @@ -103,23 +106,28 @@ static bool conflict_valid(const cbm_daemon_conflict_t *conflict) { version_valid(conflict->active_version) && fingerprint_valid(conflict->active_build_fingerprint) && version_valid(conflict->requested_version) && - fingerprint_valid(conflict->requested_build_fingerprint); + fingerprint_valid(conflict->requested_build_fingerprint) && + (conflict->status != CBM_DAEMON_HELLO_CACHE_CONFLICT || + (fingerprint_valid(conflict->active_cache_fingerprint) && + fingerprint_valid(conflict->requested_cache_fingerprint))); } static const char *conflict_reason(cbm_daemon_hello_status_t status) { switch (status) { - case CBM_DAEMON_HELLO_VERSION_CONFLICT: - return "version"; - case CBM_DAEMON_HELLO_BUILD_CONFLICT: - return "build"; - case CBM_DAEMON_HELLO_PROTOCOL_ABI_CONFLICT: - return "protocol_abi"; - case CBM_DAEMON_HELLO_STORE_ABI_CONFLICT: - return "store_abi"; - case CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT: - return "feature_abi"; - default: - return NULL; + case CBM_DAEMON_HELLO_VERSION_CONFLICT: + return "version"; + case CBM_DAEMON_HELLO_BUILD_CONFLICT: + return "build"; + case CBM_DAEMON_HELLO_PROTOCOL_ABI_CONFLICT: + return "protocol_abi"; + case CBM_DAEMON_HELLO_STORE_ABI_CONFLICT: + return "store_abi"; + case CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT: + return "feature_abi"; + case CBM_DAEMON_HELLO_CACHE_CONFLICT: + return "cache_root"; + default: + return NULL; } } @@ -153,10 +161,9 @@ bool cbm_daemon_rendezvous_key(char out[CBM_DAEMON_KEY_SIZE]) { return true; } -cbm_daemon_hello_status_t -cbm_daemon_hello_compare(const cbm_daemon_build_identity_t *active, - const cbm_daemon_build_identity_t *requested, - cbm_daemon_conflict_t *conflict_out) { +cbm_daemon_hello_status_t cbm_daemon_hello_compare(const cbm_daemon_build_identity_t *active, + const cbm_daemon_build_identity_t *requested, + cbm_daemon_conflict_t *conflict_out) { if (conflict_out) { memset(conflict_out, 0, sizeof(*conflict_out)); conflict_out->status = CBM_DAEMON_HELLO_INVALID; @@ -168,13 +175,22 @@ cbm_daemon_hello_compare(const cbm_daemon_build_identity_t *active, (void)snprintf(conflict_out->active_version, sizeof(conflict_out->active_version), "%s", active->semantic_version); (void)snprintf(conflict_out->active_build_fingerprint, - sizeof(conflict_out->active_build_fingerprint), "%s", - active->build_fingerprint); + sizeof(conflict_out->active_build_fingerprint), "%s", active->build_fingerprint); (void)snprintf(conflict_out->requested_version, sizeof(conflict_out->requested_version), "%s", requested->semantic_version); (void)snprintf(conflict_out->requested_build_fingerprint, sizeof(conflict_out->requested_build_fingerprint), "%s", requested->build_fingerprint); + if (active->cache_fingerprint) { + (void)snprintf(conflict_out->active_cache_fingerprint, + sizeof(conflict_out->active_cache_fingerprint), "%s", + active->cache_fingerprint); + } + if (requested->cache_fingerprint) { + (void)snprintf(conflict_out->requested_cache_fingerprint, + sizeof(conflict_out->requested_cache_fingerprint), "%s", + requested->cache_fingerprint); + } cbm_daemon_hello_status_t status = CBM_DAEMON_HELLO_COMPATIBLE; if (strcmp(active->semantic_version, requested->semantic_version) != 0) { @@ -192,8 +208,7 @@ cbm_daemon_hello_compare(const cbm_daemon_build_identity_t *active, return status; } -bool cbm_daemon_conflict_format(const cbm_daemon_conflict_t *conflict, char *out, - size_t out_size) { +bool cbm_daemon_conflict_format(const cbm_daemon_conflict_t *conflict, char *out, size_t out_size) { if (!out || out_size == 0) { return false; } @@ -202,13 +217,23 @@ bool cbm_daemon_conflict_format(const cbm_daemon_conflict_t *conflict, char *out if (!reason || !conflict_valid(conflict)) { return false; } - int written = snprintf( - out, out_size, - "CBM could not start because a conflicting CBM process is active " - "(%s; active version %s, build %s; requested version %s, build %s). " - "Close all CBM sessions and commands, then retry.", - reason, conflict->active_version, conflict->active_build_fingerprint, - conflict->requested_version, conflict->requested_build_fingerprint); + int written; + if (conflict->status == CBM_DAEMON_HELLO_CACHE_CONFLICT) { + written = + snprintf(out, out_size, + "CBM could not start because the active account daemon uses a " + "different cache directory (active cache %s; requested cache %s). " + "Close all CBM sessions and commands, then retry with one " + "consistent CBM_CACHE_DIR.", + conflict->active_cache_fingerprint, conflict->requested_cache_fingerprint); + } else { + written = snprintf(out, out_size, + "CBM could not start because a conflicting CBM process is active " + "(%s; active version %s, build %s; requested version %s, build %s). " + "Close all CBM sessions and commands, then retry.", + reason, conflict->active_version, conflict->active_build_fingerprint, + conflict->requested_version, conflict->requested_build_fingerprint); + } if (written < 0 || (size_t)written >= out_size) { out[0] = '\0'; return false; @@ -216,12 +241,10 @@ bool cbm_daemon_conflict_format(const cbm_daemon_conflict_t *conflict, char *out return true; } -static bool json_escape_version(const char *value, - char out[DAEMON_SERVICE_ESCAPED_VERSION_CAP]) { +static bool json_escape_version(const char *value, char out[DAEMON_SERVICE_ESCAPED_VERSION_CAP]) { static const char hex[] = "0123456789abcdef"; size_t length = 0; - if (!version_valid(value) || - !bounded_length(value, CBM_DAEMON_VERSION_TEXT_SIZE, &length)) { + if (!version_valid(value) || !bounded_length(value, CBM_DAEMON_VERSION_TEXT_SIZE, &length)) { return false; } size_t used = 0; @@ -268,14 +291,27 @@ static bool conflict_log_record(const cbm_daemon_conflict_t *conflict, if (timestamp == (time_t)-1) { return false; } - int written = snprintf( - out, DAEMON_SERVICE_LOG_RECORD_CAP, - "{\"event\":\"daemon.version_conflict\",\"timestamp_unix_s\":%lld," - "\"reason\":\"%s\"," - "\"active_version\":\"%s\",\"active_build\":\"%s\"," - "\"requested_version\":\"%s\",\"requested_build\":\"%s\"}\n", - (long long)timestamp, reason, active, conflict->active_build_fingerprint, requested, - conflict->requested_build_fingerprint); + int written; + if (conflict->status == CBM_DAEMON_HELLO_CACHE_CONFLICT) { + written = snprintf(out, DAEMON_SERVICE_LOG_RECORD_CAP, + "{\"event\":\"daemon.version_conflict\",\"timestamp_unix_s\":%lld," + "\"reason\":\"%s\",\"active_cache\":\"%s\"," + "\"requested_cache\":\"%s\",\"active_version\":\"%s\"," + "\"active_build\":\"%s\",\"requested_version\":\"%s\"," + "\"requested_build\":\"%s\"}\n", + (long long)timestamp, reason, conflict->active_cache_fingerprint, + conflict->requested_cache_fingerprint, active, + conflict->active_build_fingerprint, requested, + conflict->requested_build_fingerprint); + } else { + written = snprintf(out, DAEMON_SERVICE_LOG_RECORD_CAP, + "{\"event\":\"daemon.version_conflict\",\"timestamp_unix_s\":%lld," + "\"reason\":\"%s\"," + "\"active_version\":\"%s\",\"active_build\":\"%s\"," + "\"requested_version\":\"%s\",\"requested_build\":\"%s\"}\n", + (long long)timestamp, reason, active, conflict->active_build_fingerprint, + requested, conflict->requested_build_fingerprint); + } if (written < 0 || written >= DAEMON_SERVICE_LOG_RECORD_CAP) { return false; } @@ -319,9 +355,8 @@ static bool fd_regular(int fd, struct stat *status_out) { return true; } -bool cbm_daemon_build_fingerprint_native_file( - uintptr_t native_file, - char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { +bool cbm_daemon_build_fingerprint_native_file(uintptr_t native_file, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { if (!out) { return false; } @@ -342,9 +377,7 @@ bool cbm_daemon_build_fingerprint_native_file( bool ok = true; while (offset < before.st_size) { off_t remaining = before.st_size - offset; - size_t request = remaining < (off_t)sizeof(buffer) - ? (size_t)remaining - : sizeof(buffer); + size_t request = remaining < (off_t)sizeof(buffer) ? (size_t)remaining : sizeof(buffer); ssize_t count = pread(fd, buffer, request, offset); if (count > 0) { cbm_sha256_update(&context, buffer, (size_t)count); @@ -370,8 +403,8 @@ bool cbm_daemon_build_fingerprint_native_file( return true; } -bool cbm_daemon_build_fingerprint_file( - const char *path, char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { +bool cbm_daemon_build_fingerprint_file(const char *path, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { if (!out) { return false; } @@ -381,8 +414,8 @@ bool cbm_daemon_build_fingerprint_file( return false; } int fd = open(path, O_RDONLY | O_CLOEXEC | O_NOFOLLOW | O_NONBLOCK); - bool ok = fd >= 0 && fd_cloexec(fd) && - cbm_daemon_build_fingerprint_native_file((uintptr_t)fd, out); + bool ok = + fd >= 0 && fd_cloexec(fd) && cbm_daemon_build_fingerprint_native_file((uintptr_t)fd, out); if (fd >= 0 && close(fd) != 0) { ok = false; } @@ -441,9 +474,9 @@ static bool posix_log_path_open(const char *log_path, posix_log_path_t *path) { } path->dir_fd = open(parent, O_RDONLY | O_DIRECTORY | O_CLOEXEC | O_NOFOLLOW); struct stat parent_status; - if (path->dir_fd < 0 || !fd_cloexec(path->dir_fd) || - fstat(path->dir_fd, &parent_status) != 0 || !S_ISDIR(parent_status.st_mode) || - parent_status.st_uid != geteuid() || (parent_status.st_mode & 0022) != 0) { + if (path->dir_fd < 0 || !fd_cloexec(path->dir_fd) || fstat(path->dir_fd, &parent_status) != 0 || + !S_ISDIR(parent_status.st_mode) || parent_status.st_uid != geteuid() || + (parent_status.st_mode & 0022) != 0) { posix_log_path_close(path); return false; } @@ -462,9 +495,8 @@ static int posix_lock_file_open(const posix_log_path_t *path, bool *created_out) if (fd < 0 || !fd_cloexec(fd) || !fd_regular_current_user(fd, &status) || fchmod(fd, 0600) != 0 || fstatat(path->dir_fd, path->lock, &by_path, AT_SYMLINK_NOFOLLOW) != 0 || - !S_ISREG(by_path.st_mode) || by_path.st_uid != geteuid() || - by_path.st_nlink != 1 || status.st_dev != by_path.st_dev || - status.st_ino != by_path.st_ino) { + !S_ISREG(by_path.st_mode) || by_path.st_uid != geteuid() || by_path.st_nlink != 1 || + status.st_dev != by_path.st_dev || status.st_ino != by_path.st_ino) { if (fd >= 0) { (void)close(fd); } @@ -635,15 +667,13 @@ static bool posix_log_append(const char *log_path, const char *record, size_t re static bool windows_same_file_identity(const BY_HANDLE_FILE_INFORMATION *first, const BY_HANDLE_FILE_INFORMATION *second) { - return first && second && - first->dwVolumeSerialNumber == second->dwVolumeSerialNumber && + return first && second && first->dwVolumeSerialNumber == second->dwVolumeSerialNumber && first->nFileIndexHigh == second->nFileIndexHigh && first->nFileIndexLow == second->nFileIndexLow; } -bool cbm_daemon_build_fingerprint_native_file( - uintptr_t native_file, - char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { +bool cbm_daemon_build_fingerprint_native_file(uintptr_t native_file, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { if (!out) { return false; } @@ -653,21 +683,20 @@ bool cbm_daemon_build_fingerprint_native_file( return false; } BY_HANDLE_FILE_INFORMATION original_info; - HANDLE file = ReOpenFile(original, GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_DELETE, + HANDLE file = ReOpenFile(original, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, FILE_FLAG_SEQUENTIAL_SCAN); BY_HANDLE_FILE_INFORMATION info; LARGE_INTEGER before; - bool ok = GetFileType(original) == FILE_TYPE_DISK && - GetFileInformationByHandle(original, &original_info) != 0 && - (original_info.dwFileAttributes & - (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && - file != INVALID_HANDLE_VALUE && GetFileType(file) == FILE_TYPE_DISK && - GetFileInformationByHandle(file, &info) != 0 && - (info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == - 0 && - windows_same_file_identity(&original_info, &info) && - GetFileSizeEx(file, &before) != 0 && before.QuadPart >= 0; + bool ok = + GetFileType(original) == FILE_TYPE_DISK && + GetFileInformationByHandle(original, &original_info) != 0 && + (original_info.dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + file != INVALID_HANDLE_VALUE && GetFileType(file) == FILE_TYPE_DISK && + GetFileInformationByHandle(file, &info) != 0 && + (info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + windows_same_file_identity(&original_info, &info) && GetFileSizeEx(file, &before) != 0 && + before.QuadPart >= 0; cbm_sha256_ctx context; cbm_sha256_init(&context); unsigned char buffer[DAEMON_SERVICE_IO_CAP]; @@ -687,8 +716,8 @@ bool cbm_daemon_build_fingerprint_native_file( BY_HANDLE_FILE_INFORMATION after_info; ok = ok && GetFileSizeEx(file, &after) != 0 && GetFileInformationByHandle(file, &after_info) != 0 && - windows_same_file_identity(&info, &after_info) && - before.QuadPart == after.QuadPart && total == (uint64_t)after.QuadPart; + windows_same_file_identity(&info, &after_info) && before.QuadPart == after.QuadPart && + total == (uint64_t)after.QuadPart; if (file != INVALID_HANDLE_VALUE) { (void)CloseHandle(file); } @@ -701,8 +730,8 @@ bool cbm_daemon_build_fingerprint_native_file( return true; } -bool cbm_daemon_build_fingerprint_file( - const char *path, char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { +bool cbm_daemon_build_fingerprint_file(const char *path, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { if (!out) { return false; } @@ -715,9 +744,9 @@ bool cbm_daemon_build_fingerprint_file( if (!wide) { return false; } - HANDLE file = CreateFileW(wide, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, NULL, - OPEN_EXISTING, - FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, NULL); + HANDLE file = + CreateFileW(wide, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, NULL); free(wide); bool ok = file != INVALID_HANDLE_VALUE && cbm_daemon_build_fingerprint_native_file((uintptr_t)file, out); @@ -735,17 +764,17 @@ static bool windows_log_handle_valid(HANDLE file, LARGE_INTEGER *size_out) { return file != INVALID_HANDLE_VALUE && GetFileType(file) == FILE_TYPE_DISK && GetFileInformationByHandle(file, &info) != 0 && (info.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == - 0 && info.nNumberOfLinks == 1 && - GetFileSizeEx(file, size_out) != 0 && size_out->QuadPart >= 0; + 0 && + info.nNumberOfLinks == 1 && GetFileSizeEx(file, size_out) != 0 && + size_out->QuadPart >= 0; } static HANDLE windows_log_open(const wchar_t *path, LARGE_INTEGER *size_out) { /* windows_private_file_prepare creates or validates the file with IPC's * explicit protected current-user DACL before this handle is opened. */ - HANDLE file = CreateFileW(path, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, NULL, - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, - NULL); + HANDLE file = + CreateFileW(path, GENERIC_READ | GENERIC_WRITE, FILE_SHARE_READ, NULL, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); if (!windows_log_handle_valid(file, size_out)) { if (file != INVALID_HANDLE_VALUE) { (void)CloseHandle(file); @@ -794,8 +823,7 @@ static bool windows_log_path_open(const char *log_path, windows_log_path_t *path path->base = slash + 1; *slash = '\0'; size_t base_length = strlen(path->base); - int lock_written = snprintf(path->lock_base, sizeof(path->lock_base), "%s.lock", - path->base); + int lock_written = snprintf(path->lock_base, sizeof(path->lock_base), "%s.lock", path->base); if (base_length == 0 || base_length > 248 || lock_written <= 0 || (size_t)lock_written >= sizeof(path->lock_base)) { return false; @@ -840,8 +868,7 @@ static HANDLE windows_lock_open(const wchar_t *path) { /* Read access is sufficient for LockFileEx. Keeping this handle * read-only makes it share-compatible with the brief DACL repair * handle, whose FILE_SHARE_READ still admits every live locker. */ - path, GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, + path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL); if (file != INVALID_HANDLE_VALUE) { LARGE_INTEGER size; @@ -899,15 +926,13 @@ static bool windows_log_append(const char *log_path, const char *record, size_t conflict_log_test_hook(CBM_DAEMON_CONFLICT_LOG_BEFORE_SERIALIZATION_LOCK); } OVERLAPPED lock_range; - bool lock_acquired = lock != INVALID_HANDLE_VALUE && - windows_lock_exclusive(lock, &lock_range); + bool lock_acquired = lock != INVALID_HANDLE_VALUE && windows_lock_exclusive(lock, &lock_range); if (lock_acquired) { conflict_log_test_hook(CBM_DAEMON_CONFLICT_LOG_AFTER_SERIALIZATION_LOCK); } LARGE_INTEGER size; - bool ok = lock_acquired && - windows_private_file_prepare(path.storage, path.base); + bool ok = lock_acquired && windows_private_file_prepare(path.storage, path.base); HANDLE file = ok ? windows_log_open(path.wide, &size) : INVALID_HANDLE_VALUE; ok = ok && file != INVALID_HANDLE_VALUE; bool rotate = false; @@ -962,8 +987,7 @@ static bool windows_log_append(const char *log_path, const char *record, size_t #endif /* _WIN32 */ -bool cbm_daemon_conflict_log_append(const char *log_path, - const cbm_daemon_conflict_t *conflict, +bool cbm_daemon_conflict_log_append(const char *log_path, const cbm_daemon_conflict_t *conflict, size_t cap_bytes) { char record[DAEMON_SERVICE_LOG_RECORD_CAP]; size_t record_length = 0; diff --git a/src/daemon/service.h b/src/daemon/service.h index cd77826b5..edb7d8277 100644 --- a/src/daemon/service.h +++ b/src/daemon/service.h @@ -22,6 +22,11 @@ typedef struct { const char *semantic_version; const char *build_fingerprint; + /* SHA-256 of the canonical cache-root path. It is intentionally excluded + * from the stable HELLO envelope, but the account-wide lifetime cohort + * compares it before any daemon/CLI work can begin. NULL means an internal + * test/legacy identity with no cache namespace. */ + const char *cache_fingerprint; uint32_t protocol_abi; uint32_t store_abi; uint32_t feature_abi; @@ -35,6 +40,7 @@ typedef enum { CBM_DAEMON_HELLO_PROTOCOL_ABI_CONFLICT, CBM_DAEMON_HELLO_STORE_ABI_CONFLICT, CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT, + CBM_DAEMON_HELLO_CACHE_CONFLICT, } cbm_daemon_hello_status_t; typedef struct { @@ -43,6 +49,8 @@ typedef struct { char active_build_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; char requested_version[CBM_DAEMON_VERSION_TEXT_SIZE]; char requested_build_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + char active_cache_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + char requested_cache_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; } cbm_daemon_conflict_t; /* Stable product key. OS-account isolation is supplied by the IPC runtime @@ -51,23 +59,20 @@ bool cbm_daemon_rendezvous_key(char out[CBM_DAEMON_KEY_SIZE]); /* SHA-256 of the exact executable bytes, encoded as 64 lowercase hex * characters plus NUL. This is captured once at process startup. */ -bool cbm_daemon_build_fingerprint_file( - const char *path, char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]); +bool cbm_daemon_build_fingerprint_file(const char *path, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]); -cbm_daemon_hello_status_t -cbm_daemon_hello_compare(const cbm_daemon_build_identity_t *active, - const cbm_daemon_build_identity_t *requested, - cbm_daemon_conflict_t *conflict_out); +cbm_daemon_hello_status_t cbm_daemon_hello_compare(const cbm_daemon_build_identity_t *active, + const cbm_daemon_build_identity_t *requested, + cbm_daemon_conflict_t *conflict_out); -bool cbm_daemon_conflict_format(const cbm_daemon_conflict_t *conflict, char *out, - size_t out_size); +bool cbm_daemon_conflict_format(const cbm_daemon_conflict_t *conflict, char *out, size_t out_size); /* Append one secret-free NDJSON conflict event. A persistent owner-only * .lock serializes validation, rotation, and append across daemon * processes; cap_bytes rotates one complete prior generation to .1 * before appending a record that would cross the cap. */ -bool cbm_daemon_conflict_log_append(const char *log_path, - const cbm_daemon_conflict_t *conflict, +bool cbm_daemon_conflict_log_append(const char *log_path, const cbm_daemon_conflict_t *conflict, size_t cap_bytes); #endif /* CBM_DAEMON_SERVICE_H */ diff --git a/src/daemon/service_internal.h b/src/daemon/service_internal.h index 05f9edfd1..750b72f7c 100644 --- a/src/daemon/service_internal.h +++ b/src/daemon/service_internal.h @@ -18,19 +18,18 @@ * file descriptor on POSIX and a HANDLE cast through uintptr_t on Windows. * Callers use this after binding the handle to kernel process-image metadata, * avoiding a second lookup through a replaceable pathname. */ -bool cbm_daemon_build_fingerprint_native_file( - uintptr_t native_file, - char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]); +bool cbm_daemon_build_fingerprint_native_file(uintptr_t native_file, + char out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]); typedef enum { CBM_DAEMON_CONFLICT_LOG_BEFORE_SERIALIZATION_LOCK = 1, CBM_DAEMON_CONFLICT_LOG_AFTER_SERIALIZATION_LOCK, } cbm_daemon_conflict_log_test_stage_t; -typedef void (*cbm_daemon_conflict_log_test_hook_fn)( - void *context, cbm_daemon_conflict_log_test_stage_t stage); +typedef void (*cbm_daemon_conflict_log_test_hook_fn)(void *context, + cbm_daemon_conflict_log_test_stage_t stage); -void cbm_daemon_conflict_log_set_test_hook( - cbm_daemon_conflict_log_test_hook_fn hook, void *context); +void cbm_daemon_conflict_log_set_test_hook(cbm_daemon_conflict_log_test_hook_fn hook, + void *context); #endif /* CBM_DAEMON_SERVICE_INTERNAL_H */ diff --git a/src/daemon/version_cohort.c b/src/daemon/version_cohort.c index 4c2d987fe..c120cdf41 100644 --- a/src/daemon/version_cohort.c +++ b/src/daemon/version_cohort.c @@ -29,8 +29,10 @@ enum { VERSION_COHORT_RECORD_VERSION_OFFSET = VERSION_COHORT_RECORD_MAGIC_SIZE, VERSION_COHORT_RECORD_BUILD_OFFSET = VERSION_COHORT_RECORD_VERSION_OFFSET + CBM_DAEMON_VERSION_TEXT_SIZE, - VERSION_COHORT_RECORD_PROTOCOL_OFFSET = + VERSION_COHORT_RECORD_CACHE_OFFSET = VERSION_COHORT_RECORD_BUILD_OFFSET + CBM_DAEMON_BUILD_FINGERPRINT_SIZE, + VERSION_COHORT_RECORD_PROTOCOL_OFFSET = + VERSION_COHORT_RECORD_CACHE_OFFSET + CBM_DAEMON_BUILD_FINGERPRINT_SIZE, VERSION_COHORT_RECORD_STORE_OFFSET = VERSION_COHORT_RECORD_PROTOCOL_OFFSET + 4, VERSION_COHORT_RECORD_FEATURE_OFFSET = VERSION_COHORT_RECORD_STORE_OFFSET + 4, VERSION_COHORT_RECORD_SIZE = VERSION_COHORT_RECORD_FEATURE_OFFSET + 4, @@ -39,16 +41,12 @@ enum { }; static const unsigned char VERSION_COHORT_RECORD_MAGIC[VERSION_COHORT_RECORD_MAGIC_SIZE] = { - 'C', 'B', 'M', 'C', 'O', 'H', 1, 0, + 'C', 'B', 'M', 'C', 'O', 'H', 2, 0, }; -static const char VERSION_COHORT_ADMISSION_FILE[] = - "cbm-version-cohort-admission-v1.lock"; -static const char VERSION_COHORT_LIFETIME_FILE[] = - "cbm-version-cohort-lifetime-v1.lock"; -static const char VERSION_COHORT_MAINTENANCE_FILE[] = - "cbm-version-cohort-maintenance-v1.lock"; -static const char VERSION_COHORT_DAEMON_FILE[] = - "cbm-version-cohort-daemon-v1.lock"; +static const char VERSION_COHORT_ADMISSION_FILE[] = "cbm-version-cohort-admission-v1.lock"; +static const char VERSION_COHORT_LIFETIME_FILE[] = "cbm-version-cohort-lifetime-v1.lock"; +static const char VERSION_COHORT_MAINTENANCE_FILE[] = "cbm-version-cohort-maintenance-v1.lock"; +static const char VERSION_COHORT_DAEMON_FILE[] = "cbm-version-cohort-daemon-v1.lock"; struct cbm_version_cohort_manager { cbm_private_lock_directory_t *directory; @@ -92,8 +90,7 @@ static cbm_private_file_lock_status_t version_cohort_lock_release_until( return CBM_PRIVATE_FILE_LOCK_IO; } while (*lock_io) { - cbm_private_file_lock_status_t status = - cbm_private_file_lock_release(lock_io); + cbm_private_file_lock_status_t status = cbm_private_file_lock_release(lock_io); if (status != CBM_PRIVATE_FILE_LOCK_OK) { result = status; } @@ -101,22 +98,19 @@ static cbm_private_file_lock_status_t version_cohort_lock_release_until( break; } if (cbm_now_ms() >= deadline_ms) { - return result == CBM_PRIVATE_FILE_LOCK_OK - ? CBM_PRIVATE_FILE_LOCK_IO - : result; + return result == CBM_PRIVATE_FILE_LOCK_OK ? CBM_PRIVATE_FILE_LOCK_IO : result; } cbm_usleep(VERSION_COHORT_RETRY_US); } return result; } -static _Noreturn void version_cohort_cleanup_fail_stop( - const char *component) { +static _Noreturn void version_cohort_cleanup_fail_stop(const char *component) { /* Observer APIs cannot return an opaque cleanup handle. Once their finite * retry budget is exhausted, process exit is the only way to avoid losing * retry authority while a coordination marker remains owned. */ - cbm_log_error("daemon.forced_shutdown", "component", component, - "action", "coordination_cleanup"); + cbm_log_error("daemon.forced_shutdown", "component", component, "action", + "coordination_cleanup"); (void)fflush(stdout); (void)fflush(stderr); #ifdef _WIN32 @@ -128,10 +122,8 @@ static _Noreturn void version_cohort_cleanup_fail_stop( } #ifndef _WIN32 -static void version_cohort_startup_lock_release_complete( - cbm_daemon_ipc_startup_lock_t **lock_io) { - uint64_t deadline = version_cohort_deadline_after( - VERSION_COHORT_CLEANUP_TIMEOUT_MS); +static void version_cohort_startup_lock_release_complete(cbm_daemon_ipc_startup_lock_t **lock_io) { + uint64_t deadline = version_cohort_deadline_after(VERSION_COHORT_CLEANUP_TIMEOUT_MS); while (lock_io && *lock_io) { (void)cbm_daemon_ipc_startup_lock_release(lock_io); if (!*lock_io) { @@ -145,11 +137,25 @@ static void version_cohort_startup_lock_release_complete( } #endif -static bool version_cohort_identity_valid( - const cbm_daemon_build_identity_t *identity) { +static bool version_cohort_identity_valid(const cbm_daemon_build_identity_t *identity) { + const char *cache = identity ? identity->cache_fingerprint : NULL; + if (!cache) { + return false; + } + size_t cache_length = 0; + while (cache_length < CBM_DAEMON_BUILD_FINGERPRINT_SIZE && cache[cache_length]) { + unsigned char ch = (unsigned char)cache[cache_length]; + if (!((ch >= (unsigned char)'0' && ch <= (unsigned char)'9') || + (ch >= (unsigned char)'a' && ch <= (unsigned char)'f'))) { + return false; + } + cache_length++; + } + if (cache_length != CBM_DAEMON_BUILD_FINGERPRINT_SIZE - 1U || cache[cache_length] != '\0') { + return false; + } cbm_daemon_conflict_t comparison; - return cbm_daemon_hello_compare(identity, identity, &comparison) == - CBM_DAEMON_HELLO_COMPATIBLE; + return cbm_daemon_hello_compare(identity, identity, &comparison) == CBM_DAEMON_HELLO_COMPATIBLE; } static void version_cohort_u32_encode(unsigned char out[4], uint32_t value) { @@ -160,58 +166,56 @@ static void version_cohort_u32_encode(unsigned char out[4], uint32_t value) { } static uint32_t version_cohort_u32_decode(const unsigned char in[4]) { - return (uint32_t)in[0] | ((uint32_t)in[1] << 8) | - ((uint32_t)in[2] << 16) | ((uint32_t)in[3] << 24); + return (uint32_t)in[0] | ((uint32_t)in[1] << 8) | ((uint32_t)in[2] << 16) | + ((uint32_t)in[3] << 24); } -static bool version_cohort_record_encode( - const cbm_daemon_build_identity_t *identity, - unsigned char out[VERSION_COHORT_RECORD_SIZE]) { +static bool version_cohort_record_encode(const cbm_daemon_build_identity_t *identity, + unsigned char out[VERSION_COHORT_RECORD_SIZE]) { if (!out || !version_cohort_identity_valid(identity)) { return false; } memset(out, 0, VERSION_COHORT_RECORD_SIZE); - memcpy(out, VERSION_COHORT_RECORD_MAGIC, - VERSION_COHORT_RECORD_MAGIC_SIZE); + memcpy(out, VERSION_COHORT_RECORD_MAGIC, VERSION_COHORT_RECORD_MAGIC_SIZE); size_t version_length = strlen(identity->semantic_version); size_t build_length = strlen(identity->build_fingerprint); - memcpy(out + VERSION_COHORT_RECORD_VERSION_OFFSET, - identity->semantic_version, version_length + 1); - memcpy(out + VERSION_COHORT_RECORD_BUILD_OFFSET, - identity->build_fingerprint, build_length + 1); - version_cohort_u32_encode(out + VERSION_COHORT_RECORD_PROTOCOL_OFFSET, - identity->protocol_abi); - version_cohort_u32_encode(out + VERSION_COHORT_RECORD_STORE_OFFSET, - identity->store_abi); - version_cohort_u32_encode(out + VERSION_COHORT_RECORD_FEATURE_OFFSET, - identity->feature_abi); + memcpy(out + VERSION_COHORT_RECORD_VERSION_OFFSET, identity->semantic_version, + version_length + 1); + memcpy(out + VERSION_COHORT_RECORD_BUILD_OFFSET, identity->build_fingerprint, build_length + 1); + if (identity->cache_fingerprint) { + size_t cache_length = strlen(identity->cache_fingerprint); + memcpy(out + VERSION_COHORT_RECORD_CACHE_OFFSET, identity->cache_fingerprint, + cache_length + 1); + } + version_cohort_u32_encode(out + VERSION_COHORT_RECORD_PROTOCOL_OFFSET, identity->protocol_abi); + version_cohort_u32_encode(out + VERSION_COHORT_RECORD_STORE_OFFSET, identity->store_abi); + version_cohort_u32_encode(out + VERSION_COHORT_RECORD_FEATURE_OFFSET, identity->feature_abi); return true; } -static bool version_cohort_record_decode( - const unsigned char record[VERSION_COHORT_RECORD_SIZE], size_t length, - cbm_daemon_build_identity_t *identity_out, - char version_out[CBM_DAEMON_VERSION_TEXT_SIZE], - char build_out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { - if (!record || length != VERSION_COHORT_RECORD_SIZE || !identity_out || - !version_out || !build_out || - memcmp(record, VERSION_COHORT_RECORD_MAGIC, - VERSION_COHORT_RECORD_MAGIC_SIZE) != 0) { +static bool version_cohort_record_decode(const unsigned char record[VERSION_COHORT_RECORD_SIZE], + size_t length, cbm_daemon_build_identity_t *identity_out, + char version_out[CBM_DAEMON_VERSION_TEXT_SIZE], + char build_out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE], + char cache_out[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!record || length != VERSION_COHORT_RECORD_SIZE || !identity_out || !version_out || + !build_out || !cache_out || + memcmp(record, VERSION_COHORT_RECORD_MAGIC, VERSION_COHORT_RECORD_MAGIC_SIZE) != 0) { return false; } memcpy(version_out, record + VERSION_COHORT_RECORD_VERSION_OFFSET, CBM_DAEMON_VERSION_TEXT_SIZE); memcpy(build_out, record + VERSION_COHORT_RECORD_BUILD_OFFSET, CBM_DAEMON_BUILD_FINGERPRINT_SIZE); + memcpy(cache_out, record + VERSION_COHORT_RECORD_CACHE_OFFSET, + CBM_DAEMON_BUILD_FINGERPRINT_SIZE); cbm_daemon_build_identity_t identity = { .semantic_version = version_out, .build_fingerprint = build_out, - .protocol_abi = version_cohort_u32_decode( - record + VERSION_COHORT_RECORD_PROTOCOL_OFFSET), - .store_abi = version_cohort_u32_decode( - record + VERSION_COHORT_RECORD_STORE_OFFSET), - .feature_abi = version_cohort_u32_decode( - record + VERSION_COHORT_RECORD_FEATURE_OFFSET), + .cache_fingerprint = cache_out[0] ? cache_out : NULL, + .protocol_abi = version_cohort_u32_decode(record + VERSION_COHORT_RECORD_PROTOCOL_OFFSET), + .store_abi = version_cohort_u32_decode(record + VERSION_COHORT_RECORD_STORE_OFFSET), + .feature_abi = version_cohort_u32_decode(record + VERSION_COHORT_RECORD_FEATURE_OFFSET), }; if (!version_cohort_identity_valid(&identity)) { return false; @@ -223,26 +227,24 @@ static bool version_cohort_record_decode( static cbm_version_cohort_status_t version_cohort_status_from_lock( cbm_private_file_lock_status_t status) { switch (status) { - case CBM_PRIVATE_FILE_LOCK_OK: - return CBM_VERSION_COHORT_OK; - case CBM_PRIVATE_FILE_LOCK_BUSY: - return CBM_VERSION_COHORT_BUSY; - case CBM_PRIVATE_FILE_LOCK_UNSAFE: - return CBM_VERSION_COHORT_UNSAFE; - case CBM_PRIVATE_FILE_LOCK_IO: - default: - return CBM_VERSION_COHORT_IO; + case CBM_PRIVATE_FILE_LOCK_OK: + return CBM_VERSION_COHORT_OK; + case CBM_PRIVATE_FILE_LOCK_BUSY: + return CBM_VERSION_COHORT_BUSY; + case CBM_PRIVATE_FILE_LOCK_UNSAFE: + return CBM_VERSION_COHORT_UNSAFE; + case CBM_PRIVATE_FILE_LOCK_IO: + default: + return CBM_VERSION_COHORT_IO; } } static cbm_private_file_lock_status_t version_cohort_lock_until( - cbm_version_cohort_manager_t *manager, const char *base_name, - cbm_private_file_lock_mode_t mode, uint64_t deadline_ms, - cbm_private_file_lock_t **lock_out) { + cbm_version_cohort_manager_t *manager, const char *base_name, cbm_private_file_lock_mode_t mode, + uint64_t deadline_ms, cbm_private_file_lock_t **lock_out) { for (;;) { cbm_private_file_lock_status_t status = - cbm_private_file_lock_try_acquire(manager->directory, base_name, - mode, lock_out); + cbm_private_file_lock_try_acquire(manager->directory, base_name, mode, lock_out); if (status != CBM_PRIVATE_FILE_LOCK_BUSY || (deadline_ms != UINT64_MAX && cbm_now_ms() >= deadline_ms)) { return status; @@ -251,8 +253,7 @@ static cbm_private_file_lock_status_t version_cohort_lock_until( } } -static cbm_version_cohort_lease_t *version_cohort_lease_new( - cbm_version_cohort_manager_t *manager) { +static cbm_version_cohort_lease_t *version_cohort_lease_new(cbm_version_cohort_manager_t *manager) { if (!manager) { return NULL; } @@ -261,8 +262,7 @@ static cbm_version_cohort_lease_t *version_cohort_lease_new( return NULL; } cbm_mutex_lock(&manager->mutex); - bool admitted = !manager->closing && - manager->owner_pid == version_cohort_current_pid(); + bool admitted = !manager->closing && manager->owner_pid == version_cohort_current_pid(); if (admitted) { manager->lease_count++; } @@ -276,14 +276,12 @@ static cbm_version_cohort_lease_t *version_cohort_lease_new( return lease; } -static bool version_cohort_manager_register( - cbm_version_cohort_manager_t *manager) { +static bool version_cohort_manager_register(cbm_version_cohort_manager_t *manager) { if (!manager) { return false; } cbm_mutex_lock(&manager->mutex); - bool admitted = !manager->closing && - manager->owner_pid == version_cohort_current_pid(); + bool admitted = !manager->closing && manager->owner_pid == version_cohort_current_pid(); if (admitted) { manager->lease_count++; } @@ -291,14 +289,13 @@ static bool version_cohort_manager_register( return admitted; } -static bool version_cohort_manager_unregister( - cbm_version_cohort_manager_t *manager) { +static bool version_cohort_manager_unregister(cbm_version_cohort_manager_t *manager) { if (!manager) { return false; } cbm_mutex_lock(&manager->mutex); - bool registered = manager->owner_pid == version_cohort_current_pid() && - manager->lease_count > 0; + bool registered = + manager->owner_pid == version_cohort_current_pid() && manager->lease_count > 0; if (registered) { manager->lease_count--; } @@ -314,8 +311,7 @@ cbm_private_file_lock_status_t cbm_version_cohort_lease_release( cbm_version_cohort_lease_t *lease = *lease_io; cbm_private_file_lock_status_t result = CBM_PRIVATE_FILE_LOCK_OK; if (lease->lifetime) { - cbm_private_file_lock_status_t status = - cbm_private_file_lock_release(&lease->lifetime); + cbm_private_file_lock_status_t status = cbm_private_file_lock_release(&lease->lifetime); if (status != CBM_PRIVATE_FILE_LOCK_OK) { result = status; } @@ -324,8 +320,7 @@ cbm_private_file_lock_status_t cbm_version_cohort_lease_release( return CBM_PRIVATE_FILE_LOCK_IO; } if (lease->admission) { - cbm_private_file_lock_status_t status = - cbm_private_file_lock_release(&lease->admission); + cbm_private_file_lock_status_t status = cbm_private_file_lock_release(&lease->admission); if (status != CBM_PRIVATE_FILE_LOCK_OK) { result = status; } @@ -334,8 +329,7 @@ cbm_private_file_lock_status_t cbm_version_cohort_lease_release( return CBM_PRIVATE_FILE_LOCK_IO; } if (lease->maintenance) { - cbm_private_file_lock_status_t status = - cbm_private_file_lock_release(&lease->maintenance); + cbm_private_file_lock_status_t status = cbm_private_file_lock_release(&lease->maintenance); if (status != CBM_PRIVATE_FILE_LOCK_OK) { result = status; } @@ -358,18 +352,16 @@ cbm_private_file_lock_status_t cbm_version_cohort_lease_release( return result; } -static cbm_version_cohort_status_t version_cohort_failed( - cbm_version_cohort_lease_t *lease, cbm_version_cohort_status_t status, - cbm_version_cohort_lease_t **lease_out) { +static cbm_version_cohort_status_t version_cohort_failed(cbm_version_cohort_lease_t *lease, + cbm_version_cohort_status_t status, + cbm_version_cohort_lease_t **lease_out) { cbm_version_cohort_lease_t *cleanup = lease; - cbm_private_file_lock_status_t cleanup_status = - cbm_version_cohort_lease_release(&cleanup); + cbm_private_file_lock_status_t cleanup_status = cbm_version_cohort_lease_release(&cleanup); if (cleanup) { *lease_out = cleanup; return CBM_VERSION_COHORT_IO; } - return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK ? status - : CBM_VERSION_COHORT_IO; + return cleanup_status == CBM_PRIVATE_FILE_LOCK_OK ? status : CBM_VERSION_COHORT_IO; } cbm_version_cohort_manager_t *cbm_version_cohort_manager_new( @@ -395,30 +387,28 @@ cbm_version_cohort_manager_t *cbm_version_cohort_manager_new( } static cbm_version_cohort_status_t version_cohort_claim_new( - cbm_version_cohort_lease_t *lease, - const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms) { + cbm_version_cohort_lease_t *lease, const cbm_daemon_build_identity_t *identity, + uint64_t deadline_ms) { unsigned char record[VERSION_COHORT_RECORD_SIZE]; if (!version_cohort_record_encode(identity, record) || - cbm_private_file_lock_payload_write(lease->lifetime, record, - sizeof(record)) != + cbm_private_file_lock_payload_write(lease->lifetime, record, sizeof(record)) != CBM_PRIVATE_FILE_LOCK_OK) { return CBM_VERSION_COHORT_IO; } - cbm_private_file_lock_status_t release_status = - cbm_private_file_lock_release(&lease->lifetime); + cbm_private_file_lock_status_t release_status = cbm_private_file_lock_release(&lease->lifetime); if (release_status != CBM_PRIVATE_FILE_LOCK_OK) { return CBM_VERSION_COHORT_IO; } - return version_cohort_status_from_lock(version_cohort_lock_until( - lease->manager, VERSION_COHORT_LIFETIME_FILE, - CBM_PRIVATE_FILE_LOCK_SH, deadline_ms, &lease->lifetime)); + return version_cohort_status_from_lock( + version_cohort_lock_until(lease->manager, VERSION_COHORT_LIFETIME_FILE, + CBM_PRIVATE_FILE_LOCK_SH, deadline_ms, &lease->lifetime)); } -cbm_version_cohort_status_t cbm_version_cohort_acquire( - cbm_version_cohort_manager_t *manager, - const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms, - cbm_version_cohort_lease_t **lease_out, - cbm_daemon_conflict_t *conflict_out) { +cbm_version_cohort_status_t cbm_version_cohort_acquire(cbm_version_cohort_manager_t *manager, + const cbm_daemon_build_identity_t *identity, + uint64_t deadline_ms, + cbm_version_cohort_lease_t **lease_out, + cbm_daemon_conflict_t *conflict_out) { if (lease_out) { *lease_out = NULL; } @@ -438,49 +428,61 @@ cbm_version_cohort_status_t cbm_version_cohort_acquire( * publish EX intent until this already-started transition has finished, * while every later participant fails its non-blocking SH attempt. */ cbm_private_file_lock_status_t lock_status = - cbm_private_file_lock_try_acquire( - manager->directory, VERSION_COHORT_MAINTENANCE_FILE, - CBM_PRIVATE_FILE_LOCK_SH, &lease->maintenance); + cbm_private_file_lock_try_acquire(manager->directory, VERSION_COHORT_MAINTENANCE_FILE, + CBM_PRIVATE_FILE_LOCK_SH, &lease->maintenance); if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) { - return version_cohort_failed( - lease, version_cohort_status_from_lock(lock_status), lease_out); + return version_cohort_failed(lease, version_cohort_status_from_lock(lock_status), + lease_out); } - lock_status = version_cohort_lock_until( - manager, VERSION_COHORT_ADMISSION_FILE, CBM_PRIVATE_FILE_LOCK_EX, - deadline_ms, &lease->admission); + lock_status = + version_cohort_lock_until(manager, VERSION_COHORT_ADMISSION_FILE, CBM_PRIVATE_FILE_LOCK_EX, + deadline_ms, &lease->admission); if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) { - return version_cohort_failed( - lease, version_cohort_status_from_lock(lock_status), lease_out); + return version_cohort_failed(lease, version_cohort_status_from_lock(lock_status), + lease_out); } - lock_status = cbm_private_file_lock_try_acquire( - manager->directory, VERSION_COHORT_LIFETIME_FILE, - CBM_PRIVATE_FILE_LOCK_EX, &lease->lifetime); - cbm_version_cohort_status_t status = - version_cohort_status_from_lock(lock_status); + lock_status = + cbm_private_file_lock_try_acquire(manager->directory, VERSION_COHORT_LIFETIME_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &lease->lifetime); + cbm_version_cohort_status_t status = version_cohort_status_from_lock(lock_status); if (status == CBM_VERSION_COHORT_OK) { status = version_cohort_claim_new(lease, identity, deadline_ms); } else if (status == CBM_VERSION_COHORT_BUSY) { - lock_status = cbm_private_file_lock_try_acquire( - manager->directory, VERSION_COHORT_LIFETIME_FILE, - CBM_PRIVATE_FILE_LOCK_SH, &lease->lifetime); + lock_status = + cbm_private_file_lock_try_acquire(manager->directory, VERSION_COHORT_LIFETIME_FILE, + CBM_PRIVATE_FILE_LOCK_SH, &lease->lifetime); status = version_cohort_status_from_lock(lock_status); if (status == CBM_VERSION_COHORT_OK) { unsigned char record[VERSION_COHORT_RECORD_SIZE]; size_t record_length = 0; char active_version[CBM_DAEMON_VERSION_TEXT_SIZE]; char active_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + char active_cache[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; cbm_daemon_build_identity_t active; - if (cbm_private_file_lock_payload_read( - lease->lifetime, record, sizeof(record), - &record_length) != CBM_PRIVATE_FILE_LOCK_OK || - !version_cohort_record_decode( - record, record_length, &active, active_version, - active_build)) { + if (cbm_private_file_lock_payload_read(lease->lifetime, record, sizeof(record), + &record_length) != CBM_PRIVATE_FILE_LOCK_OK || + !version_cohort_record_decode(record, record_length, &active, active_version, + active_build, active_cache)) { status = CBM_VERSION_COHORT_UNSAFE; } else { cbm_daemon_hello_status_t comparison = cbm_daemon_hello_compare(&active, identity, conflict_out); + const char *active_cache_fingerprint = + active.cache_fingerprint ? active.cache_fingerprint : ""; + const char *requested_cache_fingerprint = + identity->cache_fingerprint ? identity->cache_fingerprint : ""; + if (comparison == CBM_DAEMON_HELLO_COMPATIBLE && + strcmp(active_cache_fingerprint, requested_cache_fingerprint) != 0) { + comparison = CBM_DAEMON_HELLO_CACHE_CONFLICT; + conflict_out->status = comparison; + (void)snprintf(conflict_out->active_cache_fingerprint, + sizeof(conflict_out->active_cache_fingerprint), "%s", + active_cache_fingerprint); + (void)snprintf(conflict_out->requested_cache_fingerprint, + sizeof(conflict_out->requested_cache_fingerprint), "%s", + requested_cache_fingerprint); + } if (comparison == CBM_DAEMON_HELLO_COMPATIBLE) { status = CBM_VERSION_COHORT_OK; } else if (comparison == CBM_DAEMON_HELLO_INVALID) { @@ -492,13 +494,11 @@ cbm_version_cohort_status_t cbm_version_cohort_acquire( status = CBM_VERSION_COHORT_IO; } else { lock_status = cbm_private_file_lock_try_acquire( - manager->directory, - VERSION_COHORT_LIFETIME_FILE, + manager->directory, VERSION_COHORT_LIFETIME_FILE, CBM_PRIVATE_FILE_LOCK_EX, &lease->lifetime); status = version_cohort_status_from_lock(lock_status); if (status == CBM_VERSION_COHORT_OK) { - status = version_cohort_claim_new( - lease, identity, deadline_ms); + status = version_cohort_claim_new(lease, identity, deadline_ms); } else if (status == CBM_VERSION_COHORT_BUSY) { status = CBM_VERSION_COHORT_CONFLICT; } @@ -514,25 +514,22 @@ cbm_version_cohort_status_t cbm_version_cohort_acquire( cbm_private_file_lock_status_t admission_release = cbm_private_file_lock_release(&lease->admission); if (admission_release != CBM_PRIVATE_FILE_LOCK_OK) { - return version_cohort_failed(lease, CBM_VERSION_COHORT_IO, - lease_out); + return version_cohort_failed(lease, CBM_VERSION_COHORT_IO, lease_out); } cbm_private_file_lock_status_t maintenance_release = cbm_private_file_lock_release(&lease->maintenance); if (maintenance_release != CBM_PRIVATE_FILE_LOCK_OK) { - return version_cohort_failed(lease, CBM_VERSION_COHORT_IO, - lease_out); + return version_cohort_failed(lease, CBM_VERSION_COHORT_IO, lease_out); } *lease_out = lease; return CBM_VERSION_COHORT_OK; } -static cbm_version_cohort_status_t -version_cohort_reserve_for_mutation_internal( +static cbm_version_cohort_status_t version_cohort_reserve_for_mutation_internal( cbm_version_cohort_manager_t *manager, uint64_t deadline_ms, cbm_version_cohort_quiesce_fn quiesce, void *quiesce_context, - cbm_version_cohort_quiesce_result_t *quiesce_result_out, - cbm_version_cohort_lease_t **lease_out, bool require_finite_deadline) { + cbm_version_cohort_quiesce_result_t *quiesce_result_out, cbm_version_cohort_lease_t **lease_out, + bool require_finite_deadline) { if (lease_out) { *lease_out = NULL; } @@ -551,55 +548,50 @@ version_cohort_reserve_for_mutation_internal( * flight. Normal admissions retain maintenance SH until their admission * transition ends, giving every participant the same deadlock-free native * order: maintenance -> admission -> lifetime. */ - cbm_private_file_lock_status_t lock_status = version_cohort_lock_until( - manager, VERSION_COHORT_MAINTENANCE_FILE, - CBM_PRIVATE_FILE_LOCK_EX, deadline_ms, &lease->maintenance); + cbm_private_file_lock_status_t lock_status = + version_cohort_lock_until(manager, VERSION_COHORT_MAINTENANCE_FILE, + CBM_PRIVATE_FILE_LOCK_EX, deadline_ms, &lease->maintenance); if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) { - return version_cohort_failed( - lease, version_cohort_status_from_lock(lock_status), lease_out); + return version_cohort_failed(lease, version_cohort_status_from_lock(lock_status), + lease_out); } - lock_status = version_cohort_lock_until( - manager, VERSION_COHORT_ADMISSION_FILE, CBM_PRIVATE_FILE_LOCK_EX, - deadline_ms, &lease->admission); + lock_status = + version_cohort_lock_until(manager, VERSION_COHORT_ADMISSION_FILE, CBM_PRIVATE_FILE_LOCK_EX, + deadline_ms, &lease->admission); if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) { - return version_cohort_failed( - lease, version_cohort_status_from_lock(lock_status), lease_out); + return version_cohort_failed(lease, version_cohort_status_from_lock(lock_status), + lease_out); } - lock_status = cbm_private_file_lock_try_acquire( - manager->directory, VERSION_COHORT_LIFETIME_FILE, - CBM_PRIVATE_FILE_LOCK_EX, &lease->lifetime); + lock_status = + cbm_private_file_lock_try_acquire(manager->directory, VERSION_COHORT_LIFETIME_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &lease->lifetime); if (lock_status == CBM_PRIVATE_FILE_LOCK_BUSY) { if (!quiesce) { *quiesce_result_out = CBM_VERSION_COHORT_QUIESCE_REFUSED; - return version_cohort_failed(lease, CBM_VERSION_COHORT_BUSY, - lease_out); + return version_cohort_failed(lease, CBM_VERSION_COHORT_BUSY, lease_out); } /* Admission remains exclusively locked across the callback and wait. * Consequently the active lifetime set can only shrink, and no new * participant can race mutation after accepting the quiesce request. */ - cbm_version_cohort_quiesce_result_t quiesce_result = - quiesce(quiesce_context); + cbm_version_cohort_quiesce_result_t quiesce_result = quiesce(quiesce_context); *quiesce_result_out = quiesce_result; if (quiesce_result == CBM_VERSION_COHORT_QUIESCE_REFUSED) { - return version_cohort_failed(lease, CBM_VERSION_COHORT_BUSY, - lease_out); + return version_cohort_failed(lease, CBM_VERSION_COHORT_BUSY, lease_out); } if (quiesce_result == CBM_VERSION_COHORT_QUIESCE_ERROR) { - return version_cohort_failed(lease, CBM_VERSION_COHORT_IO, - lease_out); + return version_cohort_failed(lease, CBM_VERSION_COHORT_IO, lease_out); } if (quiesce_result != CBM_VERSION_COHORT_QUIESCE_REQUESTED) { - return version_cohort_failed(lease, CBM_VERSION_COHORT_UNSAFE, - lease_out); + return version_cohort_failed(lease, CBM_VERSION_COHORT_UNSAFE, lease_out); } - lock_status = version_cohort_lock_until( - manager, VERSION_COHORT_LIFETIME_FILE, - CBM_PRIVATE_FILE_LOCK_EX, deadline_ms, &lease->lifetime); + lock_status = + version_cohort_lock_until(manager, VERSION_COHORT_LIFETIME_FILE, + CBM_PRIVATE_FILE_LOCK_EX, deadline_ms, &lease->lifetime); } if (lock_status != CBM_PRIVATE_FILE_LOCK_OK) { - return version_cohort_failed( - lease, version_cohort_status_from_lock(lock_status), lease_out); + return version_cohort_failed(lease, version_cohort_status_from_lock(lock_status), + lease_out); } /* All three locks intentionally remain in the lease. Maintenance makes @@ -615,32 +607,27 @@ cbm_version_cohort_status_t cbm_version_cohort_reserve_for_mutation( cbm_version_cohort_quiesce_result_t *quiesce_result_out, cbm_version_cohort_lease_t **lease_out) { return version_cohort_reserve_for_mutation_internal( - manager, deadline_ms, quiesce, quiesce_context, quiesce_result_out, - lease_out, true); + manager, deadline_ms, quiesce, quiesce_context, quiesce_result_out, lease_out, true); } cbm_version_cohort_status_t cbm_version_cohort_reserve_exclusive( cbm_version_cohort_manager_t *manager, uint64_t deadline_ms, cbm_version_cohort_lease_t **lease_out) { cbm_version_cohort_quiesce_result_t ignored_quiesce; - return version_cohort_reserve_for_mutation_internal( - manager, deadline_ms, NULL, NULL, &ignored_quiesce, lease_out, false); + return version_cohort_reserve_for_mutation_internal(manager, deadline_ms, NULL, NULL, + &ignored_quiesce, lease_out, false); } -cbm_version_cohort_maintenance_presence_t -cbm_version_cohort_maintenance_presence( - cbm_version_cohort_manager_t *manager) { +static cbm_version_cohort_maintenance_presence_t version_cohort_maintenance_presence_internal( + cbm_version_cohort_manager_t *manager, bool terminal_observer) { if (!manager || !version_cohort_manager_register(manager)) { return CBM_VERSION_COHORT_MAINTENANCE_UNSAFE; } - cbm_version_cohort_maintenance_presence_t presence = - CBM_VERSION_COHORT_MAINTENANCE_IO; + cbm_version_cohort_maintenance_presence_t presence = CBM_VERSION_COHORT_MAINTENANCE_IO; cbm_private_file_lock_t *observer = NULL; - cbm_private_file_lock_status_t status = - cbm_private_file_lock_try_acquire( - manager->directory, VERSION_COHORT_MAINTENANCE_FILE, - CBM_PRIVATE_FILE_LOCK_SH, &observer); + cbm_private_file_lock_status_t status = cbm_private_file_lock_try_acquire( + manager->directory, VERSION_COHORT_MAINTENANCE_FILE, CBM_PRIVATE_FILE_LOCK_SH, &observer); if (status == CBM_PRIVATE_FILE_LOCK_OK && observer) { presence = CBM_VERSION_COHORT_MAINTENANCE_ABSENT; } else if (status == CBM_PRIVATE_FILE_LOCK_BUSY) { @@ -649,14 +636,19 @@ cbm_version_cohort_maintenance_presence( presence = CBM_VERSION_COHORT_MAINTENANCE_UNSAFE; } - cbm_private_file_lock_status_t observer_release = - version_cohort_lock_release_until( - &observer, version_cohort_deadline_after( - VERSION_COHORT_CLEANUP_TIMEOUT_MS)); + cbm_private_file_lock_status_t observer_release = version_cohort_lock_release_until( + &observer, version_cohort_deadline_after(VERSION_COHORT_CLEANUP_TIMEOUT_MS)); if (observer_release != CBM_PRIVATE_FILE_LOCK_OK) { presence = CBM_VERSION_COHORT_MAINTENANCE_IO; } if (observer) { + if (terminal_observer) { + /* The caller's contract is immediate no-I/O process termination. + * Returning the error preserves that terminal thread's ability to + * exit even when structured logging or stdio is backpressured. The + * native handle intentionally remains process-owned until exit. */ + return CBM_VERSION_COHORT_MAINTENANCE_IO; + } version_cohort_cleanup_fail_stop("maintenance_observer_cleanup"); } if (!version_cohort_manager_unregister(manager)) { @@ -665,9 +657,18 @@ cbm_version_cohort_maintenance_presence( return presence; } +cbm_version_cohort_maintenance_presence_t cbm_version_cohort_maintenance_presence( + cbm_version_cohort_manager_t *manager) { + return version_cohort_maintenance_presence_internal(manager, false); +} + +cbm_version_cohort_maintenance_presence_t cbm_version_cohort_maintenance_presence_terminal( + cbm_version_cohort_manager_t *manager) { + return version_cohort_maintenance_presence_internal(manager, true); +} + cbm_version_cohort_status_t cbm_version_cohort_daemon_claim_acquire( - cbm_version_cohort_manager_t *manager, - cbm_version_cohort_daemon_claim_t **claim_out) { + cbm_version_cohort_manager_t *manager, cbm_version_cohort_daemon_claim_t **claim_out) { if (claim_out) { *claim_out = NULL; } @@ -681,19 +682,15 @@ cbm_version_cohort_status_t cbm_version_cohort_daemon_claim_acquire( } claim->manager = manager; claim->registered = true; - cbm_private_file_lock_status_t lock_status = - cbm_private_file_lock_try_acquire( - manager->directory, VERSION_COHORT_DAEMON_FILE, - CBM_PRIVATE_FILE_LOCK_EX, &claim->marker); + cbm_private_file_lock_status_t lock_status = cbm_private_file_lock_try_acquire( + manager->directory, VERSION_COHORT_DAEMON_FILE, CBM_PRIVATE_FILE_LOCK_EX, &claim->marker); if (lock_status == CBM_PRIVATE_FILE_LOCK_OK) { *claim_out = claim; return CBM_VERSION_COHORT_OK; } - cbm_private_file_lock_status_t cleanup_status = - version_cohort_lock_release_until( - &claim->marker, version_cohort_deadline_after( - VERSION_COHORT_CLEANUP_TIMEOUT_MS)); + cbm_private_file_lock_status_t cleanup_status = version_cohort_lock_release_until( + &claim->marker, version_cohort_deadline_after(VERSION_COHORT_CLEANUP_TIMEOUT_MS)); if (claim->marker) { *claim_out = claim; return CBM_VERSION_COHORT_IO; @@ -729,20 +726,16 @@ cbm_private_file_lock_status_t cbm_version_cohort_daemon_claim_release( return result; } -cbm_version_cohort_daemon_presence_t -cbm_version_cohort_daemon_claim_presence( +cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_claim_presence( cbm_version_cohort_manager_t *manager) { if (!manager || !version_cohort_manager_register(manager)) { return CBM_VERSION_COHORT_DAEMON_UNSAFE; } - cbm_version_cohort_daemon_presence_t presence = - CBM_VERSION_COHORT_DAEMON_IO; + cbm_version_cohort_daemon_presence_t presence = CBM_VERSION_COHORT_DAEMON_IO; cbm_private_file_lock_t *marker = NULL; - cbm_private_file_lock_status_t marker_status = - cbm_private_file_lock_try_acquire( - manager->directory, VERSION_COHORT_DAEMON_FILE, - CBM_PRIVATE_FILE_LOCK_EX, &marker); + cbm_private_file_lock_status_t marker_status = cbm_private_file_lock_try_acquire( + manager->directory, VERSION_COHORT_DAEMON_FILE, CBM_PRIVATE_FILE_LOCK_EX, &marker); if (marker_status == CBM_PRIVATE_FILE_LOCK_BUSY) { presence = CBM_VERSION_COHORT_DAEMON_COORDINATED; } else if (marker_status == CBM_PRIVATE_FILE_LOCK_OK && marker) { @@ -751,10 +744,8 @@ cbm_version_cohort_daemon_claim_presence( presence = CBM_VERSION_COHORT_DAEMON_UNSAFE; } - cbm_private_file_lock_status_t marker_release = - version_cohort_lock_release_until( - &marker, version_cohort_deadline_after( - VERSION_COHORT_CLEANUP_TIMEOUT_MS)); + cbm_private_file_lock_status_t marker_release = version_cohort_lock_release_until( + &marker, version_cohort_deadline_after(VERSION_COHORT_CLEANUP_TIMEOUT_MS)); if (marker_release != CBM_PRIVATE_FILE_LOCK_OK) { presence = CBM_VERSION_COHORT_DAEMON_IO; } @@ -767,18 +758,13 @@ cbm_version_cohort_daemon_claim_presence( return presence; } -static cbm_version_cohort_daemon_presence_t -version_cohort_active_daemon_presence( - cbm_version_cohort_manager_t *manager, - const cbm_daemon_ipc_endpoint_t *endpoint, +static cbm_version_cohort_daemon_presence_t version_cohort_active_daemon_presence( + cbm_version_cohort_manager_t *manager, const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_ipc_local_transition_t *transition) { - cbm_version_cohort_daemon_presence_t presence = - CBM_VERSION_COHORT_DAEMON_IO; + cbm_version_cohort_daemon_presence_t presence = CBM_VERSION_COHORT_DAEMON_IO; cbm_private_file_lock_t *marker = NULL; - cbm_private_file_lock_status_t marker_status = - cbm_private_file_lock_try_acquire( - manager->directory, VERSION_COHORT_DAEMON_FILE, - CBM_PRIVATE_FILE_LOCK_EX, &marker); + cbm_private_file_lock_status_t marker_status = cbm_private_file_lock_try_acquire( + manager->directory, VERSION_COHORT_DAEMON_FILE, CBM_PRIVATE_FILE_LOCK_EX, &marker); if (marker_status == CBM_PRIVATE_FILE_LOCK_BUSY) { presence = CBM_VERSION_COHORT_DAEMON_COORDINATED; } else if (marker_status == CBM_PRIVATE_FILE_LOCK_OK) { @@ -789,22 +775,16 @@ version_cohort_active_daemon_presence( * starting. Recheck lifetime while retaining the marker EX lock: * shutdown may have released lifetime after our first observation * but before the marker became available. */ - int lifetime = - cbm_daemon_ipc_local_transition_lifetime_probe( - endpoint, transition); - presence = lifetime == 0 - ? CBM_VERSION_COHORT_DAEMON_ABSENT - : lifetime == 1 - ? CBM_VERSION_COHORT_DAEMON_UNCOORDINATED - : CBM_VERSION_COHORT_DAEMON_UNSAFE; + int lifetime = cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, transition); + presence = lifetime == 0 ? CBM_VERSION_COHORT_DAEMON_ABSENT + : lifetime == 1 ? CBM_VERSION_COHORT_DAEMON_UNCOORDINATED + : CBM_VERSION_COHORT_DAEMON_UNSAFE; } } else if (marker_status == CBM_PRIVATE_FILE_LOCK_UNSAFE) { presence = CBM_VERSION_COHORT_DAEMON_UNSAFE; } - cbm_private_file_lock_status_t marker_release = - version_cohort_lock_release_until( - &marker, version_cohort_deadline_after( - VERSION_COHORT_CLEANUP_TIMEOUT_MS)); + cbm_private_file_lock_status_t marker_release = version_cohort_lock_release_until( + &marker, version_cohort_deadline_after(VERSION_COHORT_CLEANUP_TIMEOUT_MS)); if (marker_release != CBM_PRIVATE_FILE_LOCK_OK) { presence = CBM_VERSION_COHORT_DAEMON_IO; } @@ -815,16 +795,13 @@ version_cohort_active_daemon_presence( } cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence( - cbm_version_cohort_manager_t *manager, - const cbm_daemon_ipc_endpoint_t *endpoint) { + cbm_version_cohort_manager_t *manager, const cbm_daemon_ipc_endpoint_t *endpoint) { if (!manager || !endpoint || !version_cohort_manager_register(manager)) { return CBM_VERSION_COHORT_DAEMON_UNSAFE; } - cbm_version_cohort_daemon_presence_t presence = - CBM_VERSION_COHORT_DAEMON_IO; - cbm_version_cohort_daemon_presence_t claim = - cbm_version_cohort_daemon_claim_presence(manager); + cbm_version_cohort_daemon_presence_t presence = CBM_VERSION_COHORT_DAEMON_IO; + cbm_version_cohort_daemon_presence_t claim = cbm_version_cohort_daemon_claim_presence(manager); if (claim == CBM_VERSION_COHORT_DAEMON_COORDINATED) { presence = claim; } else if (claim != CBM_VERSION_COHORT_DAEMON_ABSENT) { @@ -843,15 +820,13 @@ cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence( } #else cbm_daemon_ipc_startup_lock_t *startup = NULL; - int startup_status = cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &startup); + int startup_status = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup); if (startup_status == 0) { presence = CBM_VERSION_COHORT_DAEMON_UNCOORDINATED; } else if (startup_status < 0 || !startup) { presence = CBM_VERSION_COHORT_DAEMON_UNSAFE; } else { - int cleanup = cbm_daemon_ipc_stale_generation_cleanup( - endpoint, startup); + int cleanup = cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup); if (cleanup == 1) { presence = CBM_VERSION_COHORT_DAEMON_ABSENT; } else if (cleanup == 0) { @@ -863,8 +838,7 @@ cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence( version_cohort_startup_lock_release_complete(&startup); #endif } else if (lifetime == 1) { - presence = version_cohort_active_daemon_presence(manager, NULL, - NULL); + presence = version_cohort_active_daemon_presence(manager, NULL, NULL); } } if (!version_cohort_manager_unregister(manager)) { @@ -873,18 +847,14 @@ cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence( return presence; } -cbm_version_cohort_daemon_presence_t -cbm_version_cohort_daemon_presence_under_transition( - cbm_version_cohort_manager_t *manager, - const cbm_daemon_ipc_endpoint_t *endpoint, +cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence_under_transition( + cbm_version_cohort_manager_t *manager, const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_ipc_local_transition_t *transition) { - if (!manager || !endpoint || !transition || - !version_cohort_manager_register(manager)) { + if (!manager || !endpoint || !transition || !version_cohort_manager_register(manager)) { return CBM_VERSION_COHORT_DAEMON_UNSAFE; } - int transition_status = - cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, transition); + int transition_status = cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, transition); /* After validating the guard, classify through the marker. If it is free, * keep it exclusively locked while observing lifetime so daemon shutdown * cannot become a false conflict. The retained startup transition prevents @@ -892,8 +862,7 @@ cbm_version_cohort_daemon_presence_under_transition( cbm_version_cohort_daemon_presence_t presence = transition_status < 0 ? CBM_VERSION_COHORT_DAEMON_UNSAFE - : version_cohort_active_daemon_presence( - manager, endpoint, transition); + : version_cohort_active_daemon_presence(manager, endpoint, transition); if (!version_cohort_manager_unregister(manager)) { presence = CBM_VERSION_COHORT_DAEMON_IO; } @@ -908,15 +877,14 @@ cbm_private_file_lock_status_t cbm_version_cohort_manager_free( cbm_version_cohort_manager_t *manager = *manager_io; cbm_mutex_lock(&manager->mutex); bool leases_active = manager->lease_count != 0; - bool can_free = manager->owner_pid == version_cohort_current_pid() && - !leases_active && !manager->closing; + bool can_free = + manager->owner_pid == version_cohort_current_pid() && !leases_active && !manager->closing; if (can_free) { manager->closing = true; } cbm_mutex_unlock(&manager->mutex); if (!can_free) { - return leases_active ? CBM_PRIVATE_FILE_LOCK_BUSY - : CBM_PRIVATE_FILE_LOCK_IO; + return leases_active ? CBM_PRIVATE_FILE_LOCK_BUSY : CBM_PRIVATE_FILE_LOCK_IO; } cbm_private_lock_directory_close(manager->directory); manager->directory = NULL; @@ -934,39 +902,32 @@ bool cbm_version_cohort_log_conflict(const cbm_daemon_conflict_t *conflict) { char logs[VERSION_COHORT_PATH_CAP]; char path[VERSION_COHORT_PATH_CAP]; int logs_written = snprintf(logs, sizeof(logs), "%s/logs", cache); - int path_written = snprintf(path, sizeof(path), - "%s/daemon-conflicts.ndjson", logs); - if (logs_written <= 0 || logs_written >= (int)sizeof(logs) || - path_written <= 0 || path_written >= (int)sizeof(path)) { + int path_written = snprintf(path, sizeof(path), "%s/daemon-conflicts.ndjson", logs); + if (logs_written <= 0 || logs_written >= (int)sizeof(logs) || path_written <= 0 || + path_written >= (int)sizeof(path)) { return false; } - FILE *seed = cbm_daemon_ipc_private_log_open( - logs, "daemon-conflicts.ndjson", VERSION_COHORT_LOG_CAP); + FILE *seed = + cbm_daemon_ipc_private_log_open(logs, "daemon-conflicts.ndjson", VERSION_COHORT_LOG_CAP); if (!seed) { return false; } bool seeded = fclose(seed) == 0; - return seeded && cbm_daemon_conflict_log_append( - path, conflict, VERSION_COHORT_LOG_CAP); + return seeded && cbm_daemon_conflict_log_append(path, conflict, VERSION_COHORT_LOG_CAP); } -bool cbm_version_cohort_log_uncoordinated_daemon( - const cbm_daemon_build_identity_t *requested) { +bool cbm_version_cohort_log_uncoordinated_daemon(const cbm_daemon_build_identity_t *requested) { if (!version_cohort_identity_valid(requested)) { return false; } cbm_daemon_conflict_t conflict = { .status = CBM_DAEMON_HELLO_BUILD_CONFLICT, }; - (void)snprintf(conflict.active_version, - sizeof(conflict.active_version), - "%s", "pre-cohort/unknown"); - memset(conflict.active_build_fingerprint, '0', - sizeof(conflict.active_build_fingerprint) - 1); - conflict.active_build_fingerprint[ - sizeof(conflict.active_build_fingerprint) - 1] = '\0'; - (void)snprintf(conflict.requested_version, - sizeof(conflict.requested_version), "%s", + (void)snprintf(conflict.active_version, sizeof(conflict.active_version), "%s", + "pre-cohort/unknown"); + memset(conflict.active_build_fingerprint, '0', sizeof(conflict.active_build_fingerprint) - 1); + conflict.active_build_fingerprint[sizeof(conflict.active_build_fingerprint) - 1] = '\0'; + (void)snprintf(conflict.requested_version, sizeof(conflict.requested_version), "%s", requested->semantic_version); (void)snprintf(conflict.requested_build_fingerprint, sizeof(conflict.requested_build_fingerprint), "%s", diff --git a/src/daemon/version_cohort.h b/src/daemon/version_cohort.h index c3f609d32..8b742f5f6 100644 --- a/src/daemon/version_cohort.h +++ b/src/daemon/version_cohort.h @@ -9,8 +9,7 @@ typedef struct cbm_version_cohort_manager cbm_version_cohort_manager_t; typedef struct cbm_version_cohort_lease cbm_version_cohort_lease_t; -typedef struct cbm_version_cohort_daemon_claim - cbm_version_cohort_daemon_claim_t; +typedef struct cbm_version_cohort_daemon_claim cbm_version_cohort_daemon_claim_t; typedef enum { CBM_VERSION_COHORT_OK = 0, @@ -48,8 +47,7 @@ typedef enum { CBM_VERSION_COHORT_QUIESCE_ERROR = 3, } cbm_version_cohort_quiesce_result_t; -typedef cbm_version_cohort_quiesce_result_t (*cbm_version_cohort_quiesce_fn)( - void *context); +typedef cbm_version_cohort_quiesce_result_t (*cbm_version_cohort_quiesce_fn)(void *context); /* Managers independently reopen no paths: the endpoint duplicates its * already-validated owner-only runtime-directory handle. All managers for one @@ -64,11 +62,11 @@ cbm_version_cohort_manager_t *cbm_version_cohort_manager_new( * CONFLICT with conflict_out populated. deadline_ms is an absolute * cbm_now_ms() deadline; UINT64_MAX waits indefinitely. Every non-NULL * lease_out, including cleanup-only IO state, must be released. */ -cbm_version_cohort_status_t cbm_version_cohort_acquire( - cbm_version_cohort_manager_t *manager, - const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms, - cbm_version_cohort_lease_t **lease_out, - cbm_daemon_conflict_t *conflict_out); +cbm_version_cohort_status_t cbm_version_cohort_acquire(cbm_version_cohort_manager_t *manager, + const cbm_daemon_build_identity_t *identity, + uint64_t deadline_ms, + cbm_version_cohort_lease_t **lease_out, + cbm_daemon_conflict_t *conflict_out); /* Binary activation takes the lifetime file EX. It therefore refuses while * any CLI/bootstrap/daemon participant is active and blocks new admissions @@ -96,8 +94,17 @@ cbm_version_cohort_status_t cbm_version_cohort_reserve_for_mutation( /* Cheap, non-blocking observation of the crash-released maintenance intent. * Participants use the same native gate before admission, so REQUESTED is an * authoritative fail-fast signal rather than a filesystem-presence guess. */ -cbm_version_cohort_maintenance_presence_t -cbm_version_cohort_maintenance_presence( +cbm_version_cohort_maintenance_presence_t cbm_version_cohort_maintenance_presence( + cbm_version_cohort_manager_t *manager); + +/* Terminal-observer variant for threads whose only safe response to an + * observation error is immediate process exit. Unlike the general observer, + * this never logs, flushes stdio, or fail-stops internally when native lock + * cleanup cannot be completed. It returns MAINTENANCE_IO instead; callers must + * then terminate immediately without touching borrowed manager state. A + * REQUESTED result owns no observer lock and may first use a bounded + * cooperative-cancellation grace period. */ +cbm_version_cohort_maintenance_presence_t cbm_version_cohort_maintenance_presence_terminal( cbm_version_cohort_manager_t *manager); /* The daemon holds this separate EX marker for its generation after taking @@ -106,8 +113,7 @@ cbm_version_cohort_maintenance_presence( * opening an IPC connection or registering a daemon session. A non-NULL * claim_out accompanying IO is cleanup-only authority and must be released. */ cbm_version_cohort_status_t cbm_version_cohort_daemon_claim_acquire( - cbm_version_cohort_manager_t *manager, - cbm_version_cohort_daemon_claim_t **claim_out); + cbm_version_cohort_manager_t *manager, cbm_version_cohort_daemon_claim_t **claim_out); cbm_private_file_lock_status_t cbm_version_cohort_daemon_claim_release( cbm_version_cohort_daemon_claim_t **claim_io); @@ -115,25 +121,21 @@ cbm_private_file_lock_status_t cbm_version_cohort_daemon_claim_release( * current daemon still holds its generation claim, including the cleanup * interval after its listener/lifetime reservation has closed. ABSENT means * the marker was acquired and released safely; UNSAFE/IO fail closed. */ -cbm_version_cohort_daemon_presence_t -cbm_version_cohort_daemon_claim_presence( +cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_claim_presence( cbm_version_cohort_manager_t *manager); /* Native marker ownership, never file existence, establishes a coordinated * daemon. A retained marker remains authoritative during final cleanup even * after listener/lifetime teardown. */ cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence( - cbm_version_cohort_manager_t *manager, - const cbm_daemon_ipc_endpoint_t *endpoint); + cbm_version_cohort_manager_t *manager, const cbm_daemon_ipc_endpoint_t *endpoint); /* Standalone CLI presence check under its successfully sealed and retained * startup transition. An absent daemon lifetime is authoritative because no * current or legacy daemon can start through that guard; an active lifetime * must still own the current-generation daemon marker to be coordinated. */ -cbm_version_cohort_daemon_presence_t -cbm_version_cohort_daemon_presence_under_transition( - cbm_version_cohort_manager_t *manager, - const cbm_daemon_ipc_endpoint_t *endpoint, +cbm_version_cohort_daemon_presence_t cbm_version_cohort_daemon_presence_under_transition( + cbm_version_cohort_manager_t *manager, const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_ipc_local_transition_t *transition); cbm_private_file_lock_status_t cbm_version_cohort_lease_release( @@ -149,7 +151,6 @@ bool cbm_version_cohort_log_conflict(const cbm_daemon_conflict_t *conflict); /* Persist the fail-closed migration case where the stable daemon reservation * is active but no current-generation coordination marker can be verified. */ -bool cbm_version_cohort_log_uncoordinated_daemon( - const cbm_daemon_build_identity_t *requested); +bool cbm_version_cohort_log_uncoordinated_daemon(const cbm_daemon_build_identity_t *requested); #endif /* CBM_DAEMON_VERSION_COHORT_H */ diff --git a/src/discover/discover.c b/src/discover/discover.c index 19d4a3190..c82a6bdf0 100644 --- a/src/discover/discover.c +++ b/src/discover/discover.c @@ -411,6 +411,12 @@ typedef struct { cbm_file_info_t *files; int count; int capacity; + int max_files; + uint64_t deadline_ms; + bool count_only; + bool collect_excluded; + bool limit_exceeded; + bool failed; /* Directories skipped during the walk (rel paths), so callers can surface * which subtrees were dropped (#411). strdup'd; freed by the caller via * cbm_discover_free_excluded or internally when not requested. */ @@ -428,14 +434,25 @@ typedef struct { int ignored_total; } file_list_t; +static bool file_list_should_stop(file_list_t *fl) { + if (!fl) { + return true; + } + if (!fl->failed && fl->deadline_ms != 0 && cbm_now_ms() >= fl->deadline_ms) { + fl->failed = true; + } + return fl->failed || fl->limit_exceeded; +} + static void file_list_add_excluded(file_list_t *fl, const char *rel_path) { - if (!rel_path || rel_path[0] == '\0') { + if (!fl->collect_excluded || !rel_path || rel_path[0] == '\0') { return; } if (fl->excluded_count >= fl->excluded_cap) { int new_cap = fl->excluded_cap ? fl->excluded_cap * PAIR_LEN : CBM_SZ_64; char **grown = realloc(fl->excluded, new_cap * sizeof(char *)); if (!grown) { + fl->failed = true; return; } fl->excluded = grown; @@ -443,6 +460,7 @@ static void file_list_add_excluded(file_list_t *fl, const char *rel_path) { } char *copy = strdup(rel_path); if (!copy) { + fl->failed = true; return; } fl->excluded[fl->excluded_count++] = copy; @@ -460,6 +478,7 @@ static void file_list_add_ignored(file_list_t *fl, const char *rel_path, const c int new_cap = fl->ignored_cap ? fl->ignored_cap * PAIR_LEN : CBM_SZ_64; cbm_ignored_file_t *grown = realloc(fl->ignored, new_cap * sizeof(cbm_ignored_file_t)); if (!grown) { + fl->failed = true; return; } fl->ignored = grown; @@ -470,6 +489,7 @@ static void file_list_add_ignored(file_list_t *fl, const char *rel_path, const c if (!path_copy || !reason_copy) { free(path_copy); free(reason_copy); + fl->failed = true; return; } fl->ignored[fl->ignored_count].rel_path = path_copy; @@ -479,19 +499,36 @@ static void file_list_add_ignored(file_list_t *fl, const char *rel_path, const c static void fl_add(file_list_t *fl, const char *abs_path, const char *rel_path, CBMLanguage lang, int64_t size) { + if (fl->max_files >= 0 && fl->count >= fl->max_files) { + fl->limit_exceeded = true; + return; + } + if (fl->count_only) { + fl->count++; + return; + } if (fl->count >= fl->capacity) { int new_cap = fl->capacity ? fl->capacity * PAIR_LEN : CBM_SZ_256; cbm_file_info_t *new_files = realloc(fl->files, new_cap * sizeof(cbm_file_info_t)); if (!new_files) { + fl->failed = true; return; } fl->files = new_files; fl->capacity = new_cap; } + char *path_copy = strdup(abs_path); + char *relative_copy = strdup(rel_path); + if (!path_copy || !relative_copy) { + free(path_copy); + free(relative_copy); + fl->failed = true; + return; + } cbm_file_info_t *fi = &fl->files[fl->count++]; - fi->path = strdup(abs_path); - fi->rel_path = strdup(rel_path); + fi->path = path_copy; + fi->rel_path = relative_copy; fi->language = lang; fi->size = size; } @@ -745,14 +782,25 @@ static cbm_gitignore_t *try_load_nested_gitignore(const walk_frame_t *frame) { /* Push a subdirectory onto the walk stack, inheriting local gitignore context. */ static void walk_push_subdir(walk_frame_t *stack, int *top, const char *abs_path, - const char *rel_path, const walk_frame_t *parent) { + const char *rel_path, const walk_frame_t *parent, file_list_t *out) { if (*top >= WALK_STACK_CAP) { + out->failed = true; + return; + } + int directory_length = snprintf(stack[*top].dir, CBM_SZ_4K, "%s", abs_path); + int prefix_length = snprintf(stack[*top].prefix, CBM_SZ_4K, "%s", rel_path); + if (directory_length <= 0 || directory_length >= CBM_SZ_4K || prefix_length < 0 || + prefix_length >= CBM_SZ_4K) { + out->failed = true; return; } - snprintf(stack[*top].dir, CBM_SZ_4K, "%s", abs_path); - snprintf(stack[*top].prefix, CBM_SZ_4K, "%s", rel_path); stack[*top].local_gi = parent->local_gi; - snprintf(stack[*top].local_gi_prefix, CBM_SZ_4K, "%s", parent->local_gi_prefix); + int local_prefix_length = + snprintf(stack[*top].local_gi_prefix, CBM_SZ_4K, "%s", parent->local_gi_prefix); + if (local_prefix_length < 0 || local_prefix_length >= CBM_SZ_4K) { + out->failed = true; + return; + } (*top)++; } @@ -764,22 +812,31 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram file_list_t *out) { char abs_path[CBM_SZ_4K]; char rel_path[CBM_SZ_4K]; - snprintf(abs_path, sizeof(abs_path), "%s/%s", frame->dir, entry->name); + int absolute_length = snprintf(abs_path, sizeof(abs_path), "%s/%s", frame->dir, entry->name); + int relative_length; if (frame->prefix[0] != '\0') { - snprintf(rel_path, sizeof(rel_path), "%s/%s", frame->prefix, entry->name); + relative_length = snprintf(rel_path, sizeof(rel_path), "%s/%s", frame->prefix, entry->name); } else { - snprintf(rel_path, sizeof(rel_path), "%s", entry->name); + relative_length = snprintf(rel_path, sizeof(rel_path), "%s", entry->name); + } + if (absolute_length <= 0 || (size_t)absolute_length >= sizeof(abs_path) || + relative_length <= 0 || (size_t)relative_length >= sizeof(rel_path)) { + out->failed = true; + return; } struct stat st; if (safe_stat(abs_path, &st) != 0) { + if (out->count_only) { + out->failed = true; + } return; } if (S_ISDIR(st.st_mode)) { if (!should_skip_directory(entry->name, rel_path, opts, gitignore, global_gi, cbmignore, frame->local_gi, frame->local_gi_prefix)) { - walk_push_subdir(stack, top, abs_path, rel_path, frame); + walk_push_subdir(stack, top, abs_path, rel_path, frame, out); } else { /* Record the excluded subtree root so callers can report it (#411). */ file_list_add_excluded(out, rel_path); @@ -790,52 +847,88 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram } } -enum { GI_OWNED_CAP = 64 }; +static bool walk_owned_gitignore_append(cbm_gitignore_t ***owned, size_t *count, size_t *capacity, + cbm_gitignore_t *gitignore) { + if (!owned || !count || !capacity || !gitignore) { + return false; + } + if (*count == *capacity) { + size_t next_capacity = *capacity == 0 ? 16U : *capacity * 2U; + if (next_capacity < *capacity || next_capacity > SIZE_MAX / sizeof(**owned)) { + return false; + } + cbm_gitignore_t **grown = realloc(*owned, next_capacity * sizeof(*grown)); + if (!grown) { + return false; + } + *owned = grown; + *capacity = next_capacity; + } + (*owned)[(*count)++] = gitignore; + return true; +} static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_discover_opts_t *opts, const cbm_gitignore_t *gitignore, const cbm_gitignore_t *global_gi, const cbm_gitignore_t *cbmignore, file_list_t *out) { walk_frame_t *stack = calloc(WALK_STACK_CAP, sizeof(walk_frame_t)); if (!stack) { + out->failed = true; return; } /* Collect all owned gitignores — freed at the end because child frames * on the stack hold borrowed pointers to them. */ - cbm_gitignore_t *owned_gis[GI_OWNED_CAP]; - int owned_count = 0; + cbm_gitignore_t **owned_gis = NULL; + size_t owned_count = 0; + size_t owned_capacity = 0; int top = 0; - snprintf(stack[top].dir, CBM_SZ_4K, "%s", dir_path); - snprintf(stack[top].prefix, CBM_SZ_4K, "%s", rel_prefix); + int initial_directory_length = snprintf(stack[top].dir, CBM_SZ_4K, "%s", dir_path); + int initial_prefix_length = snprintf(stack[top].prefix, CBM_SZ_4K, "%s", rel_prefix); + if (initial_directory_length <= 0 || initial_directory_length >= CBM_SZ_4K || + initial_prefix_length < 0 || initial_prefix_length >= CBM_SZ_4K) { + out->failed = true; + free(stack); + return; + } top++; - while (top > 0) { + while (top > 0 && !file_list_should_stop(out)) { walk_frame_t frame = stack[--top]; cbm_gitignore_t *loaded = try_load_nested_gitignore(&frame); if (loaded) { - frame.local_gi = loaded; - snprintf(frame.local_gi_prefix, sizeof(frame.local_gi_prefix), "%s", frame.prefix); - if (owned_count < GI_OWNED_CAP) { - owned_gis[owned_count++] = loaded; + int local_prefix_length = + snprintf(frame.local_gi_prefix, sizeof(frame.local_gi_prefix), "%s", frame.prefix); + if (local_prefix_length < 0 || + (size_t)local_prefix_length >= sizeof(frame.local_gi_prefix) || + !walk_owned_gitignore_append(&owned_gis, &owned_count, &owned_capacity, loaded)) { + cbm_gitignore_free(loaded); + out->failed = true; + break; } + frame.local_gi = loaded; } cbm_dir_t *d = cbm_opendir(frame.dir); if (!d) { + if (out->count_only) { + out->failed = true; + } continue; } cbm_dirent_t *entry; - while ((entry = cbm_readdir(d)) != NULL) { + while (!file_list_should_stop(out) && (entry = cbm_readdir(d)) != NULL) { walk_dir_process_entry(entry, &frame, opts, gitignore, global_gi, cbmignore, stack, &top, out); } cbm_closedir(d); } - for (int i = 0; i < owned_count; i++) { + for (size_t i = 0; i < owned_count; i++) { cbm_gitignore_free(owned_gis[i]); } + free(owned_gis); free(stack); } @@ -954,10 +1047,12 @@ int cbm_discover_ex(const char *repo_path, const cbm_discover_opts_t *opts, cbm_ NULL, NULL); } -int cbm_discover_ex2(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out, - int *count, char ***excluded_out, int *excluded_count_out, - cbm_ignored_file_t **ignored_out, int *ignored_count_out, - int *ignored_total_out) { +static cbm_discover_status_t discover_impl(const char *repo_path, const cbm_discover_opts_t *opts, + cbm_file_info_t **out, int *count, char ***excluded_out, + int *excluded_count_out, + cbm_ignored_file_t **ignored_out, int *ignored_count_out, + int *ignored_total_out, bool count_only, int max_files, + uint64_t deadline_ms) { if (excluded_out) { *excluded_out = NULL; } @@ -973,8 +1068,8 @@ int cbm_discover_ex2(const char *repo_path, const cbm_discover_opts_t *opts, cbm if (ignored_total_out) { *ignored_total_out = 0; } - if (!repo_path || !out || !count) { - return CBM_NOT_FOUND; + if (!repo_path || !out || !count || (count_only && max_files < 0)) { + return CBM_DISCOVER_ERROR; } *out = NULL; @@ -983,7 +1078,7 @@ int cbm_discover_ex2(const char *repo_path, const cbm_discover_opts_t *opts, cbm /* Verify directory exists */ struct stat st; if (wide_stat(repo_path, &st) != 0 || !S_ISDIR(st.st_mode)) { - return CBM_NOT_FOUND; + return CBM_DISCOVER_ERROR; } /* Load gitignore sources for ordinary repos AND linked worktrees. @@ -1044,8 +1139,13 @@ int cbm_discover_ex2(const char *repo_path, const cbm_discover_opts_t *opts, cbm } /* Walk */ - file_list_t fl = {0}; - fl.collect_ignored = ignored_out != NULL; + file_list_t fl = { + .max_files = count_only ? max_files : -1, + .deadline_ms = count_only ? deadline_ms : 0, + .count_only = count_only, + .collect_excluded = !count_only && excluded_out != NULL, + .collect_ignored = !count_only && ignored_out != NULL, + }; walk_dir(repo_path, "", opts, gitignore, global_gi, cbmignore, &fl); /* Cleanup */ @@ -1053,6 +1153,23 @@ int cbm_discover_ex2(const char *repo_path, const cbm_discover_opts_t *opts, cbm cbm_gitignore_free(global_gi); cbm_gitignore_free(cbmignore); + if (count_only) { + cbm_discover_free(fl.files, fl.count); + cbm_discover_free_excluded(fl.excluded, fl.excluded_count); + cbm_discover_free_ignored(fl.ignored, fl.ignored_count); + *count = fl.count; + if (fl.failed) { + return CBM_DISCOVER_ERROR; + } + return fl.limit_exceeded ? CBM_DISCOVER_LIMIT_EXCEEDED : CBM_DISCOVER_OK; + } + if (fl.failed) { + cbm_discover_free(fl.files, fl.count); + cbm_discover_free_excluded(fl.excluded, fl.excluded_count); + cbm_discover_free_ignored(fl.ignored, fl.ignored_count); + return CBM_DISCOVER_ERROR; + } + *out = fl.files; *count = fl.count; @@ -1078,7 +1195,33 @@ int cbm_discover_ex2(const char *repo_path, const cbm_discover_opts_t *opts, cbm } else { cbm_discover_free_ignored(fl.ignored, fl.ignored_count); } - return 0; + return CBM_DISCOVER_OK; +} + +int cbm_discover_ex2(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out, + int *count, char ***excluded_out, int *excluded_count_out, + cbm_ignored_file_t **ignored_out, int *ignored_count_out, + int *ignored_total_out) { + return discover_impl(repo_path, opts, out, count, excluded_out, excluded_count_out, ignored_out, + ignored_count_out, ignored_total_out, false, 0, 0); +} + +cbm_discover_status_t cbm_discover_count_bounded(const char *repo_path, + const cbm_discover_opts_t *opts, int max_files, + uint64_t deadline_ms, int *count_out) { + if (count_out) { + *count_out = -1; + } + if (!repo_path || !count_out || max_files < 0) { + return CBM_DISCOVER_ERROR; + } + cbm_file_info_t *files = NULL; + int count = 0; + cbm_discover_status_t status = discover_impl(repo_path, opts, &files, &count, NULL, NULL, NULL, + NULL, NULL, true, max_files, deadline_ms); + cbm_discover_free(files, count); + *count_out = status == CBM_DISCOVER_ERROR ? -1 : count; + return status; } void cbm_discover_free(cbm_file_info_t *files, int count) { diff --git a/src/discover/discover.h b/src/discover/discover.h index 0c0d8fc46..80db1a7e8 100644 --- a/src/discover/discover.h +++ b/src/discover/discover.h @@ -122,6 +122,12 @@ typedef struct { int64_t max_file_size; /* 0 = no limit */ } cbm_discover_opts_t; +typedef enum { + CBM_DISCOVER_ERROR = -1, + CBM_DISCOVER_OK = 0, + CBM_DISCOVER_LIMIT_EXCEEDED = 1, +} cbm_discover_status_t; + /* Walk a repository directory tree and discover all source files. * Applies hardcoded filters, gitignore patterns, and language detection. * Returns 0 on success, -1 on error. @@ -129,6 +135,15 @@ typedef struct { int cbm_discover(const char *repo_path, const cbm_discover_opts_t *opts, cbm_file_info_t **out, int *count); +/* Apply the exact same full discovery/filter policy without retaining a file + * array. Stops before counting more than max_files and performs no per-file + * allocation. deadline_ms is an absolute cbm_now_ms() deadline; zero disables + * it. Returns LIMIT_EXCEEDED when at least max_files + 1 indexable files exist, + * ERROR on traversal/deadline/allocation failure, or OK with the exact count. */ +cbm_discover_status_t cbm_discover_count_bounded(const char *repo_path, + const cbm_discover_opts_t *opts, int max_files, + uint64_t deadline_ms, int *count_out); + /* Like cbm_discover(), but also reports the directory subtrees that were * skipped during the walk (hardcoded ALWAYS_SKIP/FAST_SKIP dirs + gitignore * matches), so callers can surface which subtrees were dropped (#411). diff --git a/src/foundation/compat.c b/src/foundation/compat.c index 158191d47..e1bcff2f5 100644 --- a/src/foundation/compat.c +++ b/src/foundation/compat.c @@ -112,8 +112,7 @@ int cbm_mkstemp(char *tmpl) { } if (!_mktemp(buf)) return CBM_NOT_FOUND; - int fd = - _open(buf, _O_CREAT | _O_EXCL | _O_RDWR | _O_BINARY, _S_IREAD | _S_IWRITE); + int fd = _open(buf, _O_CREAT | _O_EXCL | _O_RDWR | _O_BINARY, _S_IREAD | _S_IWRITE); if (fd >= 0) strcpy(tmpl, buf); return fd; diff --git a/src/foundation/compat.h b/src/foundation/compat.h index 40f1ebf05..38886f0ad 100644 --- a/src/foundation/compat.h +++ b/src/foundation/compat.h @@ -8,6 +8,8 @@ #ifndef CBM_COMPAT_H #define CBM_COMPAT_H +#include +#include #include #include /* stdlib.h declares getenv (cbm_tmpdir) and, on Windows, _putenv_s (cbm_setenv/ @@ -123,10 +125,57 @@ int cbm_mkstemp(char *tmpl); #ifdef _WIN32 static inline int cbm_setenv(const char *name, const char *value, int overwrite) { (void)overwrite; - return _putenv_s(name, value); + if (!name || !value) { + return EINVAL; + } + int name_chars = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, name, -1, NULL, 0); + int value_chars = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, NULL, 0); + wchar_t *wide_name = + name_chars > 0 ? (wchar_t *)malloc((size_t)name_chars * sizeof(*wide_name)) : NULL; + wchar_t *wide_value = + value_chars > 0 ? (wchar_t *)malloc((size_t)value_chars * sizeof(*wide_value)) : NULL; + bool converted = + wide_name && wide_value && + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, name, -1, wide_name, name_chars) > 0 && + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, wide_value, value_chars) > 0; + if (!converted) { + free(wide_name); + free(wide_value); + return EINVAL; + } + /* Keep the CRT's narrow environment useful for legacy getenv callers, + * then repair the process-wide Windows environment with the actual UTF-16 + * value. _putenv_s alone routes UTF-8 path bytes through the active ANSI + * code page, which corrupts non-ASCII cache roots inherited by children. */ + int status = _putenv_s(name, value); + if (status == 0 && !SetEnvironmentVariableW(wide_name, wide_value)) { + status = EINVAL; + } + free(wide_name); + free(wide_value); + return status; } static inline int cbm_unsetenv(const char *name) { - return _putenv_s(name, ""); + if (!name) { + return EINVAL; + } + int name_chars = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, name, -1, NULL, 0); + wchar_t *wide_name = + name_chars > 0 ? (wchar_t *)malloc((size_t)name_chars * sizeof(*wide_name)) : NULL; + if (!wide_name || + MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, name, -1, wide_name, name_chars) <= 0) { + free(wide_name); + return EINVAL; + } + int status = _putenv_s(name, ""); + if (status == 0) { + SetLastError(ERROR_SUCCESS); + if (!SetEnvironmentVariableW(wide_name, NULL) && GetLastError() != ERROR_ENVVAR_NOT_FOUND) { + status = EINVAL; + } + } + free(wide_name); + return status; } #else #define cbm_setenv setenv diff --git a/src/foundation/compat_fs.c b/src/foundation/compat_fs.c index b86c23410..e62db8574 100644 --- a/src/foundation/compat_fs.c +++ b/src/foundation/compat_fs.c @@ -886,11 +886,9 @@ int cbm_rename_replace(const char *src, const char *dst) { wchar_t *wdst = cbm_utf8_to_wide(dst); int ret = CBM_NOT_FOUND; if (wsrc && wdst) { - ret = - MoveFileExW(wsrc, wdst, - MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) - ? 0 - : CBM_NOT_FOUND; + ret = MoveFileExW(wsrc, wdst, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) + ? 0 + : CBM_NOT_FOUND; } free(wsrc); free(wdst); diff --git a/src/foundation/lock_registry.c b/src/foundation/lock_registry.c index 20617bc27..1f70f1cba 100644 --- a/src/foundation/lock_registry.c +++ b/src/foundation/lock_registry.c @@ -150,18 +150,15 @@ static void lock_registry_broadcast_locked(cbm_lock_registry_t *registry) { * condition is associated with the global guard: release only the registry * mutex before the atomic guard-release-and-wait operation, then restore the * full G -> R lock order before returning. */ -static cbm_private_fork_wait_status_t lock_registry_wait_locked( - cbm_lock_registry_t *registry, uint64_t deadline_ms) { - (void)atomic_fetch_add_explicit(®istry->test_condition_wait_calls, 1, - memory_order_relaxed); - (void)atomic_fetch_add_explicit(®istry->test_condition_waiters_now, 1, - memory_order_relaxed); +static cbm_private_fork_wait_status_t lock_registry_wait_locked(cbm_lock_registry_t *registry, + uint64_t deadline_ms) { + (void)atomic_fetch_add_explicit(®istry->test_condition_wait_calls, 1, memory_order_relaxed); + (void)atomic_fetch_add_explicit(®istry->test_condition_waiters_now, 1, memory_order_relaxed); cbm_mutex_unlock(®istry->mutex); cbm_private_fork_wait_status_t status = cbm_private_fork_condition_wait_until_while_guarded(registry->condition, deadline_ms); cbm_mutex_lock(®istry->mutex); - (void)atomic_fetch_sub_explicit(®istry->test_condition_waiters_now, 1, - memory_order_relaxed); + (void)atomic_fetch_sub_explicit(®istry->test_condition_waiters_now, 1, memory_order_relaxed); return status; } @@ -344,8 +341,8 @@ cbm_lock_registry_t *cbm_lock_registry_new(cbm_private_lock_directory_t *directo return registry; } -cbm_private_file_lock_status_t cbm_lock_registry_request_cancel( - cbm_lock_registry_t *registry, cbm_lock_cancel_token_t *token) { +cbm_private_file_lock_status_t cbm_lock_registry_request_cancel(cbm_lock_registry_t *registry, + cbm_lock_cancel_token_t *token) { if (!token) { return CBM_PRIVATE_FILE_LOCK_IO; } @@ -439,8 +436,8 @@ static cbm_private_file_lock_status_t lock_registry_abort_attempt( static cbm_private_file_lock_status_t lock_registry_attempt_native( cbm_lock_registry_t *registry, lock_registry_entry_t *entry, lock_registry_waiter_t *waiter, - uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, - bool try_once, cbm_lock_lease_t *lease, cbm_lock_lease_t **lease_out) { + uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, bool try_once, + cbm_lock_lease_t *lease, cbm_lock_lease_t **lease_out) { cbm_private_file_lock_t *turn = NULL; cbm_private_file_lock_t *rw = NULL; cbm_private_file_lock_status_t status = CBM_PRIVATE_FILE_LOCK_BUSY; @@ -542,8 +539,7 @@ static cbm_private_file_lock_status_t lock_registry_attempt_native( rw = NULL; lock_registry_broadcast_locked(registry); } else if (!try_once && owns_attempt && - (!cancel_token || - !atomic_load_explicit(cancel_token, memory_order_acquire))) { + (!cancel_token || !atomic_load_explicit(cancel_token, memory_order_acquire))) { wait_status = lock_registry_wait_locked(registry, deadline_ms); } lock_registry_unlock(registry); @@ -552,9 +548,8 @@ static cbm_private_file_lock_status_t lock_registry_attempt_native( return CBM_PRIVATE_FILE_LOCK_OK; } if (try_once) { - return lock_registry_abort_attempt( - registry, entry, waiter, &rw, &turn, - CBM_PRIVATE_FILE_LOCK_BUSY, lease, lease_out); + return lock_registry_abort_attempt(registry, entry, waiter, &rw, &turn, + CBM_PRIVATE_FILE_LOCK_BUSY, lease, lease_out); } if (!owns_attempt) { return lock_registry_abort_attempt(registry, entry, waiter, &rw, &turn, @@ -569,8 +564,8 @@ static cbm_private_file_lock_status_t lock_registry_attempt_native( static cbm_private_file_lock_status_t lock_registry_acquire_internal( cbm_lock_registry_t *registry, const char *resource_key, cbm_private_file_lock_mode_t mode, - uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, - bool try_once, cbm_lock_lease_t **lease_out) { + uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, bool try_once, + cbm_lock_lease_t **lease_out) { if (lease_out) { *lease_out = NULL; } @@ -621,8 +616,7 @@ static cbm_private_file_lock_status_t lock_registry_acquire_internal( free(candidate); for (;;) { - bool should_stop = - !try_once && lock_registry_should_stop(deadline_ms, cancel_token); + bool should_stop = !try_once && lock_registry_should_stop(deadline_ms, cancel_token); if (!lock_registry_lock(registry)) { lease->cleanup_only = true; lease->waiter_cleanup_pending = true; @@ -646,8 +640,7 @@ static cbm_private_file_lock_status_t lock_registry_acquire_internal( if (can_attempt) { entry->attempt_waiter = &lease->waiter; } else if (try_once) { - bool removed = - lock_registry_waiter_remove(registry, entry, &lease->waiter); + bool removed = lock_registry_waiter_remove(registry, entry, &lease->waiter); lock_registry_unlock(registry); if (removed) { free(lease); @@ -657,8 +650,7 @@ static cbm_private_file_lock_status_t lock_registry_acquire_internal( lease->waiter_cleanup_pending = true; *lease_out = lease; return CBM_PRIVATE_FILE_LOCK_IO; - } else if (!cancel_token || - !atomic_load_explicit(cancel_token, memory_order_acquire)) { + } else if (!cancel_token || !atomic_load_explicit(cancel_token, memory_order_acquire)) { wait_status = lock_registry_wait_locked(registry, deadline_ms); } lock_registry_unlock(registry); @@ -676,20 +668,19 @@ static cbm_private_file_lock_status_t lock_registry_acquire_internal( } cbm_private_file_lock_status_t cbm_lock_registry_acquire( - cbm_lock_registry_t *registry, const char *resource_key, - cbm_private_file_lock_mode_t mode, uint64_t deadline_ms, - const cbm_lock_cancel_token_t *cancel_token, + cbm_lock_registry_t *registry, const char *resource_key, cbm_private_file_lock_mode_t mode, + uint64_t deadline_ms, const cbm_lock_cancel_token_t *cancel_token, cbm_lock_lease_t **lease_out) { - return lock_registry_acquire_internal(registry, resource_key, mode, - deadline_ms, cancel_token, false, - lease_out); + return lock_registry_acquire_internal(registry, resource_key, mode, deadline_ms, cancel_token, + false, lease_out); } -cbm_private_file_lock_status_t cbm_lock_registry_try_acquire( - cbm_lock_registry_t *registry, const char *resource_key, - cbm_private_file_lock_mode_t mode, cbm_lock_lease_t **lease_out) { - return lock_registry_acquire_internal(registry, resource_key, mode, - UINT64_MAX, NULL, true, lease_out); +cbm_private_file_lock_status_t cbm_lock_registry_try_acquire(cbm_lock_registry_t *registry, + const char *resource_key, + cbm_private_file_lock_mode_t mode, + cbm_lock_lease_t **lease_out) { + return lock_registry_acquire_internal(registry, resource_key, mode, UINT64_MAX, NULL, true, + lease_out); } static cbm_private_file_lock_status_t lock_registry_cleanup_lease_release( @@ -883,10 +874,10 @@ cbm_private_file_lock_status_t cbm_lock_registry_free(cbm_lock_registry_t **regi cbm_private_file_lock_fork_guard_leave(); return CBM_PRIVATE_FILE_LOCK_IO; } - bool idle = registry->waiter_count == 0 && registry->active_lease_count == 0 && - registry->pending_cleanup_count == 0 && - atomic_load_explicit(®istry->test_condition_waiters_now, - memory_order_relaxed) == 0; + bool idle = + registry->waiter_count == 0 && registry->active_lease_count == 0 && + registry->pending_cleanup_count == 0 && + atomic_load_explicit(®istry->test_condition_waiters_now, memory_order_relaxed) == 0; for (lock_registry_entry_t *entry = registry->entries; idle && entry; entry = entry->next) { idle = !entry->waiter_head && !entry->waiter_tail && !entry->attempt_waiter && entry->active_readers == 0 && !entry->writer_active; @@ -988,17 +979,16 @@ size_t cbm_lock_registry_attempting_waiter_count_for_test(cbm_lock_registry_t *r return count; } -uint64_t cbm_lock_registry_condition_wait_call_count_for_test( - const cbm_lock_registry_t *registry) { - return registry ? atomic_load_explicit(®istry->test_condition_wait_calls, - memory_order_relaxed) - : 0; +uint64_t cbm_lock_registry_condition_wait_call_count_for_test(const cbm_lock_registry_t *registry) { + return registry + ? atomic_load_explicit(®istry->test_condition_wait_calls, memory_order_relaxed) + : 0; } size_t cbm_lock_registry_condition_waiter_count_for_test(const cbm_lock_registry_t *registry) { - return registry ? atomic_load_explicit(®istry->test_condition_waiters_now, - memory_order_relaxed) - : 0; + return registry + ? atomic_load_explicit(®istry->test_condition_waiters_now, memory_order_relaxed) + : 0; } bool cbm_lock_lease_fail_next_release_step_for_test(cbm_lock_lease_t *lease, diff --git a/src/foundation/lock_registry.h b/src/foundation/lock_registry.h index fdf0b2d39..ae5b6e04d 100644 --- a/src/foundation/lock_registry.h +++ b/src/foundation/lock_registry.h @@ -21,8 +21,8 @@ cbm_lock_registry_t *cbm_lock_registry_new(cbm_private_lock_directory_t *directo /* Sticky cancellation: stores true with release ordering and wakes registry * waiters. The token must outlive every acquisition that observes it. */ -cbm_private_file_lock_status_t cbm_lock_registry_request_cancel( - cbm_lock_registry_t *registry, cbm_lock_cancel_token_t *token); +cbm_private_file_lock_status_t cbm_lock_registry_request_cancel(cbm_lock_registry_t *registry, + cbm_lock_cancel_token_t *token); /* The directory is borrowed and must outlive the registry and every lease. * Resource keys must come from a bounded internal namespace: lock sidecars are @@ -45,9 +45,10 @@ cbm_private_file_lock_status_t cbm_lock_registry_acquire( /* Make one fair process-local/native acquisition attempt without parking. * A free lock can succeed; existing queued/native contention returns BUSY. * Cleanup-only lease semantics are identical to the waiting API. */ -cbm_private_file_lock_status_t cbm_lock_registry_try_acquire( - cbm_lock_registry_t *registry, const char *resource_key, - cbm_private_file_lock_mode_t mode, cbm_lock_lease_t **lease_out); +cbm_private_file_lock_status_t cbm_lock_registry_try_acquire(cbm_lock_registry_t *registry, + const char *resource_key, + cbm_private_file_lock_mode_t mode, + cbm_lock_lease_t **lease_out); /* Writers release .rw before .turn. Readers retain only .rw. The same release * call disposes cleanup-only leases returned by failed acquisition rollback, diff --git a/src/foundation/lock_registry_internal.h b/src/foundation/lock_registry_internal.h index dd69ea4ae..0b35712d0 100644 --- a/src/foundation/lock_registry_internal.h +++ b/src/foundation/lock_registry_internal.h @@ -23,8 +23,7 @@ size_t cbm_lock_registry_active_lease_count_for_test(cbm_lock_registry_t *regist size_t cbm_lock_registry_pending_cleanup_count_for_test(cbm_lock_registry_t *registry); bool cbm_lock_registry_is_retired_for_test(const cbm_lock_registry_t *registry); size_t cbm_lock_registry_attempting_waiter_count_for_test(cbm_lock_registry_t *registry); -uint64_t cbm_lock_registry_condition_wait_call_count_for_test( - const cbm_lock_registry_t *registry); +uint64_t cbm_lock_registry_condition_wait_call_count_for_test(const cbm_lock_registry_t *registry); size_t cbm_lock_registry_condition_waiter_count_for_test(const cbm_lock_registry_t *registry); typedef enum { diff --git a/src/foundation/macos_acl.c b/src/foundation/macos_acl.c index 13dc0bcfe..6467d5b85 100644 --- a/src/foundation/macos_acl.c +++ b/src/foundation/macos_acl.c @@ -34,6 +34,46 @@ bool cbm_macos_extended_acl_fd_is_empty(int fd) { return entry_result == -1 && entry_error == EINVAL && free_result == 0; } +bool cbm_macos_extended_acl_fd_is_deny_only(int fd) { + if (fd < 0) { + return false; + } + + errno = 0; + acl_t acl = acl_get_fd_np(fd, ACL_TYPE_EXTENDED); + if (!acl) { + /* As with the empty predicate, ENOENT is Darwin's ACL-less result on + * some filesystems. Retrieval errors other than ENOENT are unsafe. */ + return errno == ENOENT; + } + + bool safe = true; + acl_entry_t entry = NULL; + int entry_id = ACL_FIRST_ENTRY; + for (;;) { + errno = 0; + int entry_result = acl_get_entry(acl, entry_id, &entry); + int entry_error = errno; + if (entry_result == -1) { + safe = entry_error == EINVAL; + break; + } + if (entry_result != 0 || !entry) { + safe = false; + break; + } + acl_tag_t tag = (acl_tag_t)0; + if (acl_get_tag_type(entry, &tag) != 0 || tag != ACL_EXTENDED_DENY) { + safe = false; + break; + } + entry_id = ACL_NEXT_ENTRY; + } + + int free_result = acl_free(acl); + return safe && free_result == 0; +} + bool cbm_macos_extended_acl_fd_clear(int fd) { if (fd < 0) { return false; @@ -53,6 +93,10 @@ bool cbm_macos_extended_acl_fd_is_empty(int fd) { return fd >= 0; } +bool cbm_macos_extended_acl_fd_is_deny_only(int fd) { + return fd >= 0; +} + bool cbm_macos_extended_acl_fd_clear(int fd) { return fd >= 0; } diff --git a/src/foundation/macos_acl.h b/src/foundation/macos_acl.h index 94fac0b4e..6277decc8 100644 --- a/src/foundation/macos_acl.h +++ b/src/foundation/macos_acl.h @@ -8,6 +8,12 @@ * platforms have no Darwin extended ACL surface, so a valid fd is sufficient. */ bool cbm_macos_extended_acl_fd_is_empty(int fd); +/* On macOS, succeeds when fd has no extended ACL or every extended ACL entry + * is an explicit DENY entry. This is the ancestor-directory predicate: deny + * entries cannot grant another account mutation authority, while any ALLOW or + * unrecognized entry fails closed. Other platforms only validate fd. */ +bool cbm_macos_extended_acl_fd_is_deny_only(int fd); + /* On macOS, replaces fd's extended ACL with an empty ACL and verifies the * result through the same anchored descriptor. Other platforms are a no-op. */ bool cbm_macos_extended_acl_fd_clear(int fd); diff --git a/src/foundation/mem.c b/src/foundation/mem.c index 1169d1734..042fbd953 100644 --- a/src/foundation/mem.c +++ b/src/foundation/mem.c @@ -175,8 +175,7 @@ cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction, } cbm_mem_budget_t cbm_mem_resolve_budget_capped(size_t total_ram, double ram_fraction, - const char *budget_mb, - size_t hard_cap_bytes) { + const char *budget_mb, size_t hard_cap_bytes) { cbm_mem_budget_t result = cbm_mem_resolve_budget(total_ram, ram_fraction, budget_mb); if (hard_cap_bytes > 0 && (result.budget == 0 || result.budget > hard_cap_bytes)) { result.budget = hard_cap_bytes; diff --git a/src/foundation/mem.h b/src/foundation/mem.h index 58362acd9..58856f518 100644 --- a/src/foundation/mem.h +++ b/src/foundation/mem.h @@ -48,8 +48,7 @@ cbm_mem_budget_t cbm_mem_resolve_budget(size_t total_ram, double ram_fraction, /* Pure capped variant used by supervised workers and deterministic tests. */ cbm_mem_budget_t cbm_mem_resolve_budget_capped(size_t total_ram, double ram_fraction, - const char *budget_mb, - size_t hard_cap_bytes); + const char *budget_mb, size_t hard_cap_bytes); /* Current RSS in bytes via mi_process_info(). * Falls back to OS-specific queries when MI_OVERRIDE=0 (ASan builds). */ diff --git a/src/foundation/platform.c b/src/foundation/platform.c index a1e4b10c6..9d69b0c99 100644 --- a/src/foundation/platform.c +++ b/src/foundation/platform.c @@ -355,7 +355,24 @@ extern char **environ; #define CBM_ENVIRON environ #endif +static const char *platform_copy_environment_value(char *buf, size_t buf_sz, const char *value) { + if (!buf || buf_sz == 0 || !value) { + return NULL; + } + size_t length = strlen(value); + if (length >= buf_sz) { + buf[0] = '\0'; + return NULL; + } + memcpy(buf, value, length + 1U); + return buf; +} + const char *cbm_safe_getenv(const char *name, char *buf, size_t buf_sz, const char *fallback) { + if (!name || !name[0] || !buf || buf_sz == 0) { + return NULL; + } + buf[0] = '\0'; #ifdef _WIN32 /* #996 Layer 2: _environ holds ANSI-code-page bytes, NOT UTF-8. A * non-ASCII value (USERPROFILE of C:\Users\Kovács János, or a Greek/CJK @@ -367,46 +384,54 @@ const char *cbm_safe_getenv(const char *name, char *buf, size_t buf_sz, const ch * SQLite VFS) already assumes. */ { wchar_t wname[CBM_SZ_256]; - int wn = MultiByteToWideChar(CP_UTF8, 0, name, -1, wname, CBM_SZ_256); + int wn = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, name, -1, wname, CBM_SZ_256); if (wn > 0) { - wchar_t wval[CBM_SZ_2K]; - DWORD got = GetEnvironmentVariableW(wname, wval, CBM_SZ_2K); - if (got > 0 && got < CBM_SZ_2K) { - char *utf8 = cbm_wide_to_utf8(wval); - if (utf8) { - snprintf(buf, buf_sz, "%s", utf8); - free(utf8); - return buf; + SetLastError(ERROR_SUCCESS); + DWORD needed = GetEnvironmentVariableW(wname, NULL, 0U); + DWORD environment_error = GetLastError(); + if (needed == 0U) { + if (environment_error == ERROR_ENVVAR_NOT_FOUND) { + return fallback ? platform_copy_environment_value(buf, buf_sz, fallback) : NULL; } + /* An existing empty variable is distinct from a missing one. */ + return buf; } - if (got == 0 && GetLastError() == ERROR_ENVVAR_NOT_FOUND) { - if (fallback) { - snprintf(buf, buf_sz, "%s", fallback); - return buf; - } - buf[0] = '\0'; + wchar_t *wval = calloc((size_t)needed, sizeof(*wval)); + if (!wval) { + return NULL; + } + SetLastError(ERROR_SUCCESS); + DWORD got = GetEnvironmentVariableW(wname, wval, needed); + DWORD read_error = GetLastError(); + if (got >= needed || (got == 0U && read_error != ERROR_SUCCESS)) { + free(wval); + return NULL; + } + char *utf8 = cbm_wide_to_utf8(wval); + free(wval); + if (!utf8) { return NULL; } + const char *copied = platform_copy_environment_value(buf, buf_sz, utf8); + free(utf8); + return copied; } - /* Conversion trouble (oversized value, allocation) — fall through to - * the ANSI scan below rather than failing outright. */ + return NULL; } -#endif +#else char **env = CBM_ENVIRON; if (env) { size_t nlen = strlen(name); for (; *env; env++) { if (strncmp(*env, name, nlen) == 0 && (*env)[nlen] == '=') { - snprintf(buf, buf_sz, "%s", *env + nlen + SKIP_ONE); - return buf; + return platform_copy_environment_value(buf, buf_sz, *env + nlen + SKIP_ONE); } } } +#endif if (fallback) { - snprintf(buf, buf_sz, "%s", fallback); - return buf; + return platform_copy_environment_value(buf, buf_sz, fallback); } - buf[0] = '\0'; return NULL; } @@ -490,11 +515,16 @@ const char *cbm_app_local_dir(void) { /* ── Cache directory ────────────────────────── */ const char *cbm_resolve_cache_dir(void) { - static CBM_TLS char buf[CBM_SZ_1K]; - char tmp[CBM_SZ_256] = ""; - cbm_safe_getenv("CBM_CACHE_DIR", tmp, sizeof(tmp), NULL); - if (tmp[0]) { - snprintf(buf, sizeof(buf), "%s", tmp); + static CBM_TLS char buf[CBM_SZ_4K]; + static const char missing[] = "\x1f" + "CBM_CACHE_DIR_MISSING" + "\x1f"; + const char *configured = cbm_safe_getenv("CBM_CACHE_DIR", buf, sizeof(buf), missing); + if (!configured) { + /* Present but not representable in the product path bound. */ + return NULL; + } + if (strcmp(configured, missing) != 0 && configured[0]) { cbm_normalize_path_sep(buf); return buf; } @@ -502,6 +532,10 @@ const char *cbm_resolve_cache_dir(void) { if (!home) { return NULL; } - snprintf(buf, sizeof(buf), "%s/.cache/codebase-memory-mcp", home); + int written = snprintf(buf, sizeof(buf), "%s/.cache/codebase-memory-mcp", home); + if (written <= 0 || (size_t)written >= sizeof(buf)) { + buf[0] = '\0'; + return NULL; + } return buf; } diff --git a/src/foundation/private_file_lock.c b/src/foundation/private_file_lock.c index ce8c40ac0..c8251354d 100644 --- a/src/foundation/private_file_lock.c +++ b/src/foundation/private_file_lock.c @@ -128,8 +128,7 @@ void cbm_private_fork_condition_free(cbm_private_fork_condition_t *condition) { free(condition); } -void cbm_private_fork_condition_broadcast_while_guarded( - cbm_private_fork_condition_t *condition) { +void cbm_private_fork_condition_broadcast_while_guarded(cbm_private_fork_condition_t *condition) { if (condition) { (void)pthread_cond_broadcast(&condition->value); } @@ -150,8 +149,8 @@ cbm_private_fork_wait_status_t cbm_private_fork_condition_wait_until_while_guard uint64_t remaining_ms = deadline_ms > now_ms ? deadline_ms - now_ms : 0; timeout.tv_sec = (time_t)(remaining_ms / 1000U); timeout.tv_nsec = (long)((remaining_ms % 1000U) * 1000000U); - status = pthread_cond_timedwait_relative_np(&condition->value, &private_fork_mutex, - &timeout); + status = + pthread_cond_timedwait_relative_np(&condition->value, &private_fork_mutex, &timeout); #else timeout.tv_sec = (time_t)(deadline_ms / 1000U); timeout.tv_nsec = (long)((deadline_ms % 1000U) * 1000000U); @@ -276,8 +275,7 @@ cbm_private_file_lock_status_t cbm_private_lock_directory_adopt_posix( !S_ISDIR(status.st_mode) || !S_ISDIR(by_path.st_mode) || status.st_uid != geteuid() || by_path.st_uid != geteuid() || (status.st_mode & 07777) != 0700 || (by_path.st_mode & 07777) != 0700 || status.st_dev != by_path.st_dev || - status.st_ino != by_path.st_ino || - !cbm_macos_extended_acl_fd_is_empty(directory_fd)) { + status.st_ino != by_path.st_ino || !cbm_macos_extended_acl_fd_is_empty(directory_fd)) { return CBM_PRIVATE_FILE_LOCK_UNSAFE; } cbm_private_lock_directory_t *directory = calloc(1, sizeof(*directory)); @@ -392,13 +390,11 @@ static bool private_lock_is_tracked(const cbm_private_file_lock_t *lock) { return false; } -static bool private_payload_fd_valid(const cbm_private_file_lock_t *lock, - struct stat *status_out) { +static bool private_payload_fd_valid(const cbm_private_file_lock_t *lock, struct stat *status_out) { struct stat status; - bool valid = lock && lock->fd >= 0 && !lock->unlocked && - lock->owner_pid == getpid() && private_lock_is_tracked(lock) && - fstat(lock->fd, &status) == 0 && S_ISREG(status.st_mode) && - status.st_uid == geteuid() && status.st_nlink == 1 && + bool valid = lock && lock->fd >= 0 && !lock->unlocked && lock->owner_pid == getpid() && + private_lock_is_tracked(lock) && fstat(lock->fd, &status) == 0 && + S_ISREG(status.st_mode) && status.st_uid == geteuid() && status.st_nlink == 1 && (status.st_mode & 07777) == 0600 && status.st_size >= 0 && cbm_macos_extended_acl_fd_is_empty(lock->fd); if (valid && status_out) { @@ -407,9 +403,9 @@ static bool private_payload_fd_valid(const cbm_private_file_lock_t *lock, return valid; } -cbm_private_file_lock_status_t cbm_private_file_lock_payload_read( - cbm_private_file_lock_t *lock, void *buffer, size_t capacity, - size_t *length_out) { +cbm_private_file_lock_status_t cbm_private_file_lock_payload_read(cbm_private_file_lock_t *lock, + void *buffer, size_t capacity, + size_t *length_out) { if (length_out) { *length_out = 0; } @@ -421,14 +417,13 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_read( } struct stat status; bool metadata_safe = private_payload_fd_valid(lock, &status); - bool valid = metadata_safe && - (uintmax_t)status.st_size <= PRIVATE_FILE_LOCK_PAYLOAD_CAP && + bool valid = metadata_safe && (uintmax_t)status.st_size <= PRIVATE_FILE_LOCK_PAYLOAD_CAP && (uintmax_t)status.st_size <= capacity; size_t length = valid ? (size_t)status.st_size : 0; size_t offset = 0; while (valid && offset < length) { - ssize_t count = pread(lock->fd, (unsigned char *)buffer + offset, - length - offset, (off_t)offset); + ssize_t count = + pread(lock->fd, (unsigned char *)buffer + offset, length - offset, (off_t)offset); if (count > 0) { offset += (size_t)count; } else if (count < 0 && errno == EINTR) { @@ -448,10 +443,10 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_read( return CBM_PRIVATE_FILE_LOCK_OK; } -cbm_private_file_lock_status_t cbm_private_file_lock_payload_write( - cbm_private_file_lock_t *lock, const void *buffer, size_t length) { - if (!lock || !buffer || length == 0 || - length > PRIVATE_FILE_LOCK_PAYLOAD_CAP || +cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_file_lock_t *lock, + const void *buffer, + size_t length) { + if (!lock || !buffer || length == 0 || length > PRIVATE_FILE_LOCK_PAYLOAD_CAP || lock->mode != CBM_PRIVATE_FILE_LOCK_EX) { return CBM_PRIVATE_FILE_LOCK_UNSAFE; } @@ -462,9 +457,8 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write( bool valid = metadata_safe && ftruncate(lock->fd, 0) == 0; size_t offset = 0; while (valid && offset < length) { - ssize_t count = pwrite(lock->fd, - (const unsigned char *)buffer + offset, - length - offset, (off_t)offset); + ssize_t count = pwrite(lock->fd, (const unsigned char *)buffer + offset, length - offset, + (off_t)offset); if (count > 0) { offset += (size_t)count; } else if (count < 0 && errno == EINTR) { @@ -1376,13 +1370,12 @@ static bool private_win_payload_handle_valid(const cbm_private_file_lock_t *lock GetFileInformationByHandle(lock->handle, &information) != 0 && (information.dwFileAttributes & (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && - information.nNumberOfLinks == 1 && - private_win_handle_is_noninheritable(lock->handle); + information.nNumberOfLinks == 1 && private_win_handle_is_noninheritable(lock->handle); } -cbm_private_file_lock_status_t cbm_private_file_lock_payload_read( - cbm_private_file_lock_t *lock, void *buffer, size_t capacity, - size_t *length_out) { +cbm_private_file_lock_status_t cbm_private_file_lock_payload_read(cbm_private_file_lock_t *lock, + void *buffer, size_t capacity, + size_t *length_out) { if (length_out) { *length_out = 0; } @@ -1405,8 +1398,9 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_read( while (valid && offset < length) { DWORD chunk = (DWORD)(length - offset); DWORD count = 0; - valid = ReadFile(lock->handle, (unsigned char *)buffer + offset, chunk, - &count, NULL) != 0 && count > 0; + valid = + ReadFile(lock->handle, (unsigned char *)buffer + offset, chunk, &count, NULL) != 0 && + count > 0; offset += valid ? (size_t)count : 0; } cbm_private_file_lock_fork_guard_leave(); @@ -1417,10 +1411,10 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_read( return CBM_PRIVATE_FILE_LOCK_OK; } -cbm_private_file_lock_status_t cbm_private_file_lock_payload_write( - cbm_private_file_lock_t *lock, const void *buffer, size_t length) { - if (!lock || !buffer || length == 0 || - length > PRIVATE_FILE_LOCK_PAYLOAD_CAP || +cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_file_lock_t *lock, + const void *buffer, + size_t length) { + if (!lock || !buffer || length == 0 || length > PRIVATE_FILE_LOCK_PAYLOAD_CAP || lock->mode != CBM_PRIVATE_FILE_LOCK_EX) { return CBM_PRIVATE_FILE_LOCK_UNSAFE; } @@ -1436,9 +1430,9 @@ cbm_private_file_lock_status_t cbm_private_file_lock_payload_write( while (valid && offset < length) { DWORD chunk = (DWORD)(length - offset); DWORD count = 0; - valid = WriteFile(lock->handle, - (const unsigned char *)buffer + offset, chunk, &count, - NULL) != 0 && count > 0; + valid = WriteFile(lock->handle, (const unsigned char *)buffer + offset, chunk, &count, + NULL) != 0 && + count > 0; offset += valid ? (size_t)count : 0; } valid = valid && FlushFileBuffers(lock->handle) != 0; @@ -1596,8 +1590,7 @@ void cbm_private_fork_condition_free(cbm_private_fork_condition_t *condition) { free(condition); } -void cbm_private_fork_condition_broadcast_while_guarded( - cbm_private_fork_condition_t *condition) { +void cbm_private_fork_condition_broadcast_while_guarded(cbm_private_fork_condition_t *condition) { if (condition) { WakeAllConditionVariable(&condition->value); } diff --git a/src/foundation/private_file_lock_internal.h b/src/foundation/private_file_lock_internal.h index 5ea81bb5e..8801d9b1a 100644 --- a/src/foundation/private_file_lock_internal.h +++ b/src/foundation/private_file_lock_internal.h @@ -20,8 +20,7 @@ typedef enum { * releases that guard and always reacquires it before returning. */ cbm_private_fork_condition_t *cbm_private_fork_condition_new(void); void cbm_private_fork_condition_free(cbm_private_fork_condition_t *condition); -void cbm_private_fork_condition_broadcast_while_guarded( - cbm_private_fork_condition_t *condition); +void cbm_private_fork_condition_broadcast_while_guarded(cbm_private_fork_condition_t *condition); cbm_private_fork_wait_status_t cbm_private_fork_condition_wait_until_while_guarded( cbm_private_fork_condition_t *condition, uint64_t deadline_ms); @@ -36,6 +35,7 @@ cbm_private_file_lock_status_t cbm_private_lock_directory_adopt_windows( * CloseHandle cleanup attempt fail before invocation. */ bool cbm_private_lock_directory_fail_lock_attempt_cleanup_for_test( cbm_private_lock_directory_t *directory); + #else /* On success ownership of directory_fd transfers to the returned object. On * failure the caller still owns it. stable_path must name that exact handle. */ @@ -54,11 +54,12 @@ bool cbm_private_file_lock_unlock_complete(const cbm_private_file_lock_t *lock); * Reads require SH or EX ownership; writes require EX ownership. The handle * validated at acquisition remains the authority, so callers never reopen a * user-controlled path. Payloads are capped at 4096 bytes. */ -cbm_private_file_lock_status_t cbm_private_file_lock_payload_read( - cbm_private_file_lock_t *lock, void *buffer, size_t capacity, - size_t *length_out); -cbm_private_file_lock_status_t cbm_private_file_lock_payload_write( - cbm_private_file_lock_t *lock, const void *buffer, size_t length); +cbm_private_file_lock_status_t cbm_private_file_lock_payload_read(cbm_private_file_lock_t *lock, + void *buffer, size_t capacity, + size_t *length_out); +cbm_private_file_lock_status_t cbm_private_file_lock_payload_write(cbm_private_file_lock_t *lock, + const void *buffer, + size_t length); /* Forces the next successfully acquired native lock down the post-lock * validation cleanup path and injects pre-call release failures there. */ diff --git a/src/foundation/subprocess.c b/src/foundation/subprocess.c index a62bd8aca..36eb38c9b 100644 --- a/src/foundation/subprocess.c +++ b/src/foundation/subprocess.c @@ -734,7 +734,10 @@ static void cbm_posix_child_exec(cbm_subprocess_t *process, int input, int outpu for (int fd = STDERR_FILENO + 1; fd < max_fd; fd++) { (void)close(fd); } - execv(process->bin, process->argv); + /* A fixed literal tool name (for example "git" or "curl") uses the + * caller's normal PATH without introducing a shell. An explicit path + * still has execvp's exact-path semantics because it contains '/'. */ + execvp(process->bin, process->argv); _exit(127); } diff --git a/src/foundation/subprocess.h b/src/foundation/subprocess.h index 266462b68..b3900b660 100644 --- a/src/foundation/subprocess.h +++ b/src/foundation/subprocess.h @@ -49,7 +49,8 @@ typedef struct { typedef void (*cbm_proc_log_cb)(const char *line, void *ud); typedef struct { - const char *bin; /* executable path; also argv[0] when argv is NULL */ + const char *bin; /* executable path or literal PATH name; + * also argv[0] when argv is NULL */ const char *const *argv; /* NULL-terminated argv; NULL => { bin, NULL } */ const char *log_file; /* child stdout+stderr are redirected here and tailed; * NULL => discard child output, no tailing */ diff --git a/src/launcher/windows_launcher.c b/src/launcher/windows_launcher.c new file mode 100644 index 000000000..4b5c09b48 --- /dev/null +++ b/src/launcher/windows_launcher.c @@ -0,0 +1,1140 @@ +/* + * windows_launcher.c -- Permanent, self-relative Windows CBM launcher. + * + * This executable intentionally stays tiny. It owns no indexer or config + * state: it validates one fixed current-v1 pointer and contains the selected + * payload in a kill-on-close job while relaying stdio and the exact exit code. + */ +#include "cli/windows_launcher_state.h" + +#ifndef _WIN32 + +#include + +int main(void) { + (void)fprintf(stderr, "codebase-memory-mcp-launcher is available only on Windows\n"); + return 1; +} + +#else + +#ifndef _WIN32_WINNT +#define _WIN32_WINNT 0x0A00 +#endif +#define WIN32_LEAN_AND_MEAN +#include +#include +#include +#include +#include + +#ifndef PIPE_REJECT_REMOTE_CLIENTS +#define PIPE_REJECT_REMOTE_CLIENTS 0x00000008 +#endif + +#include +#include +#include +#include +#include +#include +#include + +#define LAUNCHER_CONTEXT_ENV L"CBM_WINDOWS_LAUNCH_CONTEXT_HANDLE_V1" +#define LAUNCHER_CONTEXT_HEADER_SIZE 128U +#define LAUNCHER_CONTEXT_MANAGED 0x00000001U +#define LAUNCHER_CONTEXT_PRIVATE 0x00000002U +#define LAUNCHER_PIPE_BUFFER (128U * 1024U) +#define LAUNCHER_PROBE_ARG L"__cbm_launcher_capability_probe_v1" +#define LAUNCHER_RELEASE_DESCRIPTOR_ARG L"__cbm_windows_release_descriptor_v1" +#define PAYLOAD_RELEASE_DESCRIPTOR_ARG L"__cbm_windows_payload_descriptor_v1" + +static const uint8_t launcher_context_magic[8] = { + 'C', 'B', 'M', 'L', 'C', 'T', '1', '\0', +}; + +typedef struct { + DWORD Flags; +} launcher_disposition_info_ex_t; + +#define LAUNCHER_FILE_DISPOSITION_INFO_EX ((FILE_INFO_BY_HANDLE_CLASS)21) +#define LAUNCHER_FILE_DISPOSITION_DELETE 0x00000001U +#define LAUNCHER_FILE_DISPOSITION_POSIX 0x00000002U +#ifndef PROC_THREAD_ATTRIBUTE_JOB_LIST +#define PROC_THREAD_ATTRIBUTE_JOB_LIST ((DWORD_PTR)0x0002000dU) +#endif + +static void launcher_write_u32(uint8_t *output, uint32_t value) { + for (unsigned int index = 0; index < 4U; index++) { + output[index] = (uint8_t)(value >> (index * 8U)); + } +} + +static void launcher_write_u64(uint8_t *output, uint64_t value) { + for (unsigned int index = 0; index < 8U; index++) { + output[index] = (uint8_t)(value >> (index * 8U)); + } +} + +static uint64_t launcher_filetime_value(const FILETIME *time) { + return (uint64_t)time->dwLowDateTime | ((uint64_t)time->dwHighDateTime << 32U); +} + +static int launcher_failure(const wchar_t *message) { + if (message) { + (void)fwprintf(stderr, L"codebase-memory-mcp: %ls\n", message); + (void)fflush(stderr); + } + return 1; +} + +static bool launcher_same_identity(const BY_HANDLE_FILE_INFORMATION *first, + const BY_HANDLE_FILE_INFORMATION *second) { + return first->dwVolumeSerialNumber == second->dwVolumeSerialNumber && + first->nFileIndexHigh == second->nFileIndexHigh && + first->nFileIndexLow == second->nFileIndexLow; +} + +static bool launcher_file_information(HANDLE file, BY_HANDLE_FILE_INFORMATION *information) { + return file && file != INVALID_HANDLE_VALUE && GetFileType(file) == FILE_TYPE_DISK && + GetFileInformationByHandle(file, information) != 0 && + (information->dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && + information->nNumberOfLinks == 1U; +} + +static bool launcher_current_user(void **token_user_out, PSID *sid_out) { + *token_user_out = NULL; + *sid_out = NULL; + HANDLE token = NULL; + DWORD needed = 0U; + if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &token)) { + return false; + } + (void)GetTokenInformation(token, TokenUser, NULL, 0U, &needed); + void *memory = needed ? malloc(needed) : NULL; + bool ok = memory && GetTokenInformation(token, TokenUser, memory, needed, &needed) != 0; + (void)CloseHandle(token); + if (!ok) { + free(memory); + return false; + } + *token_user_out = memory; + *sid_out = ((TOKEN_USER *)memory)->User.Sid; + return true; +} + +static uint32_t launcher_sid_read_u32_le(const uint8_t *bytes) { + return (uint32_t)bytes[0] | ((uint32_t)bytes[1] << 8U) | ((uint32_t)bytes[2] << 16U) | + ((uint32_t)bytes[3] << 24U); +} + +static bool launcher_sid_is_trusted_installer(PSID sid) { + static const uint32_t subauthorities[] = { + 80U, 956008885U, 3418522649U, 1831038044U, 1853292631U, 2271478464U, + }; + if (!sid || !IsValidSid(sid)) { + return false; + } + DWORD sid_length = GetLengthSid(sid); + const uint8_t *bytes = (const uint8_t *)sid; + if (sid_length != 32U || bytes[0] != 1U || bytes[1] != 6U || bytes[2] != 0U || bytes[3] != 0U || + bytes[4] != 0U || bytes[5] != 0U || bytes[6] != 0U || bytes[7] != 5U) { + return false; + } + for (size_t index = 0U; index < sizeof(subauthorities) / sizeof(subauthorities[0]); index++) { + if (launcher_sid_read_u32_le(bytes + 8U + index * 4U) != subauthorities[index]) { + return false; + } + } + return true; +} + +static bool launcher_sid_is_trusted(PSID sid, PSID current_user) { + return sid && current_user && IsValidSid(sid) && + (EqualSid(sid, current_user) || IsWellKnownSid(sid, WinLocalSystemSid) || + IsWellKnownSid(sid, WinBuiltinAdministratorsSid) || + launcher_sid_is_trusted_installer(sid)); +} + +static bool launcher_bounded_ace_sid_is_trusted(const ACE_HEADER *header, PSID current_user) { + size_t sid_offset = offsetof(ACCESS_ALLOWED_ACE, SidStart); + if (!header || (size_t)header->AceSize < sid_offset + 8U) { + return false; + } + const ACCESS_ALLOWED_ACE *ace = (const ACCESS_ALLOWED_ACE *)header; + const uint8_t *sid = (const uint8_t *)&ace->SidStart; + size_t sid_capacity = (size_t)header->AceSize - sid_offset; + if (sid[0] != 1U || sid[1] > 15U) { + return false; + } + size_t sid_length = 8U + (size_t)sid[1] * 4U; + return sid_length <= sid_capacity && IsValidSid((PSID)sid) && + GetLengthSid((PSID)sid) == (DWORD)sid_length && + (launcher_sid_is_trusted((PSID)sid, current_user) || + (((header->AceFlags & INHERIT_ONLY_ACE) != 0U) && + IsWellKnownSid((PSID)sid, WinCreatorOwnerSid))); +} + +static bool launcher_security_is_safe(HANDLE file, bool require_current_owner) { + void *token_user = NULL; + PSID user_sid = NULL; + if (!launcher_current_user(&token_user, &user_sid)) { + return false; + } + PSID owner = NULL; + PACL dacl = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + DWORD status = GetSecurityInfo(file, SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION, &owner, + NULL, &dacl, NULL, &descriptor); + ACL_SIZE_INFORMATION information; + memset(&information, 0, sizeof(information)); + bool secure = + status == ERROR_SUCCESS && owner && IsValidSid(owner) && + (require_current_owner ? EqualSid(owner, user_sid) != 0 + : launcher_sid_is_trusted(owner, user_sid)) && + dacl && IsValidAcl(dacl) && + GetAclInformation(dacl, &information, sizeof(information), AclSizeInformation) != 0; + const DWORD mutation = GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | + FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | + FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | + WRITE_OWNER | ACCESS_SYSTEM_SECURITY; + for (DWORD index = 0; secure && index < information.AceCount; index++) { + void *opaque = NULL; + if (!GetAce(dacl, index, &opaque) || !opaque) { + secure = false; + break; + } + ACE_HEADER *header = opaque; + enum { + LAUNCHER_ACE_ALLOW = 0x00, + LAUNCHER_ACE_DENY = 0x01, + LAUNCHER_ACE_DENY_OBJECT = 0x06, + LAUNCHER_ACE_DENY_CALLBACK = 0x0a, + LAUNCHER_ACE_DENY_CALLBACK_OBJECT = 0x0c, + }; + if (header->AceType == LAUNCHER_ACE_DENY || header->AceType == LAUNCHER_ACE_DENY_OBJECT || + header->AceType == LAUNCHER_ACE_DENY_CALLBACK || + header->AceType == LAUNCHER_ACE_DENY_CALLBACK_OBJECT) { + continue; + } + if (header->AceType != LAUNCHER_ACE_ALLOW || + header->AceSize < + offsetof(ACCESS_ALLOWED_ACE, SidStart) + offsetof(SID, SubAuthority)) { + secure = false; + break; + } + ACCESS_ALLOWED_ACE *ace = opaque; + if ((ace->Mask & mutation) == 0U) { + continue; + } + if (!launcher_bounded_ace_sid_is_trusted(header, user_sid)) { + secure = false; + } + } + if (descriptor) + (void)LocalFree(descriptor); + free(token_user); + return secure; +} + +static bool launcher_security_is_private(HANDLE file) { + return launcher_security_is_safe(file, true); +} + +static HANDLE launcher_open_regular(const wchar_t *path, DWORD access, bool require_private) { + HANDLE file = + CreateFileW(path, access | FILE_READ_ATTRIBUTES | (require_private ? READ_CONTROL : 0U), + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_SEQUENTIAL_SCAN, NULL); + BY_HANDLE_FILE_INFORMATION information; + if (!launcher_file_information(file, &information) || + (require_private && !launcher_security_is_private(file))) { + if (file != INVALID_HANDLE_VALUE) + (void)CloseHandle(file); + return INVALID_HANDLE_VALUE; + } + return file; +} + +static HANDLE launcher_open_directory_private(const wchar_t *path) { + HANDLE directory = + CreateFileW(path, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + BY_HANDLE_FILE_INFORMATION information; + bool valid = directory != INVALID_HANDLE_VALUE && GetFileType(directory) == FILE_TYPE_DISK && + GetFileInformationByHandle(directory, &information) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && + launcher_security_is_private(directory); + if (!valid) { + if (directory != INVALID_HANDLE_VALUE) + (void)CloseHandle(directory); + return INVALID_HANDLE_VALUE; + } + return directory; +} + +static bool launcher_path_tree_plain(const wchar_t *file_path) { + size_t length = wcslen(file_path); + if (length < 4U || length >= CBM_WINDOWS_LAUNCHER_PATH_CAP || file_path[1] != L':' || + (file_path[2] != L'\\' && file_path[2] != L'/')) { + return false; + } + wchar_t *path = malloc((length + 1U) * sizeof(*path)); + if (!path) + return false; + memcpy(path, file_path, (length + 1U) * sizeof(*path)); + for (size_t index = 0; index < length; index++) { + if (path[index] == L'/') + path[index] = L'\\'; + } + wchar_t *last = wcsrchr(path, L'\\'); + if (!last || last <= path + 2) { + free(path); + return false; + } + *last = L'\0'; + size_t directory_length = wcslen(path); + bool valid = true; + for (size_t index = 3U; valid && index <= directory_length; index++) { + if (index < directory_length && path[index] != L'\\') + continue; + wchar_t saved = path[index]; + path[index] = L'\0'; + HANDLE component = + CreateFileW(path, FILE_READ_ATTRIBUTES | READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + BY_HANDLE_FILE_INFORMATION information; + valid = component != INVALID_HANDLE_VALUE && GetFileType(component) == FILE_TYPE_DISK && + GetFileInformationByHandle(component, &information) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && + launcher_security_is_safe(component, false); + if (component != INVALID_HANDLE_VALUE) + (void)CloseHandle(component); + path[index] = saved; + } + free(path); + return valid; +} + +static bool launcher_self_path(wchar_t output[CBM_WINDOWS_LAUNCHER_PATH_CAP]) { + DWORD length = GetModuleFileNameW(NULL, output, CBM_WINDOWS_LAUNCHER_PATH_CAP); + return length > 3U && length < CBM_WINDOWS_LAUNCHER_PATH_CAP && + launcher_path_tree_plain(output); +} + +static bool launcher_parent_path(const wchar_t *path, wchar_t *output, size_t capacity) { + size_t length = wcslen(path); + if (length + 1U > capacity) + return false; + memcpy(output, path, (length + 1U) * sizeof(*output)); + wchar_t *separator = wcsrchr(output, L'\\'); + wchar_t *slash = wcsrchr(output, L'/'); + if (!separator || (slash && slash > separator)) + separator = slash; + if (!separator || separator <= output + 2) + return false; + *separator = L'\0'; + return true; +} + +static bool launcher_read_all(HANDLE file, void *buffer, size_t size) { + size_t offset = 0U; + while (offset < size) { + DWORD amount = 0U; + DWORD request = size - offset > (size_t)MAXDWORD ? MAXDWORD : (DWORD)(size - offset); + if (!ReadFile(file, (uint8_t *)buffer + offset, request, &amount, NULL) || amount == 0U) { + return false; + } + offset += amount; + } + return true; +} + +/* 1 = managed valid state, 0 = current absent (portable bundle), -1 = corrupt. */ +static int launcher_load_current(const wchar_t *canonical_launcher, + cbm_windows_current_v1_t *state_out) { + wchar_t directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t current[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!launcher_parent_path(canonical_launcher, directory, CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + return -1; + } + int written = + swprintf(current, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\.cbm\\current-v1", directory); + if (written <= 0 || (size_t)written >= CBM_WINDOWS_LAUNCHER_PATH_CAP) { + return -1; + } + wchar_t state_directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + int state_written = + swprintf(state_directory, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\.cbm", directory); + HANDLE install_handle = launcher_open_directory_private(directory); + HANDLE state_handle = state_written > 0 && (size_t)state_written < CBM_WINDOWS_LAUNCHER_PATH_CAP + ? launcher_open_directory_private(state_directory) + : INVALID_HANDLE_VALUE; + if (install_handle == INVALID_HANDLE_VALUE || state_handle == INVALID_HANDLE_VALUE) { + if (install_handle != INVALID_HANDLE_VALUE) + (void)CloseHandle(install_handle); + if (state_handle != INVALID_HANDLE_VALUE) + (void)CloseHandle(state_handle); + DWORD state_attributes = GetFileAttributesW(state_directory); + DWORD state_error = + state_attributes == INVALID_FILE_ATTRIBUTES ? GetLastError() : ERROR_SUCCESS; + return state_attributes == INVALID_FILE_ATTRIBUTES && + (state_error == ERROR_FILE_NOT_FOUND || state_error == ERROR_PATH_NOT_FOUND) + ? 0 + : -1; + } + (void)CloseHandle(install_handle); + (void)CloseHandle(state_handle); + HANDLE file = launcher_open_regular(current, GENERIC_READ, true); + if (file == INVALID_HANDLE_VALUE) { + DWORD attributes = GetFileAttributesW(current); + return attributes == INVALID_FILE_ATTRIBUTES && (GetLastError() == ERROR_FILE_NOT_FOUND || + GetLastError() == ERROR_PATH_NOT_FOUND) + ? 0 + : -1; + } + LARGE_INTEGER size; + uint8_t record[CBM_WINDOWS_CURRENT_V1_SIZE]; + uint8_t trailing = 0U; + DWORD trailing_count = 0U; + bool valid = GetFileSizeEx(file, &size) && size.QuadPart == CBM_WINDOWS_CURRENT_V1_SIZE && + launcher_read_all(file, record, sizeof(record)) && + ReadFile(file, &trailing, 1U, &trailing_count, NULL) != 0 && + trailing_count == 0U && + cbm_windows_current_v1_decode(record, sizeof(record), state_out); + (void)CloseHandle(file); + return valid ? 1 : -1; +} + +static bool launcher_adjacent_payload(const wchar_t *canonical_launcher, + wchar_t output[CBM_WINDOWS_LAUNCHER_PATH_CAP]) { + wchar_t directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!launcher_parent_path(canonical_launcher, directory, CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + return false; + } + int written = swprintf(output, CBM_WINDOWS_LAUNCHER_PATH_CAP, + L"%ls\\codebase-memory-mcp.payload.exe", directory); + return written > 0 && (size_t)written < CBM_WINDOWS_LAUNCHER_PATH_CAP; +} + +/* WHY: wmain supplies wchar_t **. Adding nested pointee const would make these + * read-only helpers' parameter types incompatible with that Windows ABI. */ +// cppcheck-suppress constParameter +static cbm_windows_launcher_action_t launcher_wide_action(int argc, wchar_t *const argv[]) { + if (argc <= 1 || !argv) + return CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY; + for (int index = 1; index < argc; index++) { + const wchar_t *argument = argv[index]; + if (!argument) + continue; + if (wcscmp(argument, L"cli") == 0 || wcscmp(argument, L"hook-augment") == 0 || + wcscmp(argument, L"config") == 0 || wcscmp(argument, L"install") == 0 || + wcscmp(argument, L"--help") == 0 || wcscmp(argument, L"-h") == 0 || + wcscmp(argument, L"--version") == 0) { + return CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY; + } + if (wcscmp(argument, L"update") == 0) + return CBM_WINDOWS_LAUNCHER_ACTION_UPDATE; + if (wcscmp(argument, L"uninstall") == 0) + return CBM_WINDOWS_LAUNCHER_ACTION_UNINSTALL; + } + return CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY; +} + +static size_t launcher_quoted_length(const wchar_t *argument) { + bool quote = argument[0] == L'\0' || wcspbrk(argument, L" \t\n\v\"") != NULL; + size_t length = quote ? 2U : 0U; + size_t slashes = 0U; + for (const wchar_t *cursor = argument;; cursor++) { + if (*cursor == L'\\') { + slashes++; + continue; + } + if (*cursor == L'\"') { + length += slashes * 2U + 2U; + slashes = 0U; + continue; + } + if (*cursor == L'\0') { + length += quote ? slashes * 2U : slashes; + break; + } + length += slashes + 1U; + slashes = 0U; + } + return length; +} + +static wchar_t *launcher_quote_into(wchar_t *output, const wchar_t *argument) { + bool quote = argument[0] == L'\0' || wcspbrk(argument, L" \t\n\v\"") != NULL; + if (quote) + *output++ = L'\"'; + size_t slashes = 0U; + for (const wchar_t *cursor = argument;; cursor++) { + if (*cursor == L'\\') { + slashes++; + continue; + } + if (*cursor == L'\"') { + size_t count = slashes * 2U + 1U; + while (count-- > 0U) + *output++ = L'\\'; + *output++ = L'\"'; + slashes = 0U; + continue; + } + if (*cursor == L'\0') { + size_t count = quote ? slashes * 2U : slashes; + while (count-- > 0U) + *output++ = L'\\'; + break; + } + while (slashes-- > 0U) + *output++ = L'\\'; + slashes = 0U; + *output++ = *cursor; + } + if (quote) + *output++ = L'\"'; + return output; +} + +static wchar_t *launcher_command_line(const wchar_t *image, int argc, wchar_t *const argv[]) { + size_t capacity = launcher_quoted_length(image) + 1U; + for (int index = 1; index < argc; index++) { + size_t addition = launcher_quoted_length(argv[index]) + 1U; + if (SIZE_MAX - capacity < addition) + return NULL; + capacity += addition; + } + wchar_t *command = calloc(capacity, sizeof(*command)); + if (!command) + return NULL; + wchar_t *cursor = launcher_quote_into(command, image); + for (int index = 1; index < argc; index++) { + *cursor++ = L' '; + cursor = launcher_quote_into(cursor, argv[index]); + } + *cursor = L'\0'; + return command; +} + +typedef struct { + HANDLE server; + HANDLE client; +} launcher_context_pipe_t; + +static void launcher_context_pipe_close(launcher_context_pipe_t *pipe) { + if (pipe->server && pipe->server != INVALID_HANDLE_VALUE) + (void)CloseHandle(pipe->server); + if (pipe->client && pipe->client != INVALID_HANDLE_VALUE) + (void)CloseHandle(pipe->client); + memset(pipe, 0, sizeof(*pipe)); +} + +static bool launcher_context_pipe_open(launcher_context_pipe_t *pipe) { + memset(pipe, 0, sizeof(*pipe)); + uint8_t random[16]; + if (BCryptGenRandom(NULL, random, sizeof(random), BCRYPT_USE_SYSTEM_PREFERRED_RNG) != 0) { + return false; + } + wchar_t name[160]; + int written = + swprintf(name, sizeof(name) / sizeof(name[0]), + L"\\\\.\\pipe\\cbm-launch-context-v1-%lu-" + L"%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x%02x", + (unsigned long)GetCurrentProcessId(), random[0], random[1], random[2], random[3], + random[4], random[5], random[6], random[7], random[8], random[9], random[10], + random[11], random[12], random[13], random[14], random[15]); + if (written <= 0 || (size_t)written >= sizeof(name) / sizeof(name[0])) { + return false; + } + pipe->server = CreateNamedPipeW(name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | + PIPE_REJECT_REMOTE_CLIENTS, + 1U, LAUNCHER_PIPE_BUFFER, 4096U, 0U, NULL); + if (pipe->server == INVALID_HANDLE_VALUE) { + launcher_context_pipe_close(pipe); + return false; + } + SECURITY_ATTRIBUTES security = { + .nLength = sizeof(security), + .lpSecurityDescriptor = NULL, + .bInheritHandle = TRUE, + }; + pipe->client = CreateFileW(name, GENERIC_READ | GENERIC_WRITE, 0U, &security, OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, NULL); + if (pipe->client == INVALID_HANDLE_VALUE) { + launcher_context_pipe_close(pipe); + return false; + } + BOOL connected = ConnectNamedPipe(pipe->server, NULL); + if (!connected && GetLastError() != ERROR_PIPE_CONNECTED) { + launcher_context_pipe_close(pipe); + return false; + } + return true; +} + +static bool launcher_write_all(HANDLE file, const void *data, size_t size) { + size_t offset = 0U; + while (offset < size) { + DWORD amount = 0U; + DWORD request = size - offset > (size_t)MAXDWORD ? MAXDWORD : (DWORD)(size - offset); + if (!WriteFile(file, (const uint8_t *)data + offset, request, &amount, NULL) || + amount == 0U) { + return false; + } + offset += amount; + } + return true; +} + +static bool launcher_wait_context_ready(HANDLE server, HANDLE child, HANDLE parent, + bool *explicit_rejection_out) { + *explicit_rejection_out = false; + uint64_t now = GetTickCount64(); + uint64_t deadline = UINT64_MAX - now < 30000U ? UINT64_MAX : now + 30000U; + while (GetTickCount64() < deadline) { + DWORD available = 0U; + if (!PeekNamedPipe(server, NULL, 0U, NULL, &available, NULL)) { + return false; + } + if (available > 0U) { + uint8_t ready = 0U; + DWORD received = 0U; + if (ReadFile(server, &ready, 1U, &received, NULL) == 0 || received != 1U) { + return false; + } + *explicit_rejection_out = ready == (uint8_t)'X'; + return ready == (uint8_t)'R'; + } + if (WaitForSingleObject(child, 0U) == WAIT_OBJECT_0) { + return false; + } + if (WaitForSingleObject(parent, 0U) == WAIT_OBJECT_0) { + return false; + } + Sleep(2U); + } + return false; +} + +static bool launcher_context_send(HANDLE server, const wchar_t *canonical_launcher, bool managed, + bool private_activation, cbm_windows_launcher_action_t action, + const cbm_windows_current_v1_t *state) { + size_t path_chars = wcslen(canonical_launcher) + 1U; + if (path_chars >= CBM_WINDOWS_LAUNCHER_PATH_CAP || (managed && !state)) { + return false; + } + FILETIME creation; + FILETIME exit_time; + FILETIME kernel_time; + FILETIME user_time; + if (!GetProcessTimes(GetCurrentProcess(), &creation, &exit_time, &kernel_time, &user_time)) { + return false; + } + uint8_t header[LAUNCHER_CONTEXT_HEADER_SIZE]; + memset(header, 0, sizeof(header)); + memcpy(header, launcher_context_magic, sizeof(launcher_context_magic)); + launcher_write_u32(header + 8U, 1U); + launcher_write_u32(header + 12U, LAUNCHER_CONTEXT_HEADER_SIZE); + uint32_t flags = managed ? LAUNCHER_CONTEXT_MANAGED : 0U; + if (private_activation) + flags |= LAUNCHER_CONTEXT_PRIVATE; + launcher_write_u32(header + 16U, flags); + launcher_write_u32(header + 20U, (uint32_t)action); + launcher_write_u32(header + 24U, GetCurrentProcessId()); + launcher_write_u32(header + 28U, (uint32_t)path_chars); + launcher_write_u64(header + 32U, launcher_filetime_value(&creation)); + if (managed) { + launcher_write_u64(header + 40U, state->payload_size); + memcpy(header + 48U, state->payload_sha256, 64U); + } + return launcher_write_all(server, header, sizeof(header)) && + launcher_write_all(server, canonical_launcher, path_chars * sizeof(*canonical_launcher)); +} + +static HANDLE launcher_duplicate_standard(DWORD identifier, DWORD desired) { + HANDLE source = GetStdHandle(identifier); + HANDLE duplicate = NULL; + if (source && source != INVALID_HANDLE_VALUE && + DuplicateHandle(GetCurrentProcess(), source, GetCurrentProcess(), &duplicate, 0U, TRUE, + DUPLICATE_SAME_ACCESS)) { + return duplicate; + } + SECURITY_ATTRIBUTES security = { + .nLength = sizeof(security), + .lpSecurityDescriptor = NULL, + .bInheritHandle = TRUE, + }; + return CreateFileW(L"NUL", desired, FILE_SHARE_READ | FILE_SHARE_WRITE, &security, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); +} + +static bool launcher_posix_remove(HANDLE file) { + launcher_disposition_info_ex_t disposition = { + .Flags = LAUNCHER_FILE_DISPOSITION_DELETE | LAUNCHER_FILE_DISPOSITION_POSIX, + }; + return SetFileInformationByHandle(file, LAUNCHER_FILE_DISPOSITION_INFO_EX, &disposition, + sizeof(disposition)) != 0; +} + +static int launcher_spawn_payload(const wchar_t *execution_path, const wchar_t *canonical_launcher, + bool managed, bool private_activation, + cbm_windows_launcher_action_t action, + const cbm_windows_current_v1_t *state, HANDLE parent, int argc, + wchar_t *const argv[]) { + wchar_t *command = launcher_command_line(execution_path, argc, argv); + launcher_context_pipe_t context_pipe = {0}; + HANDLE standard[3] = { + launcher_duplicate_standard(STD_INPUT_HANDLE, GENERIC_READ), + launcher_duplicate_standard(STD_OUTPUT_HANDLE, GENERIC_WRITE), + launcher_duplicate_standard(STD_ERROR_HANDLE, GENERIC_WRITE), + }; + bool handles_valid = standard[0] && standard[0] != INVALID_HANDLE_VALUE && standard[1] && + standard[1] != INVALID_HANDLE_VALUE && standard[2] && + standard[2] != INVALID_HANDLE_VALUE; + bool pipe_valid = handles_valid && launcher_context_pipe_open(&context_pipe); + HANDLE inherited[4] = { + standard[0], + standard[1], + standard[2], + pipe_valid ? context_pipe.client : NULL, + }; + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits; + memset(&limits, 0, sizeof(limits)); + limits.BasicLimitInformation.LimitFlags = + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK; + HANDLE job = CreateJobObjectW(NULL, NULL); + bool job_ready = job && SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, + sizeof(limits)) != 0; + SIZE_T attribute_size = 0U; + (void)InitializeProcThreadAttributeList(NULL, 2U, 0U, &attribute_size); + LPPROC_THREAD_ATTRIBUTE_LIST attributes = attribute_size ? malloc(attribute_size) : NULL; + bool attribute_initialized = + attributes && InitializeProcThreadAttributeList(attributes, 2U, 0U, &attribute_size) != 0; + bool attribute_ready = + attribute_initialized && job_ready && + UpdateProcThreadAttribute(attributes, 0U, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, inherited, + sizeof(inherited), NULL, NULL) != 0 && + UpdateProcThreadAttribute(attributes, 0U, PROC_THREAD_ATTRIBUTE_JOB_LIST, &job, sizeof(job), + NULL, NULL) != 0; + + wchar_t context_handle[32]; + int context_written = + pipe_valid ? swprintf(context_handle, sizeof(context_handle) / sizeof(context_handle[0]), + L"%llx", (unsigned long long)(uintptr_t)context_pipe.client) + : -1; + bool environment_set = + context_written > 0 && + (size_t)context_written < sizeof(context_handle) / sizeof(context_handle[0]) && + SetEnvironmentVariableW(LAUNCHER_CONTEXT_ENV, context_handle) != 0; + STARTUPINFOEXW startup; + PROCESS_INFORMATION child; + memset(&startup, 0, sizeof(startup)); + memset(&child, 0, sizeof(child)); + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = standard[0]; + startup.StartupInfo.hStdOutput = standard[1]; + startup.StartupInfo.hStdError = standard[2]; + startup.lpAttributeList = attributes; + DWORD flags = EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT; + bool parent_alive = + parent && parent != INVALID_HANDLE_VALUE && WaitForSingleObject(parent, 0U) == WAIT_TIMEOUT; + bool created = command && pipe_valid && attribute_ready && job_ready && parent_alive && + environment_set && + CreateProcessW(execution_path, command, NULL, NULL, TRUE, flags, NULL, NULL, + &startup.StartupInfo, &child) != 0; + (void)SetEnvironmentVariableW(LAUNCHER_CONTEXT_ENV, NULL); + if (attribute_initialized) + DeleteProcThreadAttributeList(attributes); + free(attributes); + free(command); + for (size_t index = 0U; index < 3U; index++) { + if (standard[index] && standard[index] != INVALID_HANDLE_VALUE) + (void)CloseHandle(standard[index]); + } + if (context_pipe.client && context_pipe.client != INVALID_HANDLE_VALUE) { + (void)CloseHandle(context_pipe.client); + context_pipe.client = NULL; + } + bool context_sent = + created && launcher_context_send(context_pipe.server, canonical_launcher, managed, + private_activation, action, state); + bool resumed = context_sent && ResumeThread(child.hThread) != (DWORD)-1; + bool explicit_rejection = false; + bool payload_ready = resumed && launcher_wait_context_ready(context_pipe.server, child.hProcess, + parent, &explicit_rejection); + HANDLE private_file = INVALID_HANDLE_VALUE; + bool private_removed = true; + if (payload_ready && private_activation) { + private_file = + launcher_open_regular(execution_path, GENERIC_READ | GENERIC_WRITE | DELETE, true); + private_removed = + private_file != INVALID_HANDLE_VALUE && launcher_posix_remove(private_file); + } + uint8_t handshake_result = private_removed ? (uint8_t)'G' : (uint8_t)'F'; + bool authenticated = payload_ready && + launcher_write_all(context_pipe.server, &handshake_result, 1U) && + private_removed; + bool graceful_failure = explicit_rejection || (payload_ready && !private_removed); + if (context_pipe.server && context_pipe.server != INVALID_HANDLE_VALUE) { + (void)CloseHandle(context_pipe.server); + context_pipe.server = NULL; + } + if (!authenticated && created && !graceful_failure) { + (void)TerminateProcess(child.hProcess, 1U); + } + if (private_file != INVALID_HANDLE_VALUE) + (void)CloseHandle(private_file); + HANDLE waits[2] = {child.hProcess, parent}; + DWORD wait = created + ? WaitForMultipleObjects(2U, waits, FALSE, authenticated ? INFINITE : 5000U) + : WAIT_FAILED; + bool parent_died = wait == WAIT_OBJECT_0 + 1U; + if ((wait != WAIT_OBJECT_0 || parent_died) && created) { + (void)TerminateJobObject(job, 1U); + (void)WaitForSingleObject(child.hProcess, 5000U); + } + DWORD exit_code = 1U; + bool exit_valid = authenticated && wait == WAIT_OBJECT_0 && + GetExitCodeProcess(child.hProcess, &exit_code) != 0; + if (child.hThread) + (void)CloseHandle(child.hThread); + if (child.hProcess) + (void)CloseHandle(child.hProcess); + if (job) + (void)CloseHandle(job); + launcher_context_pipe_close(&context_pipe); + return exit_valid ? (int)exit_code : 1; +} + +static HANDLE launcher_parent_liveness_open(void) { + DWORD self_pid = GetCurrentProcessId(); + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0U); + if (snapshot == INVALID_HANDLE_VALUE) + return NULL; + PROCESSENTRY32W entry; + memset(&entry, 0, sizeof(entry)); + entry.dwSize = sizeof(entry); + DWORD parent_pid = 0U; + bool found = Process32FirstW(snapshot, &entry) != 0; + while (found) { + if (entry.th32ProcessID == self_pid) { + parent_pid = entry.th32ParentProcessID; + break; + } + found = Process32NextW(snapshot, &entry) != 0; + } + (void)CloseHandle(snapshot); + if (parent_pid == 0U || parent_pid == self_pid) + return NULL; + HANDLE parent = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, parent_pid); + if (!parent || WaitForSingleObject(parent, 0U) != WAIT_TIMEOUT) { + if (parent) + (void)CloseHandle(parent); + return NULL; + } + FILETIME self_creation; + FILETIME self_exit; + FILETIME self_kernel; + FILETIME self_user; + FILETIME parent_creation; + FILETIME parent_exit; + FILETIME parent_kernel; + FILETIME parent_user; + bool chronological = + GetProcessTimes(GetCurrentProcess(), &self_creation, &self_exit, &self_kernel, + &self_user) != 0 && + GetProcessTimes(parent, &parent_creation, &parent_exit, &parent_kernel, &parent_user) != + 0 && + launcher_filetime_value(&parent_creation) <= launcher_filetime_value(&self_creation); + if (!chronological) { + (void)CloseHandle(parent); + return NULL; + } + return parent; +} + +static bool launcher_private_payload_path(const wchar_t *canonical_launcher, const wchar_t *payload, + wchar_t output[CBM_WINDOWS_LAUNCHER_PATH_CAP]) { + wchar_t directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t state_directory[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t runtime[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!launcher_parent_path(canonical_launcher, directory, CBM_WINDOWS_LAUNCHER_PATH_CAP)) { + return false; + } + int state_written = + swprintf(state_directory, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\.cbm", directory); + int runtime_written = + swprintf(runtime, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\runtime", state_directory); + int output_written = swprintf( + output, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"%ls\\activation-%lu-%llu.payload.exe", runtime, + (unsigned long)GetCurrentProcessId(), (unsigned long long)GetTickCount64()); + if (state_written <= 0 || runtime_written <= 0 || output_written <= 0 || + (size_t)state_written >= CBM_WINDOWS_LAUNCHER_PATH_CAP || + (size_t)runtime_written >= CBM_WINDOWS_LAUNCHER_PATH_CAP || + (size_t)output_written >= CBM_WINDOWS_LAUNCHER_PATH_CAP) { + return false; + } + HANDLE install_handle = launcher_open_directory_private(directory); + HANDLE state_handle = launcher_open_directory_private(state_directory); + bool runtime_created = CreateDirectoryW(runtime, NULL) != 0; + DWORD runtime_error = runtime_created ? ERROR_SUCCESS : GetLastError(); + HANDLE runtime_handle = (runtime_created || runtime_error == ERROR_ALREADY_EXISTS) + ? launcher_open_directory_private(runtime) + : INVALID_HANDLE_VALUE; + bool directories_valid = + install_handle != INVALID_HANDLE_VALUE && state_handle != INVALID_HANDLE_VALUE && + runtime_handle != INVALID_HANDLE_VALUE && launcher_path_tree_plain(output); + if (install_handle != INVALID_HANDLE_VALUE) + (void)CloseHandle(install_handle); + if (state_handle != INVALID_HANDLE_VALUE) + (void)CloseHandle(state_handle); + if (runtime_handle != INVALID_HANDLE_VALUE) + (void)CloseHandle(runtime_handle); + if (!directories_valid || !CopyFileW(payload, output, TRUE)) { + if (runtime_created) + (void)RemoveDirectoryW(runtime); + return false; + } + HANDLE source = launcher_open_regular(payload, GENERIC_READ, true); + HANDLE copy = launcher_open_regular(output, GENERIC_READ | GENERIC_WRITE | DELETE, true); + BY_HANDLE_FILE_INFORMATION source_info; + BY_HANDLE_FILE_INFORMATION copy_info; + LARGE_INTEGER source_size; + LARGE_INTEGER copy_size; + bool valid = launcher_file_information(source, &source_info) && + launcher_file_information(copy, ©_info) && + GetFileSizeEx(source, &source_size) && GetFileSizeEx(copy, ©_size) && + source_size.QuadPart == copy_size.QuadPart && FlushFileBuffers(copy) != 0; + if (source != INVALID_HANDLE_VALUE) + (void)CloseHandle(source); + if (copy != INVALID_HANDLE_VALUE) + (void)CloseHandle(copy); + if (!valid) + (void)DeleteFileW(output); + return valid; +} + +/* WHY: See launcher_wide_action; this role parser reads but never owns wmain's + * mutable argv array. */ +// cppcheck-suppress constParameter +static int launcher_probe_role(int argc, wchar_t *const argv[]) { + if (argc != 4 || wcscmp(argv[1], LAUNCHER_PROBE_ARG) != 0) + return -1; + wchar_t *ready_end = NULL; + wchar_t *release_end = NULL; + unsigned long long ready_raw = wcstoull(argv[2], &ready_end, 16); + unsigned long long release_raw = wcstoull(argv[3], &release_end, 16); + if (!ready_end || *ready_end != L'\0' || !release_end || *release_end != L'\0' || + ready_raw == 0ULL || release_raw == 0ULL) { + return 1; + } + HANDLE ready = (HANDLE)(uintptr_t)ready_raw; + HANDLE release = (HANDLE)(uintptr_t)release_raw; + if (!SetEvent(ready)) + return 1; + return WaitForSingleObject(release, 30000U) == WAIT_OBJECT_0 ? 0 : 1; +} + +/* WHY: See launcher_wide_action; retain the exact wmain-compatible argv type. */ +// cppcheck-suppress constParameter +static int launcher_release_descriptor_role(int argc, wchar_t *const argv[]) { + if (argc != 2 || wcscmp(argv[1], LAUNCHER_RELEASE_DESCRIPTOR_ARG) != 0) { + return -1; + } + wchar_t canonical[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + wchar_t payload[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!launcher_self_path(canonical) || !launcher_adjacent_payload(canonical, payload)) { + return 1; + } + HANDLE launcher_file = launcher_open_regular(canonical, GENERIC_READ, true); + HANDLE payload_file = launcher_open_regular(payload, GENERIC_READ, true); + BY_HANDLE_FILE_INFORMATION launcher_information; + BY_HANDLE_FILE_INFORMATION payload_information; + bool paths_ready = launcher_file_information(launcher_file, &launcher_information) && + launcher_file_information(payload_file, &payload_information) && + launcher_path_tree_plain(payload); + if (launcher_file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(launcher_file); + } + if (payload_file != INVALID_HANDLE_VALUE) { + (void)CloseHandle(payload_file); + } + if (!paths_ready) + return 1; + + wchar_t command[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + int written = + swprintf(command, CBM_WINDOWS_LAUNCHER_PATH_CAP, L"\"%ls\" %ls %u", payload, + PAYLOAD_RELEASE_DESCRIPTOR_ARG, (unsigned int)CBM_WINDOWS_LAUNCHER_ABI_CURRENT); + if (written <= 0 || (size_t)written >= CBM_WINDOWS_LAUNCHER_PATH_CAP) { + return 1; + } + HANDLE standard[3] = { + GetStdHandle(STD_INPUT_HANDLE), + GetStdHandle(STD_OUTPUT_HANDLE), + GetStdHandle(STD_ERROR_HANDLE), + }; + bool handles_ready = true; + for (size_t index = 0U; index < 3U; index++) { + handles_ready = handles_ready && standard[index] && standard[index] != INVALID_HANDLE_VALUE; + } + SIZE_T attribute_size = 0U; + (void)InitializeProcThreadAttributeList(NULL, 1U, 0U, &attribute_size); + LPPROC_THREAD_ATTRIBUTE_LIST attributes = + handles_ready && attribute_size ? malloc(attribute_size) : NULL; + bool initialized = + attributes && InitializeProcThreadAttributeList(attributes, 1U, 0U, &attribute_size) != 0; + bool attributes_ready = + initialized && UpdateProcThreadAttribute(attributes, 0U, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + standard, sizeof(standard), NULL, NULL) != 0; + STARTUPINFOEXW startup; + PROCESS_INFORMATION child; + memset(&startup, 0, sizeof(startup)); + memset(&child, 0, sizeof(child)); + startup.StartupInfo.cb = sizeof(startup); + startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + startup.StartupInfo.hStdInput = standard[0]; + startup.StartupInfo.hStdOutput = standard[1]; + startup.StartupInfo.hStdError = standard[2]; + startup.lpAttributeList = attributes; + bool spawned = + attributes_ready && CreateProcessW(payload, command, NULL, NULL, TRUE, + CREATE_NO_WINDOW | EXTENDED_STARTUPINFO_PRESENT, NULL, + NULL, &startup.StartupInfo, &child) != 0; + if (initialized) + DeleteProcThreadAttributeList(attributes); + free(attributes); + DWORD wait = spawned ? WaitForSingleObject(child.hProcess, 30000U) : WAIT_FAILED; + if (spawned && wait != WAIT_OBJECT_0) { + (void)TerminateProcess(child.hProcess, 1U); + (void)WaitForSingleObject(child.hProcess, 5000U); + } + DWORD exit_code = 1U; + bool valid = spawned && wait == WAIT_OBJECT_0 && + GetExitCodeProcess(child.hProcess, &exit_code) != 0 && exit_code == 0U; + if (child.hThread) + (void)CloseHandle(child.hThread); + if (child.hProcess) + (void)CloseHandle(child.hProcess); + return valid ? 0 : 1; +} + +int wmain(void) { + int argc = 0; + LPWSTR *argv = CommandLineToArgvW(GetCommandLineW(), &argc); + if (!argv || argc <= 0) { + if (argv) + (void)LocalFree(argv); + return launcher_failure(L"could not parse the Windows command line"); + } + int descriptor_result = launcher_release_descriptor_role(argc, argv); + if (descriptor_result >= 0) { + (void)LocalFree(argv); + return descriptor_result; + } + int probe_result = launcher_probe_role(argc, argv); + if (probe_result >= 0) { + (void)LocalFree(argv); + return probe_result; + } + wchar_t canonical[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + if (!launcher_self_path(canonical)) { + (void)LocalFree(argv); + return launcher_failure(L"could not securely resolve the launcher path"); + } + HANDLE canonical_file = launcher_open_regular(canonical, GENERIC_READ, true); + if (canonical_file == INVALID_HANDLE_VALUE) { + (void)LocalFree(argv); + return launcher_failure(L"launcher ownership or access policy is unsafe"); + } + (void)CloseHandle(canonical_file); + + cbm_windows_current_v1_t state; + memset(&state, 0, sizeof(state)); + int state_status = launcher_load_current(canonical, &state); + if (state_status < 0) { + (void)LocalFree(argv); + return launcher_failure(L"current-v1 launcher state is corrupt or unsafe"); + } + bool managed = state_status == 1; + if (managed && + !cbm_windows_current_v1_supports_launcher_abi(&state, CBM_WINDOWS_LAUNCHER_ABI_CURRENT)) { + (void)LocalFree(argv); + return launcher_failure(L"current-v1 requires an incompatible launcher ABI"); + } + cbm_windows_launcher_action_t action = launcher_wide_action(argc, argv); + if (!cbm_windows_launcher_action_allowed(action, managed)) { + (void)LocalFree(argv); + return launcher_failure(L"portable CBM cannot update or uninstall itself; run install " + L"first or use your package manager"); + } + + wchar_t payload[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + bool path_valid = + managed ? cbm_windows_generation_payload_path(canonical, state.payload_sha256, payload, + CBM_WINDOWS_LAUNCHER_PATH_CAP) + : launcher_adjacent_payload(canonical, payload); + HANDLE payload_file = + path_valid ? launcher_open_regular(payload, GENERIC_READ, true) : INVALID_HANDLE_VALUE; + LARGE_INTEGER payload_size; + BY_HANDLE_FILE_INFORMATION payload_before; + bool payload_valid = launcher_file_information(payload_file, &payload_before) && + GetFileSizeEx(payload_file, &payload_size) && payload_size.QuadPart > 0 && + (!managed || (uint64_t)payload_size.QuadPart == state.payload_size) && + launcher_path_tree_plain(payload); + if (!payload_valid) { + if (payload_file != INVALID_HANDLE_VALUE) + (void)CloseHandle(payload_file); + (void)LocalFree(argv); + return launcher_failure(L"selected payload is missing, unsafe, or has the wrong size"); + } + HANDLE payload_recheck = launcher_open_regular(payload, GENERIC_READ, true); + BY_HANDLE_FILE_INFORMATION payload_after; + payload_valid = launcher_file_information(payload_recheck, &payload_after) && + launcher_same_identity(&payload_before, &payload_after); + if (payload_recheck != INVALID_HANDLE_VALUE) + (void)CloseHandle(payload_recheck); + (void)CloseHandle(payload_file); + if (!payload_valid) { + (void)LocalFree(argv); + return launcher_failure(L"selected payload changed during validation"); + } + + bool private_activation = managed && action != CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY; + wchar_t private_payload[CBM_WINDOWS_LAUNCHER_PATH_CAP]; + const wchar_t *execution = payload; + if (private_activation) { + if (!launcher_private_payload_path(canonical, payload, private_payload)) { + (void)LocalFree(argv); + return launcher_failure(L"could not create a private activation payload"); + } + execution = private_payload; + } + HANDLE parent = launcher_parent_liveness_open(); + if (!parent) { + if (private_activation) + (void)DeleteFileW(private_payload); + (void)LocalFree(argv); + return launcher_failure(L"could not establish immediate-parent liveness supervision"); + } + int result = launcher_spawn_payload(execution, canonical, managed, private_activation, action, + managed ? &state : NULL, parent, argc, argv); + (void)CloseHandle(parent); + if (private_activation) + (void)DeleteFileW(private_payload); + (void)LocalFree(argv); + return result; +} + +#endif /* _WIN32 */ diff --git a/src/main.c b/src/main.c index 4f23b0693..0c5dd7cab 100644 --- a/src/main.c +++ b/src/main.c @@ -19,6 +19,7 @@ #include "daemon/bootstrap.h" #include "daemon/frontend.h" #include "daemon/host.h" +#include "daemon/ipc.h" #include "daemon/project_lock.h" #include "daemon/version_cohort.h" #include "mcp/mcp.h" @@ -54,6 +55,7 @@ enum { #include "foundation/compat_thread.h" #include "foundation/mem.h" #include "foundation/profile.h" +#include "foundation/sha256.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 */ @@ -86,10 +88,8 @@ static cbm_daemon_runtime_client_t *g_daemon_client = NULL; static uint64_t main_deadline_after(uint32_t timeout_ms); -static bool main_session_context(const char *preferred_root, - char root_out[MAIN_PATH_CAP], - char allowed_out[MAIN_PATH_CAP], - const char **allowed_out_ptr); +static bool main_session_context(const char *preferred_root, char root_out[MAIN_PATH_CAP], + char allowed_out[MAIN_PATH_CAP], const char **allowed_out_ptr); typedef struct main_local_cli_lease main_local_cli_lease_t; @@ -110,22 +110,21 @@ typedef struct { typedef struct { cbm_mutex_t mutex; cbm_mcp_server_t *server; + bool maintenance_cancelled; } main_local_maintenance_context_t; -static void main_local_maintenance_context_init( - main_local_maintenance_context_t *context) { +static void main_local_maintenance_context_init(main_local_maintenance_context_t *context) { memset(context, 0, sizeof(*context)); cbm_mutex_init(&context->mutex); } -static void main_local_maintenance_context_destroy( - main_local_maintenance_context_t *context) { +static void main_local_maintenance_context_destroy(main_local_maintenance_context_t *context) { cbm_mutex_destroy(&context->mutex); memset(context, 0, sizeof(*context)); } -static void main_local_maintenance_server_bind( - main_local_maintenance_context_t *context, cbm_mcp_server_t *server) { +static void main_local_maintenance_server_bind(main_local_maintenance_context_t *context, + cbm_mcp_server_t *server) { if (!context) { return; } @@ -140,22 +139,30 @@ static bool main_local_command_cancel(void *opaque) { return false; } cbm_mutex_lock(&context->mutex); - bool cancelled = context->server && - cbm_mcp_server_cancel_active(context->server); + bool cancelled = context->server && cbm_mcp_server_cancel_active(context->server); + context->maintenance_cancelled = context->maintenance_cancelled || cancelled; cbm_mutex_unlock(&context->mutex); return cancelled; } -static void main_local_maintenance_finish( - cbm_daemon_maintenance_monitor_t **monitor, - main_local_maintenance_context_t *context, bool context_initialized, - const char *participant) { - if (monitor && *monitor && - !cbm_daemon_maintenance_monitor_stop(monitor)) { +static bool main_local_maintenance_was_cancelled(main_local_maintenance_context_t *context) { + if (!context) { + return false; + } + cbm_mutex_lock(&context->mutex); + bool cancelled = context->maintenance_cancelled; + cbm_mutex_unlock(&context->mutex); + return cancelled; +} + +static void main_local_maintenance_finish(cbm_daemon_maintenance_monitor_t **monitor, + main_local_maintenance_context_t *context, + bool context_initialized, const char *participant) { + if (monitor && *monitor && !cbm_daemon_maintenance_monitor_stop(monitor)) { /* The observer still borrows context (and may be inside cancellation). * Freeing command/server/manager memory would be a cross-thread UAF. */ - cbm_log_error("participant.maintenance_join_failed", "participant", - participant, "action", "process_exit"); + cbm_log_error("participant.maintenance_join_failed", "participant", participant, "action", + "process_exit"); (void)fflush(stdout); (void)fflush(stderr); _Exit(EXIT_FAILURE); @@ -165,10 +172,8 @@ static void main_local_maintenance_finish( } } -static _Noreturn void main_coordination_cleanup_fail_stop( - const char *component) { - cbm_log_error("coordination.cleanup_timeout", "component", component, - "action", "process_exit"); +static _Noreturn void main_coordination_cleanup_fail_stop(const char *component) { + cbm_log_error("coordination.cleanup_timeout", "component", component, "action", "process_exit"); (void)fprintf(stderr, "codebase-memory-mcp: coordination cleanup timed out (%s); " "terminating so the OS releases retained claims\n", @@ -179,8 +184,7 @@ static _Noreturn void main_coordination_cleanup_fail_stop( } static void main_project_lock_release_fully(cbm_project_lock_lease_t **lease) { - uint64_t deadline = - main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); + uint64_t deadline = main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); while (lease && *lease) { (void)cbm_project_lock_lease_release(lease); if (!*lease) { @@ -198,8 +202,7 @@ static void main_project_lock_release_fully(cbm_project_lock_lease_t **lease) { * publish it. Publication occurs after the native project lease is acquired, * so a marker from the worker also proves that its polling supervisor did not * retain the same exclusive lease. */ -static bool main_test_worker_project_lock_marker( - const main_local_cli_mutation_t *mutation) { +static bool main_test_worker_project_lock_marker(const main_local_cli_mutation_t *mutation) { #ifdef _WIN32 (void)mutation; return true; @@ -208,8 +211,8 @@ static bool main_test_worker_project_lock_marker( return true; } char marker_path[MAIN_PATH_CAP] = {0}; - if (!cbm_safe_getenv("CBM_TEST_WORKER_PROJECT_LOCK_PID_FILE", - marker_path, sizeof(marker_path), NULL) || + if (!cbm_safe_getenv("CBM_TEST_WORKER_PROJECT_LOCK_PID_FILE", marker_path, sizeof(marker_path), + NULL) || !marker_path[0]) { return true; } @@ -225,11 +228,9 @@ static bool main_test_worker_project_lock_marker( return false; } char identity[96]; - int length = snprintf(identity, sizeof(identity), "%ld %ld\n", - (long)getpid(), (long)getpgrp()); + int length = snprintf(identity, sizeof(identity), "%ld %ld\n", (long)getpid(), (long)getpgrp()); bool written = length > 0 && length < (int)sizeof(identity) && - write(marker, identity, (size_t)length) == - (ssize_t)length; + write(marker, identity, (size_t)length) == (ssize_t)length; return close(marker) == 0 && written; #endif } @@ -243,8 +244,8 @@ static bool main_local_cli_mutation_begin(void *context, const char *project) { uint64_t now = cbm_now_ms(); uint64_t deadline = now > UINT64_MAX - 100U ? UINT64_MAX : now + 100U; cbm_project_lock_lease_t *lease = NULL; - cbm_private_file_lock_status_t status = cbm_project_lock_acquire( - mutation->manager, project, deadline, NULL, &lease); + cbm_private_file_lock_status_t status = + cbm_project_lock_acquire(mutation->manager, project, deadline, NULL, &lease); if (status == CBM_PRIVATE_FILE_LOCK_OK && lease) { main_local_cli_lease_t *held = calloc(1, sizeof(*held)); if (held) { @@ -269,13 +270,13 @@ static bool main_local_cli_mutation_begin(void *context, const char *project) { } main_project_lock_release_fully(&lease); if (status != CBM_PRIVATE_FILE_LOCK_BUSY) { - cbm_log_error("cli.project_lock_failed", "project", project, - "action", "refuse_mutation"); + cbm_log_error("cli.project_lock_failed", "project", project, "action", + "refuse_mutation"); return false; } if (mutation->feedback && !mutation->waiting_reported) { - (void)fprintf(mutation->feedback, - "Waiting for another CBM mutation of %s...\n", project); + (void)fprintf(mutation->feedback, "Waiting for another CBM mutation of %s...\n", + project); (void)fflush(mutation->feedback); mutation->waiting_reported = true; } @@ -300,8 +301,7 @@ static void main_local_cli_mutation_end(void *context, const char *project) { } } -static void main_local_cli_mutation_release_all( - main_local_cli_mutation_t *mutation) { +static void main_local_cli_mutation_release_all(main_local_cli_mutation_t *mutation) { while (mutation && mutation->leases) { main_local_cli_lease_t *held = mutation->leases; mutation->leases = held->next; @@ -432,8 +432,8 @@ static bool worker_start_parent_watchdog(pid_t initial_ppid) { worker_config.kill_worker_group = true; worker_config.exit_on_parent_death = true; cbm_thread_t worker_watchdog_tid; - if (cbm_thread_create(&worker_watchdog_tid, PARENT_WATCHDOG_STACK_SIZE, - parent_watchdog_thread, &worker_config) != 0) { + if (cbm_thread_create(&worker_watchdog_tid, PARENT_WATCHDOG_STACK_SIZE, parent_watchdog_thread, + &worker_config) != 0) { return false; } return cbm_thread_detach(&worker_watchdog_tid) == 0; @@ -448,8 +448,8 @@ static bool client_start_parent_watchdog(pid_t initial_ppid) { client_config.kill_worker_group = false; client_config.exit_on_parent_death = true; cbm_thread_t watchdog; - if (cbm_thread_create(&watchdog, PARENT_WATCHDOG_STACK_SIZE, - parent_watchdog_thread, &client_config) != 0) { + if (cbm_thread_create(&watchdog, PARENT_WATCHDOG_STACK_SIZE, parent_watchdog_thread, + &client_config) != 0) { return false; } if (cbm_thread_detach(&watchdog) != 0) { @@ -594,11 +594,9 @@ static void cli_progress_worker_log(const char *line, void *context) { cbm_progress_sink_fn(line); } -static int run_cli(int argc, char **argv, - cbm_project_lock_manager_t *project_locks, +static int run_cli(int argc, char **argv, cbm_project_lock_manager_t *project_locks, main_local_maintenance_context_t *maintenance_context) { - if (argc == 1 && argv && - (strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0)) { + if (argc == 1 && argv && (strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0)) { (void)fputs(CLI_USAGE, stdout); return 0; } @@ -703,8 +701,8 @@ static int run_cli(int argc, char **argv, } } - bool progress = !index_worker && cbm_cli_progress_enabled( - progress_requested, cli_isatty(2) != 0); + bool progress = + !index_worker && cbm_cli_progress_enabled(progress_requested, cli_isatty(2) != 0); uint64_t progress_started_ms = cbm_now_ms(); if (progress) { cbm_progress_sink_init(stderr); @@ -713,11 +711,9 @@ static int run_cli(int argc, char **argv, if (!index_worker && strcmp(tool_name, "index_repository") == 0) { char self_path[MAIN_PATH_CAP] = {0}; - if (!cbm_http_server_resolve_binary_path(NULL, self_path, - sizeof(self_path)) || + if (!cbm_http_server_resolve_binary_path(NULL, self_path, sizeof(self_path)) || !cbm_index_supervisor_capture_build_fingerprint()) { - (void)fprintf(stderr, - "error: exact CLI worker identity could not be verified\n"); + (void)fprintf(stderr, "error: exact CLI worker identity could not be verified\n"); if (progress) { cbm_progress_sink_fini(); cbm_cli_progress_finish(stderr, tool_name, false, @@ -745,9 +741,8 @@ static int run_cli(int argc, char **argv, * performs the physical write. */ cbm_mcp_server_set_background_tasks(srv, false); if (project_locks) { - cbm_mcp_server_set_project_mutation_guard( - srv, main_local_cli_mutation_begin, - main_local_cli_mutation_end, &mutation); + cbm_mcp_server_set_project_mutation_guard(srv, main_local_cli_mutation_begin, + main_local_cli_mutation_end, &mutation); } } if (srv && !index_worker) { @@ -755,18 +750,16 @@ static int run_cli(int argc, char **argv, char allowed_root[MAIN_PATH_CAP]; const char *allowed_root_ptr = NULL; if (progress) { - cbm_mcp_server_set_index_log_callback(srv, cli_progress_worker_log, - NULL); + cbm_mcp_server_set_index_log_callback(srv, cli_progress_worker_log, NULL); } - if (!main_session_context(NULL, session_root, allowed_root, - &allowed_root_ptr) || - !cbm_mcp_server_set_session_context(srv, session_root, - allowed_root_ptr)) { + if (!main_session_context(NULL, session_root, allowed_root, &allowed_root_ptr) || + !cbm_mcp_server_set_session_context(srv, session_root, allowed_root_ptr)) { cbm_mcp_server_free(srv); srv = NULL; } } bool maintenance_binding_failed = srv && !maintenance_context; + bool maintenance_cancelled = false; if (srv && maintenance_context) { main_local_maintenance_server_bind(maintenance_context, srv); result = cbm_mcp_handle_tool(srv, tool_name, args_json); @@ -774,13 +767,17 @@ static int run_cli(int argc, char **argv, * teardown. The process-level monitor remains active across all * parsing and cleanup, but can no longer race a freed server. */ main_local_maintenance_server_bind(maintenance_context, NULL); + maintenance_cancelled = main_local_maintenance_was_cancelled(maintenance_context); + } + if (maintenance_cancelled && !index_worker) { + (void)fprintf(stderr, "codebase-memory-mcp: active CLI command is stopping for " + "install/update/uninstall\n"); } if (!result) { if (maintenance_binding_failed) { - (void)fprintf( - stderr, - "error: local %s maintenance cancellation could not bind safely\n", - index_worker ? "worker" : "CLI"); + (void)fprintf(stderr, + "error: local %s maintenance cancellation could not bind safely\n", + index_worker ? "worker" : "CLI"); } else { (void)fprintf(stderr, "error: failed to run local %s server\n", index_worker ? "worker" : "CLI"); @@ -789,8 +786,7 @@ static int run_cli(int argc, char **argv, main_local_cli_mutation_release_all(&mutation); if (progress) { cbm_progress_sink_fini(); - cbm_cli_progress_finish(stderr, tool_name, false, - cbm_now_ms() - progress_started_ms); + cbm_cli_progress_finish(stderr, tool_name, false, cbm_now_ms() - progress_started_ms); } free(heap_args); return SKIP_ONE; @@ -817,6 +813,7 @@ static int run_cli(int argc, char **argv, } else { exit_code = cli_print_mcp_result(result); } + exit_code = cbm_cli_exit_status_after_maintenance(exit_code, maintenance_cancelled); if (cbm_index_worker_active()) { /* Supervised worker: the response is delivered (file + stdout). * Skip the multi-GB teardown (server/store frees) — the process @@ -886,10 +883,8 @@ static void print_help(void) { /* 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, - cbm_project_lock_manager_t *project_locks, - main_local_maintenance_context_t - *maintenance_context) { +static int handle_subcommand(int argc, char **argv, cbm_project_lock_manager_t *project_locks, + main_local_maintenance_context_t *maintenance_context) { /* First scan: global flags */ for (int i = SKIP_ONE; i < argc; i++) { if (strcmp(argv[i], "--profile") == 0) { @@ -908,8 +903,8 @@ static int handle_subcommand(int argc, char **argv, if (strcmp(argv[i], "cli") == 0) { cbm_mem_init_with_cap(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram), cbm_index_worker_memory_budget_bytes()); - return run_cli(argc - i - SKIP_ONE, argv + i + SKIP_ONE, - project_locks, maintenance_context); + return run_cli(argc - i - SKIP_ONE, argv + i + SKIP_ONE, project_locks, + maintenance_context); } if (strcmp(argv[i], "hook-augment") == 0) { cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram)); @@ -932,8 +927,8 @@ static int handle_subcommand(int argc, char **argv, } /* Parse --ui= and --port= into a per-field daemon mutation. */ -static uint8_t parse_ui_flags(int argc, char **argv, bool *ui_enabled, - int *ui_port, bool *explicit_enable) { +static uint8_t parse_ui_flags(int argc, char **argv, bool *ui_enabled, int *ui_port, + bool *explicit_enable) { uint8_t update_mask = 0; for (int i = SKIP_ONE; i < argc; i++) { if (strncmp(argv[i], "--ui=", SLEN("--ui=")) == 0) { @@ -948,8 +943,8 @@ static uint8_t parse_ui_flags(int argc, char **argv, bool *ui_enabled, char *end = NULL; errno = 0; long port = strtol(value, &end, CBM_DECIMAL_BASE); - if (errno == 0 && end != value && end && *end == '\0' && - port > 0 && port < MAIN_MAX_PORT) { + if (errno == 0 && end != value && end && *end == '\0' && port > 0 && + port < MAIN_MAX_PORT) { *ui_port = (int)port; update_mask |= CBM_DAEMON_APPLICATION_UI_CONFIG_PORT; } @@ -1015,11 +1010,9 @@ static char **cbm_win_utf8_argv(int *out_argc) { } #endif /* _WIN32 */ -static bool main_resolve_executable(const char *argv0, - char out[MAIN_PATH_CAP]) { +static bool main_resolve_executable(const char *argv0, char out[MAIN_PATH_CAP]) { char resolved[MAIN_PATH_CAP]; - return cbm_http_server_resolve_binary_path(argv0, resolved, - sizeof(resolved)) && + return cbm_http_server_resolve_binary_path(argv0, resolved, sizeof(resolved)) && cbm_canonical_path(resolved, out, MAIN_PATH_CAP); } @@ -1031,9 +1024,46 @@ static bool main_build_identity(cbm_daemon_build_identity_t *identity) { if (!fingerprint) { return false; } + const char *cache = cbm_resolve_cache_dir(); + char canonical_cache[MAIN_PATH_CAP]; + static char cache_fingerprint[CBM_SHA256_HEX_LEN + 1]; + if (!cache || !cache[0]) { + return false; + } + /* Preserve one intentional alias spelling at the process boundary: an + * existing directory (including a symlink supplied by the user) is + * resolved first. Only a genuinely absent root goes through mkdir_p's + * component-by-component no-follow creation path. The process then uses + * only the resulting canonical path, so retargeting the original alias + * cannot move storage after cohort admission. */ + bool cache_ready = cbm_canonical_path(cache, canonical_cache, sizeof(canonical_cache)); + if (!cache_ready && cbm_mkdir_p(cache, 0700)) { + cache_ready = cbm_canonical_path(cache, canonical_cache, sizeof(canonical_cache)); + } + if (!cache_ready || !cbm_is_dir(canonical_cache)) { + return false; + } + cbm_normalize_path_sep(canonical_cache); + /* Admission is account-scoped, so its storage authority must be too. + * Harden the canonical object before hashing it. Replacement of this + * owner-only path by the same already-compromised OS account is outside + * the v1 threat boundary; cross-account and unsafe filesystem states fail + * here before any daemon/cohort state is opened. */ + if (!cbm_daemon_ipc_private_directory_secure(canonical_cache)) { + return false; + } + /* Every cache consumer in this process must use the exact path whose + * fingerprint joins the account-wide cohort. Keeping an original symlink + * spelling in the environment would let a later retarget move storage + * while the process still advertises the old canonical root. */ + if (cbm_setenv("CBM_CACHE_DIR", canonical_cache, 1) != 0) { + return false; + } + cbm_sha256_hex(canonical_cache, strlen(canonical_cache), cache_fingerprint); *identity = (cbm_daemon_build_identity_t){ .semantic_version = CBM_VERSION, .build_fingerprint = fingerprint, + .cache_fingerprint = cache_fingerprint, .protocol_abi = CBM_DAEMON_RUNTIME_WIRE_ABI, .store_abi = 1, .feature_abi = 1, @@ -1043,8 +1073,7 @@ static bool main_build_identity(cbm_daemon_build_identity_t *identity) { static uint64_t main_deadline_after(uint32_t timeout_ms) { uint64_t now_ms = cbm_now_ms(); - return now_ms > UINT64_MAX - timeout_ms ? UINT64_MAX - : now_ms + timeout_ms; + return now_ms > UINT64_MAX - timeout_ms ? UINT64_MAX : now_ms + timeout_ms; } static bool main_local_cli_feedback_enabled(int argc, char **argv) { @@ -1058,14 +1087,12 @@ static bool main_local_cli_feedback_enabled(int argc, char **argv) { return cbm_cli_progress_enabled(requested, cli_isatty(2) != 0); } -static int main_local_transition_acquire( - const cbm_daemon_ipc_endpoint_t *endpoint, FILE *feedback, - cbm_daemon_ipc_local_transition_t **transition_out) { +static int main_local_transition_acquire(const cbm_daemon_ipc_endpoint_t *endpoint, FILE *feedback, + cbm_daemon_ipc_local_transition_t **transition_out) { uint64_t deadline = main_deadline_after(MAIN_STARTUP_TIMEOUT_MS); bool waiting_reported = false; for (;;) { - int status = cbm_daemon_ipc_local_transition_try_acquire( - endpoint, transition_out); + int status = cbm_daemon_ipc_local_transition_try_acquire(endpoint, transition_out); if (status != 0 || cbm_now_ms() >= deadline) { return status; } @@ -1078,37 +1105,31 @@ static int main_local_transition_acquire( } } -static bool main_version_cohort_close( - cbm_version_cohort_lease_t **lease, - cbm_version_cohort_manager_t **manager) { +static bool main_version_cohort_close(cbm_version_cohort_lease_t **lease, + cbm_version_cohort_manager_t **manager) { bool ok = true; - uint64_t deadline = - main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); + uint64_t deadline = main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); while (lease && *lease) { - cbm_private_file_lock_status_t status = - cbm_version_cohort_lease_release(lease); + cbm_private_file_lock_status_t status = cbm_version_cohort_lease_release(lease); if (status != CBM_PRIVATE_FILE_LOCK_OK) { ok = false; } if (*lease) { if (cbm_now_ms() >= deadline) { - main_coordination_cleanup_fail_stop( - "cohort_lease_cleanup"); + main_coordination_cleanup_fail_stop("cohort_lease_cleanup"); } cbm_usleep(1000); } } deadline = main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); while (manager && *manager) { - cbm_private_file_lock_status_t status = - cbm_version_cohort_manager_free(manager); + cbm_private_file_lock_status_t status = cbm_version_cohort_manager_free(manager); if (status != CBM_PRIVATE_FILE_LOCK_OK) { ok = false; } if (*manager) { if (cbm_now_ms() >= deadline) { - main_coordination_cleanup_fail_stop( - "cohort_manager_cleanup"); + main_coordination_cleanup_fail_stop("cohort_manager_cleanup"); } cbm_usleep(1000); } @@ -1116,21 +1137,17 @@ static bool main_version_cohort_close( return ok; } -static bool main_project_lock_manager_close( - cbm_project_lock_manager_t **manager) { +static bool main_project_lock_manager_close(cbm_project_lock_manager_t **manager) { bool ok = true; - uint64_t deadline = - main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); + uint64_t deadline = main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); while (manager && *manager) { - cbm_private_file_lock_status_t status = - cbm_project_lock_manager_free(manager); + cbm_private_file_lock_status_t status = cbm_project_lock_manager_free(manager); if (status != CBM_PRIVATE_FILE_LOCK_OK) { ok = false; } if (*manager) { if (cbm_now_ms() >= deadline) { - main_coordination_cleanup_fail_stop( - "project_lock_manager_cleanup"); + main_coordination_cleanup_fail_stop("project_lock_manager_cleanup"); } cbm_usleep(1000); } @@ -1138,21 +1155,17 @@ static bool main_project_lock_manager_close( return ok; } -static bool main_local_transition_close( - cbm_daemon_ipc_local_transition_t **transition) { +static bool main_local_transition_close(cbm_daemon_ipc_local_transition_t **transition) { bool ok = true; - uint64_t deadline = - main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); + uint64_t deadline = main_deadline_after(MAIN_COORDINATION_CLEANUP_MS); while (transition && *transition) { - bool released = - cbm_daemon_ipc_local_transition_release(transition); + bool released = cbm_daemon_ipc_local_transition_release(transition); if (!released) { ok = false; } if (*transition) { if (cbm_now_ms() >= deadline) { - main_coordination_cleanup_fail_stop( - "local_transition_cleanup"); + main_coordination_cleanup_fail_stop("local_transition_cleanup"); } cbm_usleep(1000); } @@ -1160,17 +1173,14 @@ static bool main_local_transition_close( return ok; } -static bool main_session_context(const char *preferred_root, - char root_out[MAIN_PATH_CAP], - char allowed_out[MAIN_PATH_CAP], - const char **allowed_out_ptr) { +static bool main_session_context(const char *preferred_root, char root_out[MAIN_PATH_CAP], + char allowed_out[MAIN_PATH_CAP], const char **allowed_out_ptr) { const char *root = preferred_root && preferred_root[0] ? preferred_root : "."; if (!cbm_canonical_path(root, root_out, MAIN_PATH_CAP)) { return false; } char configured[MAIN_PATH_CAP]; - const char *allowed = cbm_safe_getenv("CBM_ALLOWED_ROOT", configured, - sizeof(configured), NULL); + const char *allowed = cbm_safe_getenv("CBM_ALLOWED_ROOT", configured, sizeof(configured), NULL); if (allowed && allowed[0]) { if (!cbm_canonical_path(allowed, allowed_out, MAIN_PATH_CAP)) { return false; @@ -1183,21 +1193,17 @@ static bool main_session_context(const char *preferred_root, return true; } -static bool main_set_client_context(cbm_daemon_runtime_client_t *client, - const char *preferred_root, - cbm_mcp_tool_profile_t tool_profile, - const char *hook_event, - const char *hook_dialect, - uint32_t timeout_ms) { +static bool main_set_client_context(cbm_daemon_runtime_client_t *client, const char *preferred_root, + cbm_mcp_tool_profile_t tool_profile, const char *hook_event, + const char *hook_dialect, uint32_t timeout_ms) { char root[MAIN_PATH_CAP]; char allowed[MAIN_PATH_CAP]; const char *allowed_ptr = NULL; if (!main_session_context(preferred_root, root, allowed, &allowed_ptr)) { return false; } - return cbm_daemon_application_client_set_context( - client, root, allowed_ptr, tool_profile, hook_event, - hook_dialect, timeout_ms) == + return cbm_daemon_application_client_set_context(client, root, allowed_ptr, tool_profile, + hook_event, hook_dialect, timeout_ms) == CBM_DAEMON_RUNTIME_APPLICATION_OK; } @@ -1223,8 +1229,7 @@ static char *main_hook_cwd(const char *input_json) { return copy; } -static bool main_hook_options(int argc, char **argv, - const char **event_out, +static bool main_hook_options(int argc, char **argv, const char **event_out, const char **dialect_out) { if (!argv || !event_out || !dialect_out) { return false; @@ -1244,8 +1249,7 @@ static bool main_hook_options(int argc, char **argv, for (int index = hook_index + 1; index < argc; index++) { if (strcmp(argv[index], "--event") == 0 && index + 1 < argc) { *event_out = argv[++index]; - } else if (strcmp(argv[index], "--dialect") == 0 && - index + 1 < argc) { + } else if (strcmp(argv[index], "--dialect") == 0 && index + 1 < argc) { *dialect_out = argv[++index]; } else { return false; @@ -1254,17 +1258,16 @@ static bool main_hook_options(int argc, char **argv, return cbm_hook_augment_invocation_supported(*event_out, *dialect_out); } -static int main_run_hook_frontend(cbm_daemon_runtime_client_t *client, - const char *hook_event, +static int main_run_hook_frontend(cbm_daemon_runtime_client_t *client, const char *hook_event, const char *hook_dialect) { char *input = cbm_hook_augment_read_stdin(); if (!input) { return 0; } char *hook_cwd = main_hook_cwd(input); - bool context_set = main_set_client_context( - client, hook_cwd, CBM_MCP_TOOL_PROFILE_ALL, hook_event, - hook_dialect, MAIN_HOOK_CONNECT_TIMEOUT_MS); + bool context_set = + main_set_client_context(client, hook_cwd, CBM_MCP_TOOL_PROFILE_ALL, hook_event, + hook_dialect, MAIN_HOOK_CONNECT_TIMEOUT_MS); free(hook_cwd); if (!context_set) { free(input); @@ -1272,13 +1275,10 @@ static int main_run_hook_frontend(cbm_daemon_runtime_client_t *client, } uint8_t *response = NULL; uint32_t response_length = 0; - cbm_daemon_runtime_application_status_t status = - cbm_daemon_application_client_hook_augment( - client, input, &response, &response_length, - MAIN_HOOK_REQUEST_TIMEOUT_MS); + cbm_daemon_runtime_application_status_t status = cbm_daemon_application_client_hook_augment( + client, input, &response, &response_length, MAIN_HOOK_REQUEST_TIMEOUT_MS); free(input); - if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response && - response_length > 0) { + if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response && response_length > 0) { (void)fwrite(response, 1, response_length, stdout); (void)fflush(stdout); } @@ -1302,10 +1302,16 @@ int main(int argc, char **argv) { } } #endif + int windows_descriptor_role = cbm_cli_windows_payload_descriptor_role(argc, argv); + if (windows_descriptor_role >= 0) { + return windows_descriptor_role; + } + if (cbm_cli_windows_launcher_startup_authenticate(argc, argv) != 0) { + return EXIT_FAILURE; + } cbm_daemon_process_role_t role = cbm_daemon_process_role(argc, argv); if (role == CBM_DAEMON_PROCESS_INVALID) { - (void)fprintf(stderr, - "codebase-memory-mcp: invalid internal process arguments\n"); + (void)fprintf(stderr, "codebase-memory-mcp: invalid internal process arguments\n"); return EXIT_FAILURE; } @@ -1315,11 +1321,9 @@ int main(int argc, char **argv) { cbm_mcp_tool_profile_t tool_profile = CBM_MCP_TOOL_PROFILE_ALL; if (role == CBM_DAEMON_PROCESS_MCP_CLIENT && - cbm_mcp_parse_tool_profile_args( - argc, (const char *const *)argv, &tool_profile) != 0) { - (void)fprintf( - stderr, - "codebase-memory-mcp: --tool-profile requires the supported value 'analysis' or 'scout'\n"); + cbm_mcp_parse_tool_profile_args(argc, (const char *const *)argv, &tool_profile) != 0) { + (void)fprintf(stderr, "codebase-memory-mcp: --tool-profile requires the supported value " + "'analysis' or 'scout'\n"); return 2; } const char *hook_event = NULL; @@ -1353,8 +1357,7 @@ int main(int argc, char **argv) { (void)fputs("Preparing one-shot local CBM command...\n", feedback); (void)fflush(feedback); } - cbm_daemon_ipc_endpoint_t *local_endpoint = - cbm_daemon_bootstrap_endpoint_new(NULL); + cbm_daemon_ipc_endpoint_t *local_endpoint = cbm_daemon_bootstrap_endpoint_new(NULL); char local_executable[MAIN_PATH_CAP]; cbm_daemon_build_identity_t local_identity; cbm_project_lock_manager_t *project_locks = @@ -1380,112 +1383,93 @@ int main(int argc, char **argv) { } cbm_http_server_set_binary_path(local_executable); - cohort_status = cbm_version_cohort_acquire( - cohort_manager, &local_identity, - main_deadline_after(MAIN_STARTUP_TIMEOUT_MS), &cohort_lease, - &cohort_conflict); + cohort_status = cbm_version_cohort_acquire(cohort_manager, &local_identity, + main_deadline_after(MAIN_STARTUP_TIMEOUT_MS), + &cohort_lease, &cohort_conflict); if (cohort_status != CBM_VERSION_COHORT_OK) { char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; bool formatted = cohort_status == CBM_VERSION_COHORT_CONFLICT && - cbm_daemon_conflict_format( - &cohort_conflict, message, sizeof(message)); + cbm_daemon_conflict_format(&cohort_conflict, message, sizeof(message)); if (cohort_status == CBM_VERSION_COHORT_CONFLICT) { (void)cbm_version_cohort_log_conflict(&cohort_conflict); } - (void)fprintf( - stderr, "codebase-memory-mcp: %s\n", - formatted - ? message - : "CLI exact-build admission could not be verified; retry after active CBM operations exit"); + (void)fprintf(stderr, "codebase-memory-mcp: %s\n", + formatted ? message + : "CLI exact-build admission could not be verified; retry " + "after active CBM operations exit"); goto local_cli_cleanup; } main_local_maintenance_context_init(&maintenance_context); maintenance_context_initialized = true; - maintenance_monitor = cbm_daemon_maintenance_monitor_start( - cohort_manager, main_local_command_cancel, &maintenance_context, - EXIT_FAILURE, "CLI command"); + maintenance_monitor = + cbm_daemon_maintenance_monitor_start(cohort_manager, main_local_command_cancel, + &maintenance_context, EXIT_FAILURE, "CLI command"); if (!maintenance_monitor) { - (void)fprintf( - stderr, - "codebase-memory-mcp: CLI maintenance observer could not start safely\n"); + (void)fprintf(stderr, + "codebase-memory-mcp: CLI maintenance observer could not start safely\n"); goto local_cli_cleanup; } - int transition_status = main_local_transition_acquire( - local_endpoint, feedback, &local_transition); + int transition_status = + main_local_transition_acquire(local_endpoint, feedback, &local_transition); if (transition_status != 1 || !local_transition) { - (void)fprintf( - stderr, - "codebase-memory-mcp: CLI startup coordination %s; retry after the active CBM transition exits\n", - transition_status == 0 ? "remained busy" : "could not be verified safely"); + (void)fprintf(stderr, + "codebase-memory-mcp: CLI startup coordination %s; retry after the " + "active CBM transition exits\n", + transition_status == 0 ? "remained busy" + : "could not be verified safely"); goto local_cli_cleanup; } - int seal_status = - cbm_daemon_ipc_local_transition_seal_legacy(local_transition); + int seal_status = cbm_daemon_ipc_local_transition_seal_legacy(local_transition); if (seal_status != 1) { if (seal_status == 0) { - (void)cbm_version_cohort_log_uncoordinated_daemon( - &local_identity); + (void)cbm_version_cohort_log_uncoordinated_daemon(&local_identity); } - (void)fprintf( - stderr, - "codebase-memory-mcp: CBM CLI could not start because a pre-coordination or unverified CBM generation is active; close all CBM sessions and commands, then retry\n"); + (void)fprintf(stderr, "codebase-memory-mcp: CBM CLI could not start because a " + "pre-coordination or unverified CBM generation is active; close " + "all CBM sessions and commands, then retry\n"); goto local_cli_cleanup; } cbm_version_cohort_daemon_presence_t daemon_presence = - cbm_version_cohort_daemon_presence_under_transition( - cohort_manager, local_endpoint, local_transition); + cbm_version_cohort_daemon_presence_under_transition(cohort_manager, local_endpoint, + local_transition); if (daemon_presence != CBM_VERSION_COHORT_DAEMON_ABSENT && daemon_presence != CBM_VERSION_COHORT_DAEMON_COORDINATED) { - if (daemon_presence == - CBM_VERSION_COHORT_DAEMON_UNCOORDINATED) { - (void)cbm_version_cohort_log_uncoordinated_daemon( - &local_identity); - (void)fprintf( - stderr, - "codebase-memory-mcp: CBM CLI could not start because " - "an active pre-coordination or unverified CBM daemon is " - "running. Close all CBM sessions and commands, then " - "retry.\n"); + if (daemon_presence == CBM_VERSION_COHORT_DAEMON_UNCOORDINATED) { + (void)cbm_version_cohort_log_uncoordinated_daemon(&local_identity); + (void)fprintf(stderr, "codebase-memory-mcp: CBM CLI could not start because " + "an active pre-coordination or unverified CBM daemon is " + "running. Close all CBM sessions and commands, then " + "retry.\n"); } else { - (void)fprintf( - stderr, - "codebase-memory-mcp: active daemon coordination could " - "not be verified safely; retry after active CBM sessions " - "exit\n"); + (void)fprintf(stderr, "codebase-memory-mcp: active daemon coordination could " + "not be verified safely; retry after active CBM sessions " + "exit\n"); } goto local_cli_cleanup; } if (!cbm_daemon_ipc_local_transition_begin_work(local_transition)) { - (void)fprintf( - stderr, - "codebase-memory-mcp: CLI startup coordination could not enter local work safely\n"); + (void)fprintf(stderr, "codebase-memory-mcp: CLI startup coordination could not enter " + "local work safely\n"); goto local_cli_cleanup; } - result = handle_subcommand(argc, argv, project_locks, - &maintenance_context); + result = handle_subcommand(argc, argv, project_locks, &maintenance_context); exit_code = result >= 0 ? result : EXIT_FAILURE; local_cli_cleanup: - main_local_maintenance_finish( - &maintenance_monitor, &maintenance_context, - maintenance_context_initialized, "CLI command"); - cleanup_ok = main_project_lock_manager_close(&project_locks) && - cleanup_ok; - cleanup_ok = main_local_transition_close(&local_transition) && - cleanup_ok; + main_local_maintenance_finish(&maintenance_monitor, &maintenance_context, + maintenance_context_initialized, "CLI command"); + cleanup_ok = main_project_lock_manager_close(&project_locks) && cleanup_ok; + cleanup_ok = main_local_transition_close(&local_transition) && cleanup_ok; /* Lifetime is the final coordination token released. The mutation * barrier must not prove every old participant gone while this process * still owns a local transition or project mutation lease. */ - cleanup_ok = - main_version_cohort_close(&cohort_lease, &cohort_manager) && - cleanup_ok; + cleanup_ok = main_version_cohort_close(&cohort_lease, &cohort_manager) && cleanup_ok; cbm_daemon_ipc_endpoint_free(local_endpoint); if (!cleanup_ok) { - (void)fprintf(stderr, - "codebase-memory-mcp: CLI coordination cleanup failed\n"); + (void)fprintf(stderr, "codebase-memory-mcp: CLI coordination cleanup failed\n"); return EXIT_FAILURE; } return exit_code; @@ -1493,12 +1477,10 @@ int main(int argc, char **argv) { char executable_path[MAIN_PATH_CAP]; cbm_daemon_build_identity_t identity; - if (!main_resolve_executable(argv[0], executable_path) || - !main_build_identity(&identity)) { + if (!main_resolve_executable(argv[0], executable_path) || !main_build_identity(&identity)) { (void)fprintf(stderr, "codebase-memory-mcp: exact executable identity could not be verified\n"); - return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS - : EXIT_FAILURE; + return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS : EXIT_FAILURE; } cbm_http_server_set_binary_path(executable_path); @@ -1511,16 +1493,11 @@ int main(int argc, char **argv) { cbm_index_worker_argv_status_message(worker_status)); return EXIT_FAILURE; } - cbm_daemon_ipc_endpoint_t *worker_endpoint = - cbm_daemon_bootstrap_endpoint_new(NULL); + cbm_daemon_ipc_endpoint_t *worker_endpoint = cbm_daemon_bootstrap_endpoint_new(NULL); cbm_project_lock_manager_t *worker_project_locks = - worker_endpoint - ? cbm_project_lock_manager_new(worker_endpoint) - : NULL; + worker_endpoint ? cbm_project_lock_manager_new(worker_endpoint) : NULL; cbm_version_cohort_manager_t *worker_cohort_manager = - worker_endpoint - ? cbm_version_cohort_manager_new(worker_endpoint) - : NULL; + worker_endpoint ? cbm_version_cohort_manager_new(worker_endpoint) : NULL; cbm_version_cohort_lease_t *worker_cohort_lease = NULL; cbm_daemon_ipc_local_transition_t *worker_transition = NULL; main_local_maintenance_context_t worker_maintenance_context; @@ -1531,88 +1508,73 @@ int main(int argc, char **argv) { bool worker_cleanup_ok = true; cbm_version_cohort_status_t worker_cohort_status = worker_project_locks && worker_cohort_manager - ? cbm_version_cohort_acquire( - worker_cohort_manager, &identity, - main_deadline_after(MAIN_STARTUP_TIMEOUT_MS), - &worker_cohort_lease, &worker_conflict) + ? cbm_version_cohort_acquire(worker_cohort_manager, &identity, + main_deadline_after(MAIN_STARTUP_TIMEOUT_MS), + &worker_cohort_lease, &worker_conflict) : CBM_VERSION_COHORT_IO; if (worker_cohort_status != CBM_VERSION_COHORT_OK) { char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; - bool formatted = - worker_cohort_status == CBM_VERSION_COHORT_CONFLICT && - cbm_daemon_conflict_format(&worker_conflict, message, - sizeof(message)); + bool formatted = worker_cohort_status == CBM_VERSION_COHORT_CONFLICT && + cbm_daemon_conflict_format(&worker_conflict, message, sizeof(message)); if (worker_cohort_status == CBM_VERSION_COHORT_CONFLICT) { (void)cbm_version_cohort_log_conflict(&worker_conflict); } - (void)fprintf( - stderr, "CBM index worker could not start: %s\n", - formatted ? message : "exact-build admission failed"); + (void)fprintf(stderr, "CBM index worker could not start: %s\n", + formatted ? message : "exact-build admission failed"); goto worker_cleanup; } main_local_maintenance_context_init(&worker_maintenance_context); worker_maintenance_context_initialized = true; worker_maintenance_monitor = cbm_daemon_maintenance_monitor_start( - worker_cohort_manager, main_local_command_cancel, - &worker_maintenance_context, EXIT_FAILURE, "index worker"); + worker_cohort_manager, main_local_command_cancel, &worker_maintenance_context, + EXIT_FAILURE, "index worker"); if (!worker_maintenance_monitor) { - (void)fprintf( - stderr, - "CBM index worker could not start: maintenance observer unavailable\n"); + (void)fprintf(stderr, + "CBM index worker could not start: maintenance observer unavailable\n"); goto worker_cleanup; } - int worker_transition_status = main_local_transition_acquire( - worker_endpoint, NULL, &worker_transition); + int worker_transition_status = + main_local_transition_acquire(worker_endpoint, NULL, &worker_transition); if (worker_transition_status != 1 || !worker_transition) { - (void)fprintf( - stderr, - "CBM index worker could not start: local coordination %s\n", - worker_transition_status == 0 - ? "remained busy" - : "could not be verified safely"); + (void)fprintf(stderr, "CBM index worker could not start: local coordination %s\n", + worker_transition_status == 0 ? "remained busy" + : "could not be verified safely"); goto worker_cleanup; } - int worker_seal_status = - cbm_daemon_ipc_local_transition_seal_legacy(worker_transition); + int worker_seal_status = cbm_daemon_ipc_local_transition_seal_legacy(worker_transition); if (worker_seal_status != 1) { if (worker_seal_status == 0) { (void)cbm_version_cohort_log_uncoordinated_daemon(&identity); } - (void)fprintf( - stderr, - "CBM index worker could not start: a pre-coordination or unverified CBM generation is active\n"); + (void)fprintf(stderr, "CBM index worker could not start: a pre-coordination or " + "unverified CBM generation is active\n"); goto worker_cleanup; } cbm_version_cohort_daemon_presence_t worker_daemon_presence = - cbm_version_cohort_daemon_presence_under_transition( - worker_cohort_manager, worker_endpoint, worker_transition); + cbm_version_cohort_daemon_presence_under_transition(worker_cohort_manager, + worker_endpoint, worker_transition); if (worker_daemon_presence != CBM_VERSION_COHORT_DAEMON_ABSENT && - worker_daemon_presence != - CBM_VERSION_COHORT_DAEMON_COORDINATED) { - if (worker_daemon_presence == - CBM_VERSION_COHORT_DAEMON_UNCOORDINATED) { + worker_daemon_presence != CBM_VERSION_COHORT_DAEMON_COORDINATED) { + if (worker_daemon_presence == CBM_VERSION_COHORT_DAEMON_UNCOORDINATED) { (void)cbm_version_cohort_log_uncoordinated_daemon(&identity); } - (void)fprintf( - stderr, - "CBM index worker could not start: active daemon coordination could not be verified safely\n"); + (void)fprintf(stderr, "CBM index worker could not start: active daemon coordination " + "could not be verified safely\n"); goto worker_cleanup; } if (!cbm_daemon_ipc_local_transition_begin_work(worker_transition)) { - (void)fprintf( - stderr, - "CBM index worker could not start: local coordination could not enter worker execution\n"); + (void)fprintf(stderr, "CBM index worker could not start: local coordination could not " + "enter worker execution\n"); goto worker_cleanup; } - cbm_index_set_worker_role_options( - true, invocation.response_out, invocation.single_thread, invocation.marker_file, - invocation.quarantine_file, invocation.memory_budget_bytes); + cbm_index_set_worker_role_options(true, invocation.response_out, invocation.single_thread, + invocation.marker_file, invocation.quarantine_file, + invocation.memory_budget_bytes); #ifndef _WIN32 if (!worker_prepare_process_group() || process_initial_ppid <= 1 || - getppid() != process_initial_ppid || - !worker_start_watchdog_test_descendant() || + getppid() != process_initial_ppid || !worker_start_watchdog_test_descendant() || !worker_start_parent_watchdog(process_initial_ppid)) { static const char message[] = "CBM index worker could not start: process-tree containment unavailable\n"; @@ -1622,40 +1584,30 @@ int main(int argc, char **argv) { } #endif cbm_index_supervisor_mark_host(); - result = handle_subcommand(argc, argv, worker_project_locks, - &worker_maintenance_context); + result = handle_subcommand(argc, argv, worker_project_locks, &worker_maintenance_context); worker_cleanup: - main_local_maintenance_finish( - &worker_maintenance_monitor, &worker_maintenance_context, - worker_maintenance_context_initialized, "index worker"); + main_local_maintenance_finish(&worker_maintenance_monitor, &worker_maintenance_context, + worker_maintenance_context_initialized, "index worker"); worker_cleanup_ok = - main_project_lock_manager_close(&worker_project_locks) && - worker_cleanup_ok; - worker_cleanup_ok = - main_local_transition_close(&worker_transition) && - worker_cleanup_ok; + main_project_lock_manager_close(&worker_project_locks) && worker_cleanup_ok; + worker_cleanup_ok = main_local_transition_close(&worker_transition) && worker_cleanup_ok; /* As in the parent CLI, release cohort lifetime last so activation * cannot overtake physical-worker coordination cleanup. */ worker_cleanup_ok = - main_version_cohort_close(&worker_cohort_lease, - &worker_cohort_manager) && + main_version_cohort_close(&worker_cohort_lease, &worker_cohort_manager) && worker_cleanup_ok; cbm_daemon_ipc_endpoint_free(worker_endpoint); - if (!worker_cleanup_ok || - worker_cohort_status != CBM_VERSION_COHORT_OK || result < 0) { + if (!worker_cleanup_ok || worker_cohort_status != CBM_VERSION_COHORT_OK || result < 0) { return EXIT_FAILURE; } return result; } - cbm_daemon_ipc_endpoint_t *endpoint = - cbm_daemon_bootstrap_endpoint_new(NULL); + cbm_daemon_ipc_endpoint_t *endpoint = cbm_daemon_bootstrap_endpoint_new(NULL); if (!endpoint) { - (void)fprintf(stderr, - "codebase-memory-mcp: secure daemon endpoint could not be created\n"); - return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS - : EXIT_FAILURE; + (void)fprintf(stderr, "codebase-memory-mcp: secure daemon endpoint could not be created\n"); + return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS : EXIT_FAILURE; } if (role == CBM_DAEMON_PROCESS_DAEMON) { @@ -1671,36 +1623,30 @@ int main(int argc, char **argv) { return result == 0 ? EXIT_SUCCESS : EXIT_FAILURE; } - cbm_version_cohort_manager_t *client_cohort_manager = - cbm_version_cohort_manager_new(endpoint); + cbm_version_cohort_manager_t *client_cohort_manager = cbm_version_cohort_manager_new(endpoint); cbm_version_cohort_lease_t *client_cohort_lease = NULL; cbm_daemon_conflict_t client_cohort_conflict; cbm_version_cohort_status_t client_cohort_status = client_cohort_manager - ? cbm_version_cohort_acquire( - client_cohort_manager, &identity, - main_deadline_after(role == CBM_DAEMON_PROCESS_HOOK_CLIENT - ? MAIN_HOOK_STARTUP_TIMEOUT_MS - : MAIN_STARTUP_TIMEOUT_MS), - &client_cohort_lease, &client_cohort_conflict) + ? cbm_version_cohort_acquire(client_cohort_manager, &identity, + main_deadline_after(role == CBM_DAEMON_PROCESS_HOOK_CLIENT + ? MAIN_HOOK_STARTUP_TIMEOUT_MS + : MAIN_STARTUP_TIMEOUT_MS), + &client_cohort_lease, &client_cohort_conflict) : CBM_VERSION_COHORT_IO; if (client_cohort_status != CBM_VERSION_COHORT_OK) { char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; - bool formatted = client_cohort_status == CBM_VERSION_COHORT_CONFLICT && - cbm_daemon_conflict_format( - &client_cohort_conflict, message, - sizeof(message)); + bool formatted = + client_cohort_status == CBM_VERSION_COHORT_CONFLICT && + cbm_daemon_conflict_format(&client_cohort_conflict, message, sizeof(message)); if (client_cohort_status == CBM_VERSION_COHORT_CONFLICT) { (void)cbm_version_cohort_log_conflict(&client_cohort_conflict); } - (void)fprintf( - stderr, "codebase-memory-mcp: %s\n", - formatted ? message : "client exact-build admission failed"); - (void)main_version_cohort_close(&client_cohort_lease, - &client_cohort_manager); + (void)fprintf(stderr, "codebase-memory-mcp: %s\n", + formatted ? message : "client exact-build admission failed"); + (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); cbm_daemon_ipc_endpoint_free(endpoint); - return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS - : EXIT_FAILURE; + return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS : EXIT_FAILURE; } cbm_daemon_bootstrap_config_t bootstrap_config = { @@ -1708,38 +1654,29 @@ int main(int argc, char **argv) { .endpoint = endpoint, .identity = &identity, .executable_path = executable_path, - .connect_timeout_ms = role == CBM_DAEMON_PROCESS_HOOK_CLIENT - ? MAIN_HOOK_CONNECT_TIMEOUT_MS - : MAIN_CONNECT_TIMEOUT_MS, - .startup_timeout_ms = role == CBM_DAEMON_PROCESS_HOOK_CLIENT - ? MAIN_HOOK_STARTUP_TIMEOUT_MS - : MAIN_STARTUP_TIMEOUT_MS, + .connect_timeout_ms = role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? MAIN_HOOK_CONNECT_TIMEOUT_MS + : MAIN_CONNECT_TIMEOUT_MS, + .startup_timeout_ms = role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? MAIN_HOOK_STARTUP_TIMEOUT_MS + : MAIN_STARTUP_TIMEOUT_MS, }; cbm_daemon_bootstrap_result_t bootstrap_result; cbm_daemon_bootstrap_status_t bootstrap_status = cbm_daemon_bootstrap_execute(&bootstrap_config, &bootstrap_result); cbm_daemon_ipc_endpoint_free(endpoint); - if (bootstrap_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || - !bootstrap_result.client) { - (void)main_version_cohort_close(&client_cohort_lease, - &client_cohort_manager); - return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS - : EXIT_FAILURE; + if (bootstrap_status != CBM_DAEMON_BOOTSTRAP_CONNECTED || !bootstrap_result.client) { + (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); + return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS : EXIT_FAILURE; } g_daemon_client = bootstrap_result.client; if (role == CBM_DAEMON_PROCESS_MCP_CLIENT && - !main_set_client_context( - g_daemon_client, NULL, tool_profile, NULL, NULL, - MAIN_CONNECT_TIMEOUT_MS)) { - (void)fprintf(stderr, - "codebase-memory-mcp: daemon session context was rejected\n"); - (void)cbm_daemon_runtime_client_close(g_daemon_client, - MAIN_CLOSE_TIMEOUT_MS); + !main_set_client_context(g_daemon_client, NULL, tool_profile, NULL, NULL, + MAIN_CONNECT_TIMEOUT_MS)) { + (void)fprintf(stderr, "codebase-memory-mcp: daemon session context was rejected\n"); + (void)cbm_daemon_runtime_client_close(g_daemon_client, MAIN_CLOSE_TIMEOUT_MS); g_daemon_client = NULL; - (void)main_version_cohort_close(&client_cohort_lease, - &client_cohort_manager); + (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); return EXIT_FAILURE; } @@ -1747,48 +1684,36 @@ int main(int argc, char **argv) { * conflicting binary must be observationally read-only: applying its * flags before bootstrap could reconfigure the already-running daemon * even though that client was then rejected. */ - if (role == CBM_DAEMON_PROCESS_MCP_CLIENT && - cbm_mcp_tool_profile_allows_http(tool_profile)) { + if (role == CBM_DAEMON_PROCESS_MCP_CLIENT && cbm_mcp_tool_profile_allows_http(tool_profile)) { bool ui_enabled = false; int ui_port = 0; bool explicitly_enabled = false; - uint8_t update_mask = parse_ui_flags( - argc, argv, &ui_enabled, &ui_port, &explicitly_enabled); - if (update_mask != 0 && - cbm_daemon_application_client_set_ui_config( - g_daemon_client, update_mask, ui_enabled, ui_port, - MAIN_CONNECT_TIMEOUT_MS) != - CBM_DAEMON_RUNTIME_APPLICATION_OK) { - (void)fprintf( - stderr, - "codebase-memory-mcp: daemon UI configuration update failed\n"); - (void)cbm_daemon_runtime_client_close(g_daemon_client, - MAIN_CLOSE_TIMEOUT_MS); + uint8_t update_mask = + parse_ui_flags(argc, argv, &ui_enabled, &ui_port, &explicitly_enabled); + if (update_mask != 0 && cbm_daemon_application_client_set_ui_config( + g_daemon_client, update_mask, ui_enabled, ui_port, + MAIN_CONNECT_TIMEOUT_MS) != CBM_DAEMON_RUNTIME_APPLICATION_OK) { + (void)fprintf(stderr, "codebase-memory-mcp: daemon UI configuration update failed\n"); + (void)cbm_daemon_runtime_client_close(g_daemon_client, MAIN_CLOSE_TIMEOUT_MS); g_daemon_client = NULL; - (void)main_version_cohort_close(&client_cohort_lease, - &client_cohort_manager); + (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); return EXIT_FAILURE; } if (explicitly_enabled && CBM_EMBEDDED_FILE_COUNT == 0) { - (void)fprintf(stderr, - "codebase-memory-mcp: --ui requested, but this binary was built " - "without the embedded UI; rebuild with `make -f Makefile.cbm " - "cbm-with-ui`.\n"); + (void)fprintf(stderr, "codebase-memory-mcp: --ui requested, but this binary was built " + "without the embedded UI; rebuild with `make -f Makefile.cbm " + "cbm-with-ui`.\n"); } } #ifndef _WIN32 if (!client_start_parent_watchdog(process_initial_ppid)) { - (void)fprintf(stderr, - "codebase-memory-mcp: parent-death watchdog could not start\n"); - (void)cbm_daemon_runtime_client_close(g_daemon_client, - role == CBM_DAEMON_PROCESS_HOOK_CLIENT - ? MAIN_HOOK_CLOSE_TIMEOUT_MS - : MAIN_CLOSE_TIMEOUT_MS); + (void)fprintf(stderr, "codebase-memory-mcp: parent-death watchdog could not start\n"); + (void)cbm_daemon_runtime_client_close( + g_daemon_client, role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? MAIN_HOOK_CLOSE_TIMEOUT_MS + : MAIN_CLOSE_TIMEOUT_MS); g_daemon_client = NULL; - (void)main_version_cohort_close(&client_cohort_lease, - &client_cohort_manager); - return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS - : EXIT_FAILURE; + (void)main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); + return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS : EXIT_FAILURE; } #endif @@ -1801,22 +1726,18 @@ int main(int argc, char **argv) { #ifdef _WIN32 cbm_hook_augment_arm_deadline(); #endif - result = main_run_hook_frontend(g_daemon_client, hook_event, - hook_dialect); - (void)cbm_daemon_runtime_client_close(g_daemon_client, - MAIN_HOOK_CLOSE_TIMEOUT_MS); + result = main_run_hook_frontend(g_daemon_client, hook_event, hook_dialect); + (void)cbm_daemon_runtime_client_close(g_daemon_client, MAIN_HOOK_CLOSE_TIMEOUT_MS); g_daemon_client = NULL; } else { setup_signal_handlers(); - result = cbm_daemon_frontend_mcp_run( - g_daemon_client, client_cohort_manager, stdin, stdout); + result = cbm_daemon_frontend_mcp_run(g_daemon_client, client_cohort_manager, stdin, stdout); g_daemon_client = NULL; /* frontend consumed the handle */ } - bool client_cohort_cleanup = main_version_cohort_close( - &client_cohort_lease, &client_cohort_manager); + bool client_cohort_cleanup = + main_version_cohort_close(&client_cohort_lease, &client_cohort_manager); atomic_store(&g_shutdown, 1); - if (!client_cohort_cleanup && - role != CBM_DAEMON_PROCESS_HOOK_CLIENT) { + if (!client_cohort_cleanup && role != CBM_DAEMON_PROCESS_HOOK_CLIENT) { return EXIT_FAILURE; } return result < 0 ? EXIT_FAILURE : result; diff --git a/src/mcp/index_supervisor.c b/src/mcp/index_supervisor.c index 667acef59..e5dd53d34 100644 --- a/src/mcp/index_supervisor.c +++ b/src/mcp/index_supervisor.c @@ -31,8 +31,7 @@ #define worker_getpid getpid #endif -_Static_assert(CBM_INDEX_WORKER_BUILD_FINGERPRINT_SIZE == - CBM_DAEMON_BUILD_FINGERPRINT_SIZE, +_Static_assert(CBM_INDEX_WORKER_BUILD_FINGERPRINT_SIZE == CBM_DAEMON_BUILD_FINGERPRINT_SIZE, "worker and daemon build fingerprint sizes must match"); /* ── Worker-role state ────────────────────────────────────────────── */ @@ -137,9 +136,8 @@ static bool worker_parse_positive_size(const char *text, size_t *value_out) { return true; } -cbm_index_worker_argv_status_t -cbm_index_worker_parse_process_argv(int argc, char *const argv[], - cbm_index_worker_invocation_t *invocation_out) { +cbm_index_worker_argv_status_t cbm_index_worker_parse_process_argv( + int argc, char *const argv[], cbm_index_worker_invocation_t *invocation_out) { if (invocation_out) { memset(invocation_out, 0, sizeof(*invocation_out)); } @@ -153,13 +151,12 @@ cbm_index_worker_parse_process_argv(int argc, char *const argv[], if (!contains_worker_role) { return CBM_INDEX_WORKER_ARGV_NOT_WORKER; } - if (!invocation_out || argc < 9 || !argv || !argv[0] || !argv[0][0] || - !argv[1] || strcmp(argv[1], "cli") != 0 || !argv[2] || - strcmp(argv[2], "--index-worker") != 0 || !argv[3] || - strcmp(argv[3], CBM_INDEX_WORKER_BUILD_ARG) != 0 || + if (!invocation_out || argc < 9 || !argv || !argv[0] || !argv[0][0] || !argv[1] || + strcmp(argv[1], "cli") != 0 || !argv[2] || strcmp(argv[2], "--index-worker") != 0 || + !argv[3] || strcmp(argv[3], CBM_INDEX_WORKER_BUILD_ARG) != 0 || !worker_fingerprint_valid(argv[4]) || !argv[5] || - strcmp(argv[5], "index_repository") != 0 || !argv[6] || !argv[6][0] || - !argv[7] || strcmp(argv[7], "--response-out") != 0 || !argv[8] || !argv[8][0]) { + strcmp(argv[5], "index_repository") != 0 || !argv[6] || !argv[6][0] || !argv[7] || + strcmp(argv[7], "--response-out") != 0 || !argv[8] || !argv[8][0]) { return CBM_INDEX_WORKER_ARGV_INVALID; } @@ -169,16 +166,14 @@ cbm_index_worker_parse_process_argv(int argc, char *const argv[], .response_out = argv[8], }; int next = 9; - if (next < argc && argv[next] && - strcmp(argv[next], CBM_INDEX_WORKER_MEMORY_BUDGET_ARG) == 0) { + if (next < argc && argv[next] && strcmp(argv[next], CBM_INDEX_WORKER_MEMORY_BUDGET_ARG) == 0) { if (next + 1 >= argc || !worker_parse_positive_size(argv[next + 1], &parsed.memory_budget_bytes)) { return CBM_INDEX_WORKER_ARGV_INVALID; } next += 2; } - if (next < argc && argv[next] && - strcmp(argv[next], CBM_INDEX_WORKER_SINGLE_THREAD_ARG) == 0) { + if (next < argc && argv[next] && strcmp(argv[next], CBM_INDEX_WORKER_SINGLE_THREAD_ARG) == 0) { parsed.single_thread = true; next++; } @@ -189,8 +184,7 @@ cbm_index_worker_parse_process_argv(int argc, char *const argv[], parsed.marker_file = argv[next + 1]; next += 2; } - if (next < argc && argv[next] && - strcmp(argv[next], CBM_INDEX_WORKER_QUARANTINE_ARG) == 0) { + if (next < argc && argv[next] && strcmp(argv[next], CBM_INDEX_WORKER_QUARANTINE_ARG) == 0) { if (next + 1 >= argc || !argv[next + 1] || !argv[next + 1][0]) { return CBM_INDEX_WORKER_ARGV_INVALID; } @@ -267,8 +261,8 @@ bool cbm_index_supervisor_should_wrap(void) { static bool supervisor_disable_requested(void) { char supervisor_setting[CBM_SZ_32] = {0}; - return cbm_safe_getenv("CBM_INDEX_SUPERVISOR", supervisor_setting, - sizeof(supervisor_setting), NULL) && + return cbm_safe_getenv("CBM_INDEX_SUPERVISOR", supervisor_setting, sizeof(supervisor_setting), + NULL) && strcmp(supervisor_setting, "0") == 0; } @@ -567,10 +561,10 @@ static void worker_terminal_log(cbm_index_worker_handle_t *handle) { } } -int cbm_index_worker_start_with_log( - const char *args_json, size_t memory_budget_bytes, bool single_thread, - const char *marker_file, const char *quarantine_file, cbm_proc_log_cb log_callback, - void *log_context, cbm_index_worker_handle_t **handle_out) { +int cbm_index_worker_start_with_log(const char *args_json, size_t memory_budget_bytes, + bool single_thread, const char *marker_file, + const char *quarantine_file, cbm_proc_log_cb log_callback, + void *log_context, cbm_index_worker_handle_t **handle_out) { if (handle_out) { *handle_out = NULL; } @@ -760,18 +754,18 @@ void cbm_index_worker_destroy(cbm_index_worker_handle_t *handle) { free(handle); } -int cbm_index_spawn_worker_with_log_cancel( - const char *args_json, bool single_thread, const char *marker_file, - const char *quarantine_file, cbm_proc_log_cb log_callback, void *log_context, - const atomic_int *cancel_requested, cbm_index_worker_result_t *result) { +int cbm_index_spawn_worker_with_log_cancel(const char *args_json, bool single_thread, + const char *marker_file, const char *quarantine_file, + cbm_proc_log_cb log_callback, void *log_context, + const atomic_int *cancel_requested, + cbm_index_worker_result_t *result) { if (!result) { return -1; } worker_result_init(result); cbm_index_worker_handle_t *handle = NULL; - if (cbm_index_worker_start_with_log(args_json, 0, single_thread, marker_file, - quarantine_file, log_callback, log_context, - &handle) != 0) { + if (cbm_index_worker_start_with_log(args_json, 0, single_thread, marker_file, quarantine_file, + log_callback, log_context, &handle) != 0) { return -1; } const cbm_index_worker_result_t *cached = NULL; @@ -797,19 +791,19 @@ int cbm_index_spawn_worker_with_log_cancel( return 0; } -int cbm_index_spawn_worker_with_log( - const char *args_json, bool single_thread, const char *marker_file, - const char *quarantine_file, cbm_proc_log_cb log_callback, void *log_context, - cbm_index_worker_result_t *result) { - return cbm_index_spawn_worker_with_log_cancel( - args_json, single_thread, marker_file, quarantine_file, log_callback, - log_context, NULL, result); +int cbm_index_spawn_worker_with_log(const char *args_json, bool single_thread, + const char *marker_file, const char *quarantine_file, + cbm_proc_log_cb log_callback, void *log_context, + cbm_index_worker_result_t *result) { + return cbm_index_spawn_worker_with_log_cancel(args_json, single_thread, marker_file, + quarantine_file, log_callback, log_context, NULL, + result); } int cbm_index_spawn_worker(const char *args_json, bool single_thread, const char *marker_file, const char *quarantine_file, cbm_index_worker_result_t *result) { - return cbm_index_spawn_worker_with_log(args_json, single_thread, marker_file, - quarantine_file, NULL, NULL, result); + return cbm_index_spawn_worker_with_log(args_json, single_thread, marker_file, quarantine_file, + NULL, NULL, result); } void cbm_index_worker_result_free(cbm_index_worker_result_t *result) { diff --git a/src/mcp/index_supervisor.h b/src/mcp/index_supervisor.h index ac271bf9d..5f8f57018 100644 --- a/src/mcp/index_supervisor.h +++ b/src/mcp/index_supervisor.h @@ -77,9 +77,8 @@ typedef enum { * outside this exact shape is INVALID, never an ordinary CLI request. A valid * shape is admitted only when its expected fingerprint matches the image * captured by this process before stateful initialization. */ -cbm_index_worker_argv_status_t -cbm_index_worker_parse_process_argv(int argc, char *const argv[], - cbm_index_worker_invocation_t *invocation_out); +cbm_index_worker_argv_status_t cbm_index_worker_parse_process_argv( + int argc, char *const argv[], cbm_index_worker_invocation_t *invocation_out); const char *cbm_index_worker_argv_status_message(cbm_index_worker_argv_status_t status); /* Host marking (#845): the supervisor gate is OPT-IN per process. Only the real @@ -118,8 +117,8 @@ typedef struct { bool tree_quiesced; bool supervision_failed; bool response_rejected; /* clean worker exceeded the bounded response protocol */ - char *response; /* worker result only after a contained, uncancelled CLEAN exit; - * borrowed for async polls, caller-owned from the sync wrapper */ + char *response; /* worker result only after a contained, uncancelled CLEAN exit; + * borrowed for async polls, caller-owned from the sync wrapper */ } cbm_index_worker_result_t; /* Daemon-owned, nonblocking supervisor for one contained worker process tree. */ @@ -143,10 +142,10 @@ int cbm_index_worker_start(const char *args_json, size_t memory_budget_bytes, bo * remains caller-owned until terminal. No process-global sink is installed. * Poll stays bounded while draining log bursts and does not report terminal * until every completed worker log line has reached this callback. */ -int cbm_index_worker_start_with_log( - const char *args_json, size_t memory_budget_bytes, bool single_thread, - const char *marker_file, const char *quarantine_file, cbm_proc_log_cb log_callback, - void *log_context, cbm_index_worker_handle_t **handle_out); +int cbm_index_worker_start_with_log(const char *args_json, size_t memory_budget_bytes, + bool single_thread, const char *marker_file, + const char *quarantine_file, cbm_proc_log_cb log_callback, + void *log_context, cbm_index_worker_handle_t **handle_out); /* Strictly nonblocking and called by one owner thread/event loop. result_out is * set to NULL while running and to a borrowed immutable cached result only at @@ -187,19 +186,19 @@ void cbm_index_worker_destroy(cbm_index_worker_handle_t *handle); int cbm_index_spawn_worker(const char *args_json, bool single_thread, const char *marker_file, const char *quarantine_file, cbm_index_worker_result_t *result); -int cbm_index_spawn_worker_with_log( - const char *args_json, bool single_thread, const char *marker_file, - const char *quarantine_file, cbm_proc_log_cb log_callback, void *log_context, - cbm_index_worker_result_t *result); +int cbm_index_spawn_worker_with_log(const char *args_json, bool single_thread, + const char *marker_file, const char *quarantine_file, + cbm_proc_log_cb log_callback, void *log_context, + cbm_index_worker_result_t *result); /* Synchronous request-owned variant. A nonzero cancellation flag is forwarded * once to the contained worker; polling continues until the complete process * tree is terminal, so callers never drop supervision authority on cancel. */ -int cbm_index_spawn_worker_with_log_cancel( - const char *args_json, bool single_thread, const char *marker_file, - const char *quarantine_file, cbm_proc_log_cb log_callback, - void *log_context, const atomic_int *cancel_requested, - cbm_index_worker_result_t *result); +int cbm_index_spawn_worker_with_log_cancel(const char *args_json, bool single_thread, + const char *marker_file, const char *quarantine_file, + cbm_proc_log_cb log_callback, void *log_context, + const atomic_int *cancel_requested, + cbm_index_worker_result_t *result); void cbm_index_worker_result_free(cbm_index_worker_result_t *result); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index b43ba5bbf..47aed5b83 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -43,6 +43,7 @@ enum { #include "store/store.h" #include #include "cypher/cypher.h" +#include "discover/discover.h" #include "pipeline/pipeline.h" #include "pipeline/pass_cross_repo.h" #include "git/git_context.h" @@ -56,6 +57,7 @@ enum { #include "foundation/compat_thread.h" #include "foundation/log.h" #include "foundation/limits.h" +#include "foundation/subprocess.h" #include "mcp/index_supervisor.h" #include "mcp/compact_out.h" #include "foundation/str_util.h" @@ -1414,9 +1416,12 @@ struct cbm_mcp_server { cbm_thread_t autoindex_tid; bool autoindex_active; /* true if auto-index thread was started */ - /* Active pipeline tracking for cancellation support */ + /* Request-scoped cancellation. The flag is shared by every cancellable + * operation reached during one tool dispatch; active_pipeline remains a + * diagnostic pointer for index_repository only. */ + cbm_mutex_t request_scope_mutex; + unsigned int request_scope_depth; atomic_int pipeline_cancel_requested; - atomic_int active_pipeline_running; cbm_pipeline_t *active_pipeline; /* non-NULL while index_repository runs */ int64_t active_request_id; /* JSON-RPC id of the in-progress tool call */ char *active_request_id_str; /* string JSON-RPC id of the in-progress tool call */ @@ -1428,8 +1433,8 @@ cbm_mcp_server_t *cbm_mcp_server_new(const char *store_path) { if (!srv) { return NULL; } + cbm_mutex_init(&srv->request_scope_mutex); atomic_init(&srv->pipeline_cancel_requested, 0); - atomic_init(&srv->active_pipeline_running, 0); /* If a store_path is given, open that project directly. * Otherwise, create an in-memory store for test/embedded use. */ @@ -1524,8 +1529,7 @@ void cbm_mcp_server_set_background_tasks(cbm_mcp_server_t *srv, bool enabled) { } } -void cbm_mcp_server_set_index_executor(cbm_mcp_server_t *srv, - cbm_mcp_index_executor_fn executor, +void cbm_mcp_server_set_index_executor(cbm_mcp_server_t *srv, cbm_mcp_index_executor_fn executor, void *context) { if (srv) { srv->index_executor = executor; @@ -1533,8 +1537,7 @@ void cbm_mcp_server_set_index_executor(cbm_mcp_server_t *srv, } } -void cbm_mcp_server_set_index_log_callback(cbm_mcp_server_t *srv, - cbm_proc_log_cb callback, +void cbm_mcp_server_set_index_log_callback(cbm_mcp_server_t *srv, cbm_proc_log_cb callback, void *context) { if (srv) { srv->index_log_callback = callback; @@ -1542,9 +1545,9 @@ void cbm_mcp_server_set_index_log_callback(cbm_mcp_server_t *srv, } } -void cbm_mcp_server_set_project_mutation_guard( - cbm_mcp_server_t *srv, cbm_mcp_project_mutation_begin_fn begin, - cbm_mcp_project_mutation_end_fn end, void *context) { +void cbm_mcp_server_set_project_mutation_guard(cbm_mcp_server_t *srv, + cbm_mcp_project_mutation_begin_fn begin, + cbm_mcp_project_mutation_end_fn end, void *context) { if (!srv) { return; } @@ -1559,8 +1562,7 @@ void cbm_mcp_server_set_project_mutation_guard( } static bool mcp_project_mutation_begin(cbm_mcp_server_t *srv, const char *project) { - return !srv->mutation_begin || - srv->mutation_begin(srv->mutation_context, project); + return !srv->mutation_begin || srv->mutation_begin(srv->mutation_context, project); } static void mcp_project_mutation_end(cbm_mcp_server_t *srv, const char *project) { @@ -1585,6 +1587,7 @@ void cbm_mcp_server_free(cbm_mcp_server_t *srv) { free(srv->current_project); free(srv->allowed_root); free(srv->active_request_id_str); + cbm_mutex_destroy(&srv->request_scope_mutex); free(srv); } @@ -1619,8 +1622,7 @@ bool cbm_mcp_server_has_cached_store(cbm_mcp_server_t *srv) { } bool cbm_mcp_server_release_pristine_memory_store(cbm_mcp_server_t *srv) { - const char *db_path = - srv && srv->store ? cbm_store_db_path(srv->store) : NULL; + const char *db_path = srv && srv->store ? cbm_store_db_path(srv->store) : NULL; if (!srv || !srv->owns_store || !srv->store || srv->current_project || srv->store_last_used != 0 || db_path != NULL) { return false; @@ -1638,16 +1640,51 @@ bool cbm_mcp_server_cancel_active(cbm_mcp_server_t *srv) { if (!srv) { return false; } - if (atomic_load_explicit(&srv->active_pipeline_running, memory_order_acquire) == 0) { + cbm_mutex_lock(&srv->request_scope_mutex); + bool active = srv->request_scope_depth != 0; + if (active) { + atomic_store_explicit(&srv->pipeline_cancel_requested, 1, memory_order_release); + } + cbm_mutex_unlock(&srv->request_scope_mutex); + return active; +} + +bool cbm_mcp_server_request_scope_begin(cbm_mcp_server_t *srv) { + if (!srv) { return false; } - atomic_store_explicit(&srv->pipeline_cancel_requested, 1, memory_order_release); - return true; + cbm_mutex_lock(&srv->request_scope_mutex); + bool available = srv->request_scope_depth < UINT_MAX; + if (available) { + if (srv->request_scope_depth == 0) { + atomic_store_explicit(&srv->pipeline_cancel_requested, 0, memory_order_release); + } + srv->request_scope_depth++; + } + cbm_mutex_unlock(&srv->request_scope_mutex); + return available; +} + +void cbm_mcp_server_request_scope_end(cbm_mcp_server_t *srv) { + if (!srv) { + return; + } + cbm_mutex_lock(&srv->request_scope_mutex); + if (srv->request_scope_depth > 0) { + srv->request_scope_depth--; + if (srv->request_scope_depth == 0) { + atomic_store_explicit(&srv->pipeline_cancel_requested, 0, memory_order_release); + } + } + cbm_mutex_unlock(&srv->request_scope_mutex); +} + +static bool mcp_request_cancelled(const cbm_mcp_server_t *srv) { + return srv && atomic_load_explicit(&srv->pipeline_cancel_requested, memory_order_acquire) != 0; } void cbm_mcp_server_set_quarantine_test_hook(cbm_mcp_server_t *srv, - cbm_mcp_quarantine_test_hook_fn hook, - void *context) { + cbm_mcp_quarantine_test_hook_fn hook, void *context) { if (!srv) { return; } @@ -1697,20 +1734,18 @@ static bool db_internal_project_name(const char *full_path, char *name_out, size * passed name (drifted filename). Defined after is_project_db_file below. */ static cbm_store_t *resolve_store_fallback_scan(const char *project); -static bool reserve_unique_corrupt_pending(const char *path, char *pending, - size_t pending_size, char *backup, - size_t backup_size) { +static bool reserve_unique_corrupt_pending(const char *path, char *pending, size_t pending_size, + char *backup, size_t backup_size) { static atomic_uint_fast64_t sequence = 0; for (unsigned int attempt = 0; attempt < 128; attempt++) { uint64_t token = cbm_now_ns() ^ ((uint64_t)(unsigned int)getpid() << 32) ^ atomic_fetch_add_explicit(&sequence, 1, memory_order_relaxed); - int backup_written = snprintf(backup, backup_size, "%s.corrupt.%016llx", path, - (unsigned long long)token); - int pending_written = snprintf(pending, pending_size, - "%s.corrupt.pending.%016llx", path, + int backup_written = + snprintf(backup, backup_size, "%s.corrupt.%016llx", path, (unsigned long long)token); + int pending_written = snprintf(pending, pending_size, "%s.corrupt.pending.%016llx", path, (unsigned long long)token); - if (backup_written <= 0 || (size_t)backup_written >= backup_size || - pending_written <= 0 || (size_t)pending_written >= pending_size) { + if (backup_written <= 0 || (size_t)backup_written >= backup_size || pending_written <= 0 || + (size_t)pending_written >= pending_size) { return false; } if (cbm_file_exists(backup)) { @@ -1718,8 +1753,8 @@ static bool reserve_unique_corrupt_pending(const char *path, char *pending, } #ifdef _WIN32 wchar_t *wide = cbm_utf8_to_wide(pending); - HANDLE file = wide ? CreateFileW(wide, GENERIC_READ | GENERIC_WRITE, 0, NULL, - CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL) + HANDLE file = wide ? CreateFileW(wide, GENERIC_READ | GENERIC_WRITE, 0, NULL, CREATE_NEW, + FILE_ATTRIBUTE_NORMAL, NULL) : INVALID_HANDLE_VALUE; DWORD create_error = file == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS; free(wide); @@ -1818,31 +1853,29 @@ static bool quarantine_step_allowed(cbm_mcp_server_t *srv, const char *step) { * and only then remove the corrupt live generation. A crash can therefore * leave the live DB, the completed backup, or both, but never destroys the * only recoverable generation. */ -static bool quarantine_corrupt_store(cbm_mcp_server_t *srv, const char *project, - const char *path, +static bool quarantine_corrupt_store(cbm_mcp_server_t *srv, const char *project, const char *path, char *backup_out, size_t backup_out_size) { char backup[CBM_SZ_2K]; char pending[CBM_SZ_2K]; - if (!reserve_unique_corrupt_pending(path, pending, sizeof(pending), backup, - sizeof(backup))) { - cbm_log_error("store.auto_clean_failed", "project", project, "path", path, - "reason", "cannot reserve unique backup"); + if (!reserve_unique_corrupt_pending(path, pending, sizeof(pending), backup, sizeof(backup))) { + cbm_log_error("store.auto_clean_failed", "project", project, "path", path, "reason", + "cannot reserve unique backup"); return false; } if (cbm_store_backup_path(path, pending) != CBM_STORE_OK || cbm_store_prepare_path_for_replace(pending) != CBM_STORE_OK) { discard_corrupt_pending(pending); - cbm_log_error("store.auto_clean_failed", "project", project, "path", path, - "reason", "cannot create self-contained recovery snapshot"); + cbm_log_error("store.auto_clean_failed", "project", project, "path", path, "reason", + "cannot create self-contained recovery snapshot"); return false; } cbm_store_t *snapshot = cbm_store_open_path_query(pending); if (!snapshot) { discard_corrupt_pending(pending); - cbm_log_error("store.auto_clean_failed", "project", project, "path", path, - "reason", "recovery snapshot cannot be reopened"); + cbm_log_error("store.auto_clean_failed", "project", project, "path", path, "reason", + "recovery snapshot cannot be reopened"); return false; } cbm_store_close(snapshot); @@ -1850,28 +1883,26 @@ static bool quarantine_corrupt_store(cbm_mcp_server_t *srv, const char *project, if (!quarantine_step_allowed(srv, "before_snapshot_publish") || !publish_corrupt_backup(pending, backup)) { discard_corrupt_pending(pending); - cbm_log_error("store.auto_clean_failed", "project", project, "path", path, - "reason", "cannot atomically publish recovery snapshot"); + cbm_log_error("store.auto_clean_failed", "project", project, "path", path, "reason", + "cannot atomically publish recovery snapshot"); return false; } discard_corrupt_pending(pending); if (!quarantine_step_allowed(srv, "after_snapshot_publish")) { - cbm_log_error("store.auto_clean_failed", "project", project, "path", path, - "reason", "backup complete; live generation retained", "backup", - backup); + cbm_log_error("store.auto_clean_failed", "project", project, "path", path, "reason", + "backup complete; live generation retained", "backup", backup); return false; } if (cbm_unlink(path) != 0 && errno != ENOENT) { - cbm_log_error("store.auto_clean_failed", "project", project, "path", path, - "reason", "backup complete; live database removal failed", "backup", - backup); + cbm_log_error("store.auto_clean_failed", "project", project, "path", path, "reason", + "backup complete; live database removal failed", "backup", backup); return false; } if (cbm_remove_db_sidecars(path) != 0) { - cbm_log_error("store.auto_clean_sidecars", "project", project, "path", path, - "reason", "backup complete; stale sidecar cleanup deferred"); + cbm_log_error("store.auto_clean_sidecars", "project", project, "path", path, "reason", + "backup complete; stale sidecar cleanup deferred"); } if (backup_out && backup_out_size > 0) { @@ -1928,11 +1959,11 @@ static cbm_store_t *resolve_store_internal(cbm_mcp_server_t *srv, const char *pr cbm_store_close(srv->store); srv->store = NULL; char backup[CBM_SZ_2K] = {0}; - bool quarantined = quarantine_corrupt_store( - srv, project, path, backup, sizeof(backup)); - cbm_log_error("store.auto_clean", "project", project, "path", path, - "action", quarantined ? "corrupt generation quarantined" - : "corrupt generation preserved", + bool quarantined = + quarantine_corrupt_store(srv, project, path, backup, sizeof(backup)); + cbm_log_error("store.auto_clean", "project", project, "path", path, "action", + quarantined ? "corrupt generation quarantined" + : "corrupt generation preserved", "backup", quarantined ? backup : "none"); } if (!mutation_already_held) { @@ -3986,8 +4017,8 @@ static char *handle_delete_project(cbm_mcp_server_t *srv, const char *args) { } if (!mcp_project_mutation_begin(srv, name)) { free(name); - return cbm_mcp_text_result( - "project operation cancelled or blocked by an active index", true); + return cbm_mcp_text_result("project operation cancelled or blocked by an active index", + true); } /* Close store if it's the project being deleted */ @@ -5344,8 +5375,7 @@ static unsigned char cross_repo_project_lock_fold(unsigned char ch) { /* Match daemon/project_lock.c's OS-key identity exactly: only ASCII A-Z folds. * The raw strcmp tie-break gives qsort a total, input-order-independent order * while keeping the caller's original project spelling as the lease value. */ -static int cross_repo_project_lock_key_compare_values(const char *left, - const char *right) { +static int cross_repo_project_lock_key_compare_values(const char *left, const char *right) { const unsigned char *left_cursor = (const unsigned char *)left; const unsigned char *right_cursor = (const unsigned char *)right; while (*left_cursor && *right_cursor) { @@ -5369,8 +5399,7 @@ static int cross_repo_project_lock_key_compare(const void *left, const void *rig return cross_repo_project_lock_key_compare_values(*left_key, *right_key); } -static bool cross_repo_project_lock_keys_equivalent(const char *left, - const char *right) { +static bool cross_repo_project_lock_keys_equivalent(const char *left, const char *right) { const unsigned char *left_cursor = (const unsigned char *)left; const unsigned char *right_cursor = (const unsigned char *)right; while (*left_cursor && *right_cursor) { @@ -5390,9 +5419,8 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path if (name_override && name_override[0] && !cbm_validate_project_name(name_override)) { return cbm_mcp_text_result("invalid project name", true); } - char *project = name_override && name_override[0] - ? heap_strdup(name_override) - : cbm_project_name_from_path(repo_path); + char *project = name_override && name_override[0] ? heap_strdup(name_override) + : cbm_project_name_from_path(repo_path); if (!project) { return cbm_mcp_text_result("cannot derive project name", true); } @@ -5447,24 +5475,20 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path free(lease_keys); yyjson_doc_free(jdoc); free(project); - return cbm_mcp_text_result("target_projects must contain valid project names or '*'", - true); + return cbm_mcp_text_result("target_projects must contain valid project names or '*'", true); } if (all_projects && tp_count != 1) { free(targets); free(lease_keys); yyjson_doc_free(jdoc); free(project); - return cbm_mcp_text_result( - "target_projects wildcard '*' must be the only entry", true); + return cbm_mcp_text_result("target_projects wildcard '*' must be the only entry", true); } if (!all_projects) { - qsort(targets, (size_t)tp_count, sizeof(*targets), - cross_repo_project_key_compare); + qsort(targets, (size_t)tp_count, sizeof(*targets), cross_repo_project_key_compare); int unique_count = 0; for (int i = 0; i < tp_count; i++) { - if (unique_count == 0 || - strcmp(targets[i], targets[unique_count - 1]) != 0) { + if (unique_count == 0 || strcmp(targets[i], targets[unique_count - 1]) != 0) { targets[unique_count++] = targets[i]; } } @@ -5483,41 +5507,35 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path cross_repo_project_lock_key_compare); int unique_count = 0; for (int i = 0; i < lease_count; i++) { - if (unique_count == 0 || - !cross_repo_project_lock_keys_equivalent( - lease_keys[i], lease_keys[unique_count - 1])) { + if (unique_count == 0 || !cross_repo_project_lock_keys_equivalent( + lease_keys[i], lease_keys[unique_count - 1])) { lease_keys[unique_count++] = lease_keys[i]; } } lease_count = unique_count; } - atomic_store_explicit(&srv->pipeline_cancel_requested, 0, memory_order_release); - atomic_store_explicit(&srv->active_pipeline_running, 1, memory_order_release); int held_count = 0; - while (held_count < lease_count && - mcp_project_mutation_begin(srv, lease_keys[held_count])) { + while (held_count < lease_count && mcp_project_mutation_begin(srv, lease_keys[held_count])) { held_count++; } - bool cancelled = atomic_load_explicit(&srv->pipeline_cancel_requested, - memory_order_acquire) != 0; + bool cancelled = + atomic_load_explicit(&srv->pipeline_cancel_requested, memory_order_acquire) != 0; if (held_count != lease_count || cancelled) { while (held_count > 0) { held_count--; mcp_project_mutation_end(srv, lease_keys[held_count]); } - atomic_store_explicit(&srv->active_pipeline_running, 0, memory_order_release); free(targets); free(lease_keys); yyjson_doc_free(jdoc); free(project); - return cbm_mcp_text_result( - "cross-repo operation cancelled or blocked by active indexing", true); + return cbm_mcp_text_result("cross-repo operation cancelled or blocked by active indexing", + true); } cbm_cross_repo_result_t result = cbm_cross_repo_match_cancellable( project, targets, tp_count, &srv->pipeline_cancel_requested); - atomic_store_explicit(&srv->active_pipeline_running, 0, memory_order_release); while (held_count > 0) { held_count--; mcp_project_mutation_end(srv, lease_keys[held_count]); @@ -5529,8 +5547,7 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path if (result.failed) { free(project); return cbm_mcp_text_result( - "cross-repo source or target project is missing, invalid, or not indexed", - true); + "cross-repo source or target project is missing, invalid, or not indexed", true); } int total = result.http_edges + result.async_edges + result.channel_edges + result.grpc_edges + @@ -6121,8 +6138,7 @@ static char *index_run_supervised(cbm_mcp_server_t *srv, const char *args) { cbm_index_worker_result_t wr; int rc = cbm_index_spawn_worker_with_log_cancel( args, false, NULL, NULL, srv ? srv->index_log_callback : NULL, - srv ? srv->index_log_context : NULL, - srv ? &srv->pipeline_cancel_requested : NULL, &wr); + srv ? srv->index_log_context : NULL, srv ? &srv->pipeline_cancel_requested : NULL, &wr); cbm_mcp_supervised_result_disposition_t disposition = cbm_mcp_supervised_result_disposition(rc, &wr); @@ -6197,8 +6213,7 @@ static char *index_run_supervised(cbm_mcp_server_t *srv, const char *args) { cbm_index_worker_result_t wr2; int rc2 = cbm_index_spawn_worker_with_log_cancel( args, /*single_thread=*/false, marker_path, quarantine_path, - srv ? srv->index_log_callback : NULL, - srv ? srv->index_log_context : NULL, + srv ? srv->index_log_callback : NULL, srv ? srv->index_log_context : NULL, srv ? &srv->pipeline_cancel_requested : NULL, &wr2); cbm_mcp_supervised_result_disposition_t recovery_disposition = cbm_mcp_supervised_result_disposition(rc2, &wr2); @@ -6289,8 +6304,7 @@ static char *index_run_supervised(cbm_mcp_server_t *srv, const char *args) { cbm_index_worker_result_t wrp; int rcp = cbm_index_spawn_worker_with_log_cancel( args, /*single_thread=*/false, NULL, quarantine_path, - srv ? srv->index_log_callback : NULL, - srv ? srv->index_log_context : NULL, + srv ? srv->index_log_callback : NULL, srv ? srv->index_log_context : NULL, srv ? &srv->pipeline_cancel_requested : NULL, &wrp); cbm_mcp_supervised_result_disposition_t partial_disposition = cbm_mcp_supervised_result_disposition(rcp, &wrp); @@ -6455,10 +6469,9 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { * registry only after path canonicalization and workspace authorization. */ if (srv->index_executor) { char *worker_args = index_args_with_repo_path(args, repo_path); - char *coordinated = worker_args - ? srv->index_executor(srv->index_executor_context, - repo_path, worker_args) - : NULL; + char *coordinated = + worker_args ? srv->index_executor(srv->index_executor_context, repo_path, worker_args) + : NULL; free(worker_args); free(repo_path); free(mode_str); @@ -6472,8 +6485,8 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { * execution. A supervised worker owns the OS mutation lease itself: if the * CLI parent is killed, the worker must keep project exclusion until its * parent-death watchdog reaps the complete worker tree. */ - char *mutation_project = cbm_project_name_from_path( - name_override && name_override[0] ? name_override : repo_path); + char *mutation_project = + cbm_project_name_from_path(name_override && name_override[0] ? name_override : repo_path); if (!mutation_project) { free(repo_path); free(mode_str); @@ -6495,13 +6508,7 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { free(name_override); return cbm_mcp_text_result("failed to prepare supervised index request", true); } - atomic_store_explicit(&srv->pipeline_cancel_requested, 0, - memory_order_release); - atomic_store_explicit(&srv->active_pipeline_running, 1, - memory_order_release); char *supervised = index_run_supervised(srv, worker_args); - atomic_store_explicit(&srv->active_pipeline_running, 0, - memory_order_release); free(worker_args); if (supervised) { free(mutation_project); @@ -6525,8 +6532,16 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { free(repo_path); free(mode_str); free(name_override); - return cbm_mcp_text_result( - "index operation blocked by another mutation for this project", true); + return cbm_mcp_text_result("index operation blocked by another mutation for this project", + true); + } + if (mcp_request_cancelled(srv)) { + mcp_project_mutation_end(srv, mutation_project); + free(mutation_project); + free(repo_path); + free(mode_str); + free(name_override); + return cbm_mcp_text_result("index operation cancelled for this request", true); } cbm_index_mode_t mode = CBM_MODE_FULL; @@ -6575,12 +6590,9 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { * Track active pipeline so signal handler and notifications/cancelled * can cancel it mid-run. */ cbm_pipeline_lock(); - atomic_store_explicit(&srv->pipeline_cancel_requested, 0, memory_order_release); cbm_pipeline_bind_cancel_flag(p, &srv->pipeline_cancel_requested); srv->active_pipeline = p; - atomic_store_explicit(&srv->active_pipeline_running, 1, memory_order_release); int rc = cbm_pipeline_run(p); - atomic_store_explicit(&srv->active_pipeline_running, 0, memory_order_release); srv->active_pipeline = NULL; cbm_pipeline_unlock(); @@ -8226,6 +8238,90 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { /* ── detect_changes ───────────────────────────────────────────── */ +/* Run shell-backed query helpers inside the same process-tree containment used + * by indexing. A plain popen owns only its shell stream: on disconnect there is + * no safe handle with which to stop a blocked git child or its descendants. */ +static bool mcp_command_output_path(char out[CBM_SZ_2K]) { + char directory[CBM_SZ_1K]; + const char *cache = cbm_resolve_cache_dir(); + int written; + if (cache && cache[0]) { + written = snprintf(directory, sizeof(directory), "%s/logs", cache); + if (written <= 0 || written >= (int)sizeof(directory) || !cbm_mkdir_p(directory, 0700)) { + return false; + } + } else { + written = snprintf(directory, sizeof(directory), "%s", cbm_tmpdir()); + if (written <= 0 || written >= (int)sizeof(directory)) { + return false; + } + } + written = snprintf(out, CBM_SZ_2K, "%s/.mcp-command-XXXXXX", directory); + if (written <= 0 || written >= CBM_SZ_2K) { + out[0] = '\0'; + return false; + } + int descriptor = cbm_mkstemp(out); + if (descriptor < 0) { + out[0] = '\0'; + return false; + } +#ifdef _WIN32 + (void)_close(descriptor); +#else + (void)close(descriptor); +#endif + return true; +} + +static int mcp_run_shell_command_cancellable(cbm_mcp_server_t *srv, const char *command, + char output_path[CBM_SZ_2K], + cbm_proc_result_t *result_out) { + if (!srv || !command || !output_path || !result_out || !mcp_command_output_path(output_path)) { + return -1; + } +#ifdef _WIN32 + const char *shell = getenv("COMSPEC"); + if (!shell || !shell[0]) { + shell = "cmd.exe"; + } + const char *argv[] = {shell, "/D", "/S", "/C", command, NULL}; +#else + const char *shell = "/bin/sh"; + const char *argv[] = {"sh", "-c", command, NULL}; +#endif + cbm_proc_opts_t options = { + .bin = shell, + .argv = argv, + .log_file = output_path, + .quiet_timeout_ms = 0, + .cancel_grace_ms = CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS, + .delete_log_on_exit = false, + }; + cbm_subprocess_t *process = NULL; + if (cbm_subprocess_spawn(&options, &process) != 0) { + (void)cbm_unlink(output_path); + output_path[0] = '\0'; + return -1; + } + + cbm_proc_poll_t state; + for (;;) { + if (mcp_request_cancelled(srv)) { + (void)cbm_subprocess_request_cancel(process); + } + state = cbm_subprocess_poll(process, result_out); + if (state != CBM_PROC_POLL_RUNNING) { + break; + } + cbm_usleep(10000); + } + bool contained = state == CBM_PROC_POLL_TERMINAL && result_out->tree_quiesced && + !result_out->supervision_failed; + cbm_subprocess_destroy(process); + return contained ? 0 : -1; +} + /* Find symbols defined in a file and add them to the impacted array. */ static void detect_add_impacted_symbols(cbm_store_t *store, const char *project, const char *file, yyjson_mut_doc *doc, yyjson_mut_val *impacted) { @@ -8329,18 +8425,39 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { root_path, base_branch, root_path, root_path); #endif - FILE *fp = cbm_popen(cmd, "r"); - if (!fp) { + char output_path[CBM_SZ_2K] = {0}; + cbm_proc_result_t git_result = {0}; + int git_run = mcp_run_shell_command_cancellable(srv, cmd, output_path, &git_result); + bool git_cancelled = git_result.cancellation_requested || mcp_request_cancelled(srv); + if (git_cancelled) { + (void)cbm_unlink(output_path); + free(root_path); + free(project); + free(base_branch); + free(scope); + return cbm_mcp_text_result("detect_changes cancelled for this request", true); + } + if (git_run != 0) { + (void)cbm_unlink(output_path); char errmsg[CBM_SZ_256]; snprintf(errmsg, sizeof(errmsg), - "git diff failed: cannot execute command (%s). Check that git is installed.", - strerror(errno)); + "git diff failed: the contained command could not complete. " + "Check that git is installed."); free(root_path); free(project); free(base_branch); free(scope); return cbm_mcp_text_result(errmsg, true); } + FILE *fp = cbm_fopen(output_path, "rb"); + if (!fp) { + (void)cbm_unlink(output_path); + free(root_path); + free(project); + free(base_branch); + free(scope); + return cbm_mcp_text_result("git diff failed: contained output could not be read", true); + } yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root_obj = yyjson_mut_obj(doc); @@ -8390,7 +8507,9 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { detect_add_impacted_symbols(store, project, path_line, doc, impacted); } } - int git_status = cbm_pclose(fp); + (void)fclose(fp); + (void)cbm_unlink(output_path); + int git_status = git_result.exit_code; bool is_error = false; if (git_status != 0 && file_count == 0) { @@ -8509,8 +8628,15 @@ static char *handle_manage_adr(cbm_mcp_server_t *srv, const char *args) { free(project); free(mode_str); free(content); - return cbm_mcp_text_result( - "project operation cancelled or blocked by an active index", true); + return cbm_mcp_text_result("project operation cancelled or blocked by an active index", + true); + } + if (mcp_request_cancelled(srv)) { + mcp_project_mutation_end(srv, project); + free(project); + free(mode_str); + free(content); + return cbm_mcp_text_result("project operation cancelled for this request", true); } } @@ -8753,7 +8879,15 @@ static void release_request_store(cbm_mcp_server_t *srv) { } char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) { + bool request_scope = !srv || cbm_mcp_server_request_scope_begin(srv); + if (!request_scope) { + release_request_store(srv); + return cbm_mcp_text_result("request cancellation scope unavailable", true); + } char *result = dispatch_tool(srv, tool_name, args_json); + if (srv) { + cbm_mcp_server_request_scope_end(srv); + } release_request_store(srv); return result; } @@ -8837,8 +8971,8 @@ static void *autoindex_thread(void *arg) { register_watcher_if_enabled(srv); return NULL; } - cbm_log_error("autoindex.supervision_failed", "project", srv->session_project, - "action", "fail_closed"); + cbm_log_error("autoindex.supervision_failed", "project", srv->session_project, "action", + "fail_closed"); return NULL; } @@ -8865,6 +8999,31 @@ static void *autoindex_thread(void *arg) { return NULL; } +bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit, + int *file_count_out) { + if (file_count_out) { + *file_count_out = -1; + } + if (!root_path || !root_path[0] || file_limit < 0) { + return false; + } + enum { AUTO_INDEX_COUNT_TIMEOUT_MS = 5000 }; + cbm_discover_opts_t options = { + .mode = CBM_MODE_FULL, + .ignore_file = NULL, + .max_file_size = 0, + }; + int count = -1; + cbm_discover_status_t status = cbm_discover_count_bounded( + root_path, &options, file_limit, cbm_now_ms() + AUTO_INDEX_COUNT_TIMEOUT_MS, &count); + if (file_count_out) { + *file_count_out = status == CBM_DISCOVER_LIMIT_EXCEEDED + ? (file_limit < INT_MAX ? file_limit + 1 : INT_MAX) + : count; + } + return status == CBM_DISCOVER_OK; +} + /* Start auto-indexing if configured and project not yet indexed. */ static void maybe_auto_index(cbm_mcp_server_t *srv) { if (srv->session_root[0] == '\0') { @@ -8886,16 +9045,13 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { } } -/* Default file limit for auto-indexing new projects */ -#define DEFAULT_AUTO_INDEX_LIMIT 50000 - /* Check auto_index config */ bool auto_index = false; - int file_limit = DEFAULT_AUTO_INDEX_LIMIT; + int file_limit = CBM_MCP_DEFAULT_AUTO_INDEX_LIMIT; if (srv->config) { auto_index = cbm_config_get_bool(srv->config, CBM_CONFIG_AUTO_INDEX, false); - file_limit = - cbm_config_get_int(srv->config, CBM_CONFIG_AUTO_INDEX_LIMIT, DEFAULT_AUTO_INDEX_LIMIT); + file_limit = cbm_config_get_int(srv->config, CBM_CONFIG_AUTO_INDEX_LIMIT, + CBM_MCP_DEFAULT_AUTO_INDEX_LIMIT); } if (!auto_index) { @@ -8904,27 +9060,16 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) { return; } - /* Quick file count check to avoid OOM on massive repos */ - if (!cbm_validate_shell_arg(srv->session_root)) { - cbm_log_warn("autoindex.skip", "reason", "path contains shell metacharacters"); + /* Quick tracked-file count check to avoid OOM on massive repos. */ + int file_count = -1; + if (!cbm_mcp_auto_index_within_file_limit(srv->session_root, file_limit, &file_count)) { + char files[32]; + (void)snprintf(files, sizeof(files), "%d", file_count); + cbm_log_warn("autoindex.skip", "reason", + file_count >= 0 ? "too_many_files" : "unsafe_or_unavailable_path", "files", + files, "limit", CBM_CONFIG_AUTO_INDEX_LIMIT); return; } - char cmd[CBM_SZ_1K]; - snprintf(cmd, sizeof(cmd), "git -C '%s' ls-files 2>/dev/null | wc -l", srv->session_root); - FILE *fp = cbm_popen(cmd, "r"); - if (fp) { - char line[CBM_SZ_64]; - if (fgets(line, sizeof(line), fp)) { - int count = (int)strtol(line, NULL, CBM_DECIMAL_BASE); - if (count > file_limit) { - cbm_log_warn("autoindex.skip", "reason", "too_many_files", "files", line, "limit", - CBM_CONFIG_AUTO_INDEX_LIMIT); - cbm_pclose(fp); - return; - } - } - cbm_pclose(fp); - } /* Launch auto-index in background */ if (cbm_thread_create(&srv->autoindex_tid, 0, autoindex_thread, srv) == 0) { @@ -9043,6 +9188,47 @@ static char *inject_update_notice(cbm_mcp_server_t *srv, char *result_json) { /* ── Server request handler ───────────────────────────────────── */ +bool cbm_mcp_jsonrpc_response_prepend_notice(char **response_io, const char *notice) { + if (!response_io || !*response_io || !notice || !notice[0]) { + return false; + } + yyjson_doc *document = yyjson_read(*response_io, strlen(*response_io), 0); + if (!document) { + return false; + } + yyjson_mut_doc *mutable_document = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = + mutable_document ? yyjson_val_mut_copy(mutable_document, yyjson_doc_get_root(document)) + : NULL; + yyjson_doc_free(document); + if (!mutable_document || !root) { + yyjson_mut_doc_free(mutable_document); + return false; + } + yyjson_mut_doc_set_root(mutable_document, root); + yyjson_mut_val *result = yyjson_mut_is_obj(root) ? yyjson_mut_obj_get(root, "result") : NULL; + yyjson_mut_val *content = + result && yyjson_mut_is_obj(result) ? yyjson_mut_obj_get(result, "content") : NULL; + yyjson_mut_val *item = + content && yyjson_mut_is_arr(content) ? yyjson_mut_obj(mutable_document) : NULL; + bool added = item && yyjson_mut_obj_add_str(mutable_document, item, "type", "text") && + yyjson_mut_obj_add_str(mutable_document, item, "text", notice) && + yyjson_mut_arr_prepend(content, item); + if (!added) { + yyjson_mut_doc_free(mutable_document); + return false; + } + char *replacement = + yyjson_mut_write(mutable_document, YYJSON_WRITE_ALLOW_INVALID_UNICODE, NULL); + yyjson_mut_doc_free(mutable_document); + if (!replacement) { + return false; + } + free(*response_io); + *response_io = replacement; + return true; +} + char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { cbm_jsonrpc_request_t req = {0}; if (cbm_jsonrpc_parse(line, &req) < 0) { @@ -9062,6 +9248,13 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { return NULL; } + if (!cbm_mcp_server_request_scope_begin(srv)) { + int64_t request_id = req.id; + cbm_jsonrpc_request_free(&req); + return cbm_jsonrpc_format_error(request_id, JSONRPC_INTERNAL_ERROR, + "Request cancellation scope unavailable"); + } + struct timespec req_t0; cbm_clock_gettime(CLOCK_MONOTONIC, &req_t0); char *result_json = NULL; @@ -9069,11 +9262,9 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { bool request_logged = false; if (strcmp(req.method, "initialize") == 0) { - result_json = cbm_mcp_initialize_response_for_profile( - req.params_raw, srv->tool_profile); + result_json = cbm_mcp_initialize_response_for_profile(req.params_raw, srv->tool_profile); detect_session(srv); - if (srv->background_tasks && - srv->tool_profile == CBM_MCP_TOOL_PROFILE_ALL) { + if (srv->background_tasks && srv->tool_profile == CBM_MCP_TOOL_PROFILE_ALL) { start_update_check(srv); maybe_auto_index(srv); } @@ -9136,6 +9327,7 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { long long dur_us = ((long long)(t1.tv_sec - req_t0.tv_sec) * MCP_S_TO_US) + ((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US); cbm_log_mcp_request(req.method, NULL, true, dur_us); + cbm_mcp_server_request_scope_end(srv); cbm_jsonrpc_request_free(&req); return err; } @@ -9153,6 +9345,7 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { ((long long)(t1.tv_nsec - req_t0.tv_nsec) / MCP_MS_TO_US); cbm_log_mcp_request(req.method, NULL, true, dur_us); free(request_error_json); + cbm_mcp_server_request_scope_end(srv); cbm_jsonrpc_request_free(&req); return err; } @@ -9172,6 +9365,7 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { }; char *out = cbm_jsonrpc_format_response(&resp); free(result_json); + cbm_mcp_server_request_scope_end(srv); cbm_jsonrpc_request_free(&req); return out; } diff --git a/src/mcp/mcp.h b/src/mcp/mcp.h index 0da5e7f83..58b01a32a 100644 --- a/src/mcp/mcp.h +++ b/src/mcp/mcp.h @@ -159,19 +159,17 @@ const char *cbm_mcp_server_allowed_root(const cbm_mcp_server_t *srv); * coordinator owns background work. */ void cbm_mcp_server_set_background_tasks(cbm_mcp_server_t *srv, bool enabled); -void cbm_mcp_server_set_index_executor(cbm_mcp_server_t *srv, - cbm_mcp_index_executor_fn executor, +void cbm_mcp_server_set_index_executor(cbm_mcp_server_t *srv, cbm_mcp_index_executor_fn executor, void *context); /* Relay supervised worker logs to one local request (for CLI progress). The * callback/context are borrowed until the synchronous tool call returns. */ -void cbm_mcp_server_set_index_log_callback(cbm_mcp_server_t *srv, - cbm_proc_log_cb callback, +void cbm_mcp_server_set_index_log_callback(cbm_mcp_server_t *srv, cbm_proc_log_cb callback, void *context); -void cbm_mcp_server_set_project_mutation_guard( - cbm_mcp_server_t *srv, cbm_mcp_project_mutation_begin_fn begin, - cbm_mcp_project_mutation_end_fn end, void *context); +void cbm_mcp_server_set_project_mutation_guard(cbm_mcp_server_t *srv, + cbm_mcp_project_mutation_begin_fn begin, + cbm_mcp_project_mutation_end_fn end, void *context); /* Read one complete MCP message from in. Supports newline-delimited JSON and * Content-Length framing, including additional headers. Returns 1 on success, @@ -248,11 +246,18 @@ struct cbm_pipeline; /* forward decl */ * Returns NULL if no pipeline is running. */ struct cbm_pipeline *cbm_mcp_server_active_pipeline(cbm_mcp_server_t *srv); -/* Thread-safe cancellation for daemon connection teardown. The active - * pipeline pointer is protected until cbm_pipeline_cancel() has recorded its - * atomic request, so the request thread cannot clear/free it concurrently. */ +/* Thread-safe cancellation for daemon connection teardown. Returns false when + * no request scope is active. Long-running request operations share the atomic + * cancellation token and own any process-tree teardown before returning. */ bool cbm_mcp_server_cancel_active(cbm_mcp_server_t *srv); +/* Publish an outer request lifetime before crossing an asynchronous transport + * boundary. The daemon application uses this to close the short interval + * between accepting a request token and entering JSON-RPC/tool dispatch. Each + * successful begin must be paired with end. */ +bool cbm_mcp_server_request_scope_begin(cbm_mcp_server_t *srv); +void cbm_mcp_server_request_scope_end(cbm_mcp_server_t *srv); + /* ── URI helpers ───────────────────────────────────────────────── */ /* Parse a file:// URI and extract the filesystem path. diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h index d3f1690e2..8547a8f4c 100644 --- a/src/mcp/mcp_internal.h +++ b/src/mcp/mcp_internal.h @@ -8,12 +8,24 @@ typedef bool (*cbm_mcp_quarantine_test_hook_fn)(void *context, const char *step); void cbm_mcp_server_set_quarantine_test_hook(cbm_mcp_server_t *srv, - cbm_mcp_quarantine_test_hook_fn hook, - void *context); + cbm_mcp_quarantine_test_hook_fn hook, void *context); /* Release only the constructor-created pristine in-memory store. Public * cbm_mcp_server_new(NULL) semantics remain unchanged; daemon sessions use * this immediately before publication so idle sessions retain no SQLite DB. */ bool cbm_mcp_server_release_pristine_memory_store(cbm_mcp_server_t *srv); +/* Prepend one daemon-owned notice to a successful JSON-RPC tool response. + * On success replaces and frees *response_io; on failure it is unchanged. */ +bool cbm_mcp_jsonrpc_response_prepend_notice(char **response_io, const char *notice); + +enum { CBM_MCP_DEFAULT_AUTO_INDEX_LIMIT = 50000 }; + +/* Count indexable files with the pipeline's native full-mode discovery policy, + * without retaining per-file results. A false result means the count exceeded + * file_limit or could not be established before the bounded deadline; every + * such failure is fail-closed because this is the memory-admission guard. */ +bool cbm_mcp_auto_index_within_file_limit(const char *root_path, int file_limit, + int *file_count_out); + #endif diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 0b4d58012..285be212d 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -1326,12 +1326,12 @@ cbm_cross_repo_result_t cbm_cross_repo_match_cancellable(const char *project, result.elapsed_ms = ((double)(t1.tv_sec - t0.tv_sec) * CR_MS_PER_SEC) + ((double)(t1.tv_nsec - t0.tv_nsec) / CR_NS_PER_MS); - int total = result.http_edges + result.async_edges + result.channel_edges + result.grpc_edges + - result.graphql_edges + result.trpc_edges; if (result.cancelled) { cbm_log_info("cross_repo.cancelled", "project", project, "partial_results", result.partial_results ? "true" : "false"); } else { + int total = result.http_edges + result.async_edges + result.channel_edges + + result.grpc_edges + result.graphql_edges + result.trpc_edges; cbm_log_info("cross_repo.done", "project", project, "total", cr_itoa(total)); } diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 5489402d3..0c3041959 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -283,8 +283,9 @@ void cbm_pipeline_set_before_publish_hook_for_tests( } } -void cbm_pipeline_set_rename_hook_for_tests( - cbm_pipeline_t *p, int (*hook)(const char *, const char *, void *), void *ctx) { +void cbm_pipeline_set_rename_hook_for_tests(cbm_pipeline_t *p, + int (*hook)(const char *, const char *, void *), + void *ctx) { if (p) { p->rename_hook = hook; p->rename_hook_ctx = ctx; @@ -1283,8 +1284,8 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil free(db_path); return CBM_NOT_FOUND; } - bool hash_records_complete = true; { + bool hash_records_complete = true; CBM_PROF_START(t_delhash); if (cbm_store_delete_file_hashes(hash_store, p->project_name) != CBM_STORE_OK) { hash_records_complete = false; @@ -1652,9 +1653,8 @@ static int cbm_pipeline_run_staged(cbm_pipeline_t *p, bool *was_incremental) { goto cleanup; } - cbm_log_info("pipeline.done", "nodes", itoa_buf(cbm_gbuf_node_count(p->gbuf)), "edges", - itoa_buf(cbm_gbuf_edge_count(p->gbuf)), "elapsed_ms", - itoa_buf((int)elapsed_ms(t0))); + cbm_log_info("pipeline.done", "nodes", itoa_buf(p->committed_nodes), "edges", + itoa_buf(p->committed_edges), "elapsed_ms", itoa_buf((int)elapsed_ms(t0))); CBM_PROF_END("pipeline", "TOTAL", t_pipeline_total); cleanup: @@ -1798,9 +1798,10 @@ static int seal_staging_db(const char *staging_path) { if (!store) { return CBM_NOT_FOUND; } - int rc = cbm_store_check_integrity(store) && cbm_store_prepare_for_publish(store) == CBM_STORE_OK - ? 0 - : CBM_NOT_FOUND; + int rc = + cbm_store_check_integrity(store) && cbm_store_prepare_for_publish(store) == CBM_STORE_OK + ? 0 + : CBM_NOT_FOUND; cbm_store_close(store); if (rc == 0 && cbm_remove_db_sidecars(staging_path) != 0) { rc = CBM_NOT_FOUND; @@ -1811,13 +1812,11 @@ static int seal_staging_db(const char *staging_path) { static int export_after_publish(cbm_pipeline_t *p, const char *final_path, bool was_incremental) { if (p->persistence) { CBM_PROF_START(t_art); - int rc = - cbm_artifact_export(final_path, p->repo_path, p->project_name, CBM_ARTIFACT_BEST); + int rc = cbm_artifact_export(final_path, p->repo_path, p->project_name, CBM_ARTIFACT_BEST); CBM_PROF_END("persist", "6_artifact_export", t_art); if (rc != 0) { const char *err = cbm_artifact_export_last_error(); - cbm_log_error("pipeline.err", "phase", "artifact_export", "err", - err ? err : "unknown"); + cbm_log_error("pipeline.err", "phase", "artifact_export", "err", err ? err : "unknown"); } return rc; } @@ -1846,8 +1845,7 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { bool backup_succeeded = false; if (final_existed) { - backup_succeeded = - cbm_store_backup_path(final_path, staging_path) == CBM_STORE_OK; + backup_succeeded = cbm_store_backup_path(final_path, staging_path) == CBM_STORE_OK; if (!backup_succeeded) { cbm_log_warn("pipeline.stage", "action", "backup_failed_full_rebuild", "path", final_path); diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 5e0378ea9..21740ba64 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -662,8 +662,8 @@ static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *p cbm_log_error("incremental.err", "msg", "open_staging_after_dump", "path", db_path); return CBM_STORE_ERR; } - int rc = persist_hashes(hash_store, project, files, file_count, mode_skipped, - mode_skipped_count); + int rc = + persist_hashes(hash_store, project, files, file_count, mode_skipped, mode_skipped_count); /* Coverage rows (#963): re-write the merged set into the rebuilt DB * (AFTER hashes, so the deleted-file prune sees the live file set). */ @@ -673,8 +673,7 @@ static int dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *p cbm_coverage_meta_t meta = meta_template ? *meta_template : (cbm_coverage_meta_t){0}; meta.generation = have_project_info ? project_info.indexed_at : NULL; meta.hash_records_complete = rc == CBM_STORE_OK; - if (cbm_store_coverage_replace_ex(hash_store, project, cov, cov_count, &meta) != - CBM_STORE_OK) { + if (cbm_store_coverage_replace_ex(hash_store, project, cov, cov_count, &meta) != CBM_STORE_OK) { cbm_log_error("incremental.err", "msg", "persist_coverage", "project", project); rc = CBM_STORE_ERR; } diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 3a7d453ed..b6ac6575b 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -635,8 +635,9 @@ void cbm_pipeline_set_committed_counts(cbm_pipeline_t *p, int nodes, int edges); * before the cancellation check + atomic replace. Not part of the public API. */ void cbm_pipeline_set_before_publish_hook_for_tests( cbm_pipeline_t *p, void (*hook)(cbm_pipeline_t *, const char *, void *), void *ctx); -void cbm_pipeline_set_rename_hook_for_tests( - cbm_pipeline_t *p, int (*hook)(const char *, const char *, void *), void *ctx); +void cbm_pipeline_set_rename_hook_for_tests(cbm_pipeline_t *p, + int (*hook)(const char *, const char *, void *), + void *ctx); /* Parse a gRPC stub call "." into the canonical proto * service name + method. Returns true ONLY when a recognized gRPC stub/client diff --git a/src/ui/config.c b/src/ui/config.c index 13b6cfe2a..206ff4918 100644 --- a/src/ui/config.c +++ b/src/ui/config.c @@ -42,8 +42,7 @@ void cbm_ui_config_path(char *buf, int bufsz) { /* ── Load ────────────────────────────────────────────────────── */ -static char *config_read_file(const char *path, size_t *length_out, - bool *opened_out) { +static char *config_read_file(const char *path, size_t *length_out, bool *opened_out) { if (length_out) { *length_out = 0; } @@ -58,25 +57,22 @@ static char *config_read_file(const char *path, size_t *length_out, if (!wide_path) { return NULL; } - HANDLE file = CreateFileW(wide_path, GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_DELETE, - NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + HANDLE file = CreateFileW(wide_path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); free(wide_path); if (file == INVALID_HANDLE_VALUE) { return NULL; } *opened_out = true; LARGE_INTEGER size; - if (!GetFileSizeEx(file, &size) || size.QuadPart <= 0 || - size.QuadPart > 4096) { + if (!GetFileSizeEx(file, &size) || size.QuadPart <= 0 || size.QuadPart > 4096) { (void)CloseHandle(file); return NULL; } size_t length = (size_t)size.QuadPart; char *buffer = malloc(length + 1U); DWORD read_length = 0; - bool read_ok = buffer && - ReadFile(file, buffer, (DWORD)length, &read_length, NULL) && + bool read_ok = buffer && ReadFile(file, buffer, (DWORD)length, &read_length, NULL) && read_length == (DWORD)length; (void)CloseHandle(file); if (!read_ok) { @@ -94,8 +90,7 @@ static char *config_read_file(const char *path, size_t *length_out, return NULL; } long file_length = ftell(file); - if (file_length <= 0 || file_length > 4096 || - fseek(file, 0, SEEK_SET) != 0) { + if (file_length <= 0 || file_length > 4096 || fseek(file, 0, SEEK_SET) != 0) { (void)fclose(file); return NULL; } @@ -168,8 +163,7 @@ void cbm_ui_config_load(cbm_ui_config_t *cfg) { /* ── Save ────────────────────────────────────────────────────── */ -static bool config_parent_directory(const char *path, char *directory, - size_t directory_size) { +static bool config_parent_directory(const char *path, char *directory, size_t directory_size) { int written = snprintf(directory, directory_size, "%s", path ? path : ""); if (written <= 0 || (size_t)written >= directory_size) { return false; @@ -193,8 +187,7 @@ static bool config_parent_directory(const char *path, char *directory, #ifdef _WIN32 static volatile LONG g_config_temp_sequence = 0; -static bool config_write_atomic(const char *path, const char *json, - size_t json_length) { +static bool config_write_atomic(const char *path, const char *json, size_t json_length) { wchar_t *wide_path = cbm_utf8_to_wide(path); if (!wide_path) { return false; @@ -205,19 +198,14 @@ static bool config_write_atomic(const char *path, const char *json, if (temporary) { for (unsigned int attempt = 0; attempt < 128U; attempt++) { ULONG sequence = (ULONG)InterlockedIncrement(&g_config_temp_sequence); - int written = swprintf(temporary, temporary_capacity, - L"%ls.tmp.%08lX.%08lX", wide_path, - (unsigned long)GetCurrentProcessId(), - (unsigned long)sequence); + int written = swprintf(temporary, temporary_capacity, L"%ls.tmp.%08lX.%08lX", wide_path, + (unsigned long)GetCurrentProcessId(), (unsigned long)sequence); if (written <= 0 || (size_t)written >= temporary_capacity) { break; } file = CreateFileW(temporary, GENERIC_WRITE, 0, NULL, CREATE_NEW, - FILE_ATTRIBUTE_NORMAL | - FILE_FLAG_WRITE_THROUGH, - NULL); - if (file != INVALID_HANDLE_VALUE || - GetLastError() != ERROR_FILE_EXISTS) { + FILE_ATTRIBUTE_NORMAL | FILE_FLAG_WRITE_THROUGH, NULL); + if (file != INVALID_HANDLE_VALUE || GetLastError() != ERROR_FILE_EXISTS) { break; } } @@ -228,8 +216,7 @@ static bool config_write_atomic(const char *path, const char *json, size_t remaining = json_length - offset; DWORD chunk = remaining > UINT32_MAX ? UINT32_MAX : (DWORD)remaining; DWORD written = 0; - ok = WriteFile(file, json + offset, chunk, &written, NULL) != 0 && - written > 0; + ok = WriteFile(file, json + offset, chunk, &written, NULL) != 0 && written > 0; offset += written; } if (ok) { @@ -240,8 +227,7 @@ static bool config_write_atomic(const char *path, const char *json, } if (ok) { ok = MoveFileExW(temporary, wide_path, - MOVEFILE_REPLACE_EXISTING | - MOVEFILE_WRITE_THROUGH) != 0; + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) != 0; } if (!ok && temporary) { (void)DeleteFileW(temporary); @@ -251,12 +237,10 @@ static bool config_write_atomic(const char *path, const char *json, return ok; } #else -static bool config_write_all(int descriptor, const char *json, - size_t json_length) { +static bool config_write_all(int descriptor, const char *json, size_t json_length) { size_t offset = 0; while (offset < json_length) { - ssize_t written = write(descriptor, json + offset, - json_length - offset); + ssize_t written = write(descriptor, json + offset, json_length - offset); if (written < 0 && errno == EINTR) { continue; } @@ -310,8 +294,7 @@ static bool config_sync_parent_directory(const char *path) { return synced; } -static bool config_write_atomic(const char *path, const char *json, - size_t json_length) { +static bool config_write_atomic(const char *path, const char *json, size_t json_length) { char temporary[CBM_SZ_2K]; int written = snprintf(temporary, sizeof(temporary), "%s.tmp.XXXXXX", path); if (written <= 0 || (size_t)written >= sizeof(temporary)) { @@ -325,8 +308,8 @@ static bool config_write_atomic(const char *path, const char *json, int descriptor_flags = fcntl(descriptor, F_GETFD); ok = ok && descriptor_flags >= 0 && fcntl(descriptor, F_SETFD, descriptor_flags | FD_CLOEXEC) == 0; - ok = ok && config_write_all(descriptor, json, json_length) && - config_sync_descriptor(descriptor); + ok = + ok && config_write_all(descriptor, json, json_length) && config_sync_descriptor(descriptor); if (close(descriptor) != 0) { ok = false; } @@ -358,8 +341,7 @@ bool cbm_ui_config_save(const cbm_ui_config_t *cfg) { directory_ready = cbm_mkdir_p(dir, 0750) || cbm_is_dir(dir); } if (!directory_ready) { - cbm_log_error("ui.config.write_fail", "path", path, "reason", - "create_directory"); + cbm_log_error("ui.config.write_fail", "path", path, "reason", "create_directory"); return false; } @@ -368,16 +350,12 @@ bool cbm_ui_config_save(const cbm_ui_config_t *cfg) { bool serialized = doc && root; if (serialized) { yyjson_mut_doc_set_root(doc, root); - serialized = - yyjson_mut_obj_add_bool(doc, root, "ui_enabled", - cfg->ui_enabled) && - yyjson_mut_obj_add_int(doc, root, "ui_port", cfg->ui_port); + serialized = yyjson_mut_obj_add_bool(doc, root, "ui_enabled", cfg->ui_enabled) && + yyjson_mut_obj_add_int(doc, root, "ui_port", cfg->ui_port); } size_t json_len = 0; - char *json = serialized - ? yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, &json_len) - : NULL; + char *json = serialized ? yyjson_mut_write(doc, YYJSON_WRITE_PRETTY, &json_len) : NULL; if (doc) { yyjson_mut_doc_free(doc); } @@ -390,8 +368,7 @@ bool cbm_ui_config_save(const cbm_ui_config_t *cfg) { bool saved = config_write_atomic(path, json, json_len); free(json); if (!saved) { - cbm_log_error("ui.config.write_fail", "path", path, "reason", - "atomic_publish"); + cbm_log_error("ui.config.write_fail", "path", path, "reason", "atomic_publish"); return false; } diff --git a/src/ui/http_server.c b/src/ui/http_server.c index 15c59a959..9da078436 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -756,8 +756,7 @@ static void handle_adr_get(cbm_http_conn_t *c, const cbm_http_req_t *req) { } /* POST /api/adr — save ADR content. Body: {"project":"...","content":"..."} */ -static void handle_adr_save(cbm_http_server_t *srv, cbm_http_conn_t *c, - const cbm_http_req_t *req) { +static void handle_adr_save(cbm_http_server_t *srv, cbm_http_conn_t *c, const cbm_http_req_t *req) { if (req->body_len == 0 || req->body_len > 16384) { cbm_http_replyf(c, 400, g_cors_json, "{\"error\":\"invalid body\"}"); return; @@ -781,8 +780,7 @@ static void handle_adr_save(cbm_http_server_t *srv, cbm_http_conn_t *c, const char *proj = yyjson_get_str(v_proj); const char *content = yyjson_get_str(v_content); - if (srv->mutation_begin && - !srv->mutation_begin(srv->mutation_context, proj)) { + if (srv->mutation_begin && !srv->mutation_begin(srv->mutation_context, proj)) { yyjson_doc_free(doc); cbm_http_replyf(c, 423, g_cors_json, "{\"error\":\"project is busy; retry after indexing\"}"); @@ -935,18 +933,16 @@ static void *index_thread_fn(void *arg) { cbm_log_info("ui.index.start", "path", job->root_path); cbm_http_server_t *server = job->server; int result = server && server->index_executor - ? server->index_executor(server->index_executor_context, - job->root_path, job->project_name) + ? server->index_executor(server->index_executor_context, job->root_path, + job->project_name) : -1; if (result != 0) { - snprintf(job->error_msg, sizeof(job->error_msg), - "daemon index operation failed"); + snprintf(job->error_msg, sizeof(job->error_msg), "daemon index operation failed"); atomic_store(&job->status, 3); } else { atomic_store(&job->status, 2); } - cbm_log_info("ui.index.done", "path", job->root_path, "rc", - result == 0 ? "ok" : "err"); + cbm_log_info("ui.index.done", "path", job->root_path, "rc", result == 0 ? "ok" : "err"); return NULL; } @@ -1069,8 +1065,7 @@ static void handle_delete_project(cbm_http_server_t *srv, cbm_http_conn_t *c, return; } - if (srv->mutation_begin && - !srv->mutation_begin(srv->mutation_context, name)) { + if (srv->mutation_begin && !srv->mutation_begin(srv->mutation_context, name)) { cbm_http_replyf(c, 423, g_cors_json, "{\"error\":\"project is busy; retry after indexing\"}"); return; @@ -1675,8 +1670,8 @@ static char *http_read_only_index_rejected(void *context, const char *repo_path, (void)context; (void)repo_path; (void)args_json; - return cbm_mcp_text_result( - "UI RPC indexing is disabled; use the coordinated /api/index route", true); + return cbm_mcp_text_result("UI RPC indexing is disabled; use the coordinated /api/index route", + true); } cbm_http_server_t *cbm_http_server_new(int port) { @@ -1695,8 +1690,7 @@ cbm_http_server_t *cbm_http_server_new(int port) { return NULL; } cbm_mcp_server_set_background_tasks(srv->mcp, false); - cbm_mcp_server_set_index_executor(srv->mcp, - http_read_only_index_rejected, srv); + cbm_mcp_server_set_index_executor(srv->mcp, http_read_only_index_rejected, srv); /* Bind to localhost only (httpd refuses anything else by construction) */ srv->listener = cbm_httpd_listen(port); @@ -1792,8 +1786,7 @@ void cbm_http_server_set_watcher(cbm_http_server_t *srv, struct cbm_watcher *wat } } -void cbm_http_server_set_index_executor(cbm_http_server_t *srv, - cbm_http_index_executor_fn executor, +void cbm_http_server_set_index_executor(cbm_http_server_t *srv, cbm_http_index_executor_fn executor, void *context) { if (srv) { srv->index_executor = executor; @@ -1801,15 +1794,15 @@ void cbm_http_server_set_index_executor(cbm_http_server_t *srv, } } -void cbm_http_server_set_project_mutation_guard( - cbm_http_server_t *srv, cbm_http_project_mutation_begin_fn begin, - cbm_http_project_mutation_end_fn end, void *context) { +void cbm_http_server_set_project_mutation_guard(cbm_http_server_t *srv, + cbm_http_project_mutation_begin_fn begin, + cbm_http_project_mutation_end_fn end, + void *context) { if (!srv || ((begin == NULL) != (end == NULL))) { return; } srv->mutation_begin = begin; srv->mutation_end = end; srv->mutation_context = begin ? context : NULL; - cbm_mcp_server_set_project_mutation_guard(srv->mcp, begin, end, - begin ? context : NULL); + cbm_mcp_server_set_project_mutation_guard(srv->mcp, begin, end, begin ? context : NULL); } diff --git a/src/ui/http_server.h b/src/ui/http_server.h index 6e2a2de26..7544b7881 100644 --- a/src/ui/http_server.h +++ b/src/ui/http_server.h @@ -51,16 +51,16 @@ void cbm_http_server_set_recv_deadline_ms(cbm_http_server_t *srv, int ms); void cbm_http_server_set_watcher(cbm_http_server_t *srv, struct cbm_watcher *watcher); /* Route UI indexing through the daemon's shared operation registry. */ -void cbm_http_server_set_index_executor(cbm_http_server_t *srv, - cbm_http_index_executor_fn executor, +void cbm_http_server_set_index_executor(cbm_http_server_t *srv, cbm_http_index_executor_fn executor, void *context); /* Route direct UI mutations and /rpc mutation tools through the daemon's * per-project coordination gate. Direct UI calls are non-blocking: begin=false * is returned to the browser as a retryable locked response. */ -void cbm_http_server_set_project_mutation_guard( - cbm_http_server_t *srv, cbm_http_project_mutation_begin_fn begin, - cbm_http_project_mutation_end_fn end, void *context); +void cbm_http_server_set_project_mutation_guard(cbm_http_server_t *srv, + cbm_http_project_mutation_begin_fn begin, + cbm_http_project_mutation_end_fn end, + void *context); /* Initialize the log ring buffer mutex. Must be called once before any threads. */ void cbm_ui_log_init(void); diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 11e2cf5a8..6a4844137 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -458,8 +458,8 @@ static bool watcher_unlink_cached_file(const char *project_name, const char *pat int unlink_errno = errno; char errno_text[CBM_SZ_32]; snprintf(errno_text, sizeof(errno_text), "%d", unlink_errno); - cbm_log_warn("watcher.root_prune_delete_failed", "project", project_name, - "artifact", artifact, "path", path, "errno", errno_text); + cbm_log_warn("watcher.root_prune_delete_failed", "project", project_name, "artifact", artifact, + "path", path, "errno", errno_text); return false; } @@ -488,20 +488,17 @@ static bool delete_cached_project_db(const char *project_name) { } int written = snprintf(path, path_capacity, "%s/%s.db", cache_dir, project_name); bool formatted = written > 0 && (size_t)written < path_capacity; - bool removed = formatted && - watcher_unlink_cached_file(project_name, path, "database"); + bool removed = formatted && watcher_unlink_cached_file(project_name, path, "database"); /* If the main DB could not be removed, preserve WAL/SHM: together they * may be the only recoverable committed generation. */ if (removed) { written = snprintf(sidecar, path_capacity + sizeof("-wal"), "%s-wal", path); - removed = written > 0 && - (size_t)written < path_capacity + sizeof("-wal") && + removed = written > 0 && (size_t)written < path_capacity + sizeof("-wal") && watcher_unlink_cached_file(project_name, sidecar, "wal"); } if (removed) { written = snprintf(sidecar, path_capacity + sizeof("-wal"), "%s-shm", path); - removed = written > 0 && - (size_t)written < path_capacity + sizeof("-wal") && + removed = written > 0 && (size_t)written < path_capacity + sizeof("-wal") && watcher_unlink_cached_file(project_name, sidecar, "shm"); } free(path); @@ -559,10 +556,10 @@ void cbm_watcher_free(cbm_watcher_t *w) { free(w); } -void cbm_watcher_set_project_mutation_guard( - cbm_watcher_t *w, cbm_watcher_project_mutation_begin_fn begin, - cbm_watcher_project_mutation_end_fn end, cbm_watcher_project_pruned_fn pruned, - void *context) { +void cbm_watcher_set_project_mutation_guard(cbm_watcher_t *w, + cbm_watcher_project_mutation_begin_fn begin, + cbm_watcher_project_mutation_end_fn end, + cbm_watcher_project_pruned_fn pruned, void *context) { if (!w) { return; } @@ -757,8 +754,8 @@ static void prune_missing_project(cbm_watcher_t *w, project_state_t *s) { bool removed = false; cbm_mutex_lock(&w->coordination_lock); - bool mutation_acquired = !w->mutation_begin || - w->mutation_begin(w->mutation_context, project_name); + bool mutation_acquired = + !w->mutation_begin || w->mutation_begin(w->mutation_context, project_name); if (!mutation_acquired) { cbm_mutex_unlock(&w->coordination_lock); free(project_name); @@ -774,12 +771,11 @@ static void prune_missing_project(cbm_watcher_t *w, project_state_t *s) { * never pruned. */ int stat_errno = 0; uint64_t now_ms = cbm_now_ms(); - bool still_eligible = current == s && strcmp(current->root_path, root_path) == 0 && - current->missing_root_count >= MISSING_ROOT_DELETE_AFTER && - current->first_missing_ms > 0 && - now_ms - current->first_missing_ms >= - (uint64_t)prune_grace_s() * CBM_MSEC_PER_SEC && - root_status(root_path, &stat_errno) == ROOT_MISSING; + bool still_eligible = + current == s && strcmp(current->root_path, root_path) == 0 && + current->missing_root_count >= MISSING_ROOT_DELETE_AFTER && current->first_missing_ms > 0 && + now_ms - current->first_missing_ms >= (uint64_t)prune_grace_s() * CBM_MSEC_PER_SEC && + root_status(root_path, &stat_errno) == ROOT_MISSING; if (still_eligible && defer_state_free(w, s)) { if (delete_cached_project_db(project_name)) { atomic_store_explicit(&s->registered, false, memory_order_release); diff --git a/src/watcher/watcher.h b/src/watcher/watcher.h index 9d59411f9..699013250 100644 --- a/src/watcher/watcher.h +++ b/src/watcher/watcher.h @@ -54,10 +54,10 @@ void cbm_watcher_free(cbm_watcher_t *w); /* Install or clear daemon-owned stale-root coordination. Passing NULL for * begin/end clears all callbacks. The setter waits for an in-flight prune * callback to finish before returning. */ -void cbm_watcher_set_project_mutation_guard( - cbm_watcher_t *w, cbm_watcher_project_mutation_begin_fn begin, - cbm_watcher_project_mutation_end_fn end, cbm_watcher_project_pruned_fn pruned, - void *context); +void cbm_watcher_set_project_mutation_guard(cbm_watcher_t *w, + cbm_watcher_project_mutation_begin_fn begin, + cbm_watcher_project_mutation_end_fn end, + cbm_watcher_project_pruned_fn pruned, void *context); /* ── Watch list management ──────────────────────────────────────── */ diff --git a/tests/repro/repro_issue557.c b/tests/repro/repro_issue557.c index a312aeadf..e93dc5bfd 100644 --- a/tests/repro/repro_issue557.c +++ b/tests/repro/repro_issue557.c @@ -105,8 +105,7 @@ static int file_exists(const char *path) { * reservation artifacts (for example, -wal, -shm, or .pending) must not make * the primary survival assertion pass on their own. */ static int is_hex_digit(char ch) { - return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || - (ch >= 'A' && ch <= 'F'); + return (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F'); } static int is_unique_corrupt_main_name(const char *name) { @@ -160,8 +159,7 @@ static int is_backup_artifact_name(const char *name) { size_t corrupt_len = strlen(corrupt_prefix); if (strncmp(name, corrupt_prefix, corrupt_len) == 0 && - (name[corrupt_len] == '\0' || name[corrupt_len] == '.' || - name[corrupt_len] == '-')) { + (name[corrupt_len] == '\0' || name[corrupt_len] == '.' || name[corrupt_len] == '-')) { return 1; } @@ -337,9 +335,9 @@ TEST(repro_issue557_corrupt_db_not_silently_deleted) { char backup_corrupt[720], backup_bak[720]; snprintf(backup_corrupt, sizeof(backup_corrupt), "%s.corrupt", db_path); - snprintf(backup_bak, sizeof(backup_bak), "%s.bak", db_path); - int backup_exists = unique_corrupt_main_exists(tmp_cache) || - file_exists(backup_corrupt) || file_exists(backup_bak); + snprintf(backup_bak, sizeof(backup_bak), "%s.bak", db_path); + int backup_exists = unique_corrupt_main_exists(tmp_cache) || file_exists(backup_corrupt) || + file_exists(backup_bak); /* Clean up temp dir (best effort -- before the assertion so the dir * is removed even when the assertion fails and longjmp unwinds). */ diff --git a/tests/test_activation_transaction.c b/tests/test_activation_transaction.c index 4ccc433b1..681c277e8 100644 --- a/tests/test_activation_transaction.c +++ b/tests/test_activation_transaction.c @@ -34,20 +34,18 @@ enum { ACTIVATION_TEST_PATH_CAP = 1024, ACTIVATION_TEST_CONTENT_CAP = 256 }; static bool activation_test_fixture(char out[ACTIVATION_TEST_PATH_CAP]) { - int written = snprintf(out, ACTIVATION_TEST_PATH_CAP, - "%s/cbm-activation-transaction-XXXXXX", cbm_tmpdir()); - return written > 0 && written < ACTIVATION_TEST_PATH_CAP && - cbm_mkdtemp(out) != NULL; + int written = snprintf(out, ACTIVATION_TEST_PATH_CAP, "%s/cbm-activation-transaction-XXXXXX", + cbm_tmpdir()); + return written > 0 && written < ACTIVATION_TEST_PATH_CAP && cbm_mkdtemp(out) != NULL; } -static bool activation_test_path(char out[ACTIVATION_TEST_PATH_CAP], - const char *directory, const char *name) { +static bool activation_test_path(char out[ACTIVATION_TEST_PATH_CAP], const char *directory, + const char *name) { int written = snprintf(out, ACTIVATION_TEST_PATH_CAP, "%s/%s", directory, name); return written > 0 && written < ACTIVATION_TEST_PATH_CAP; } -static bool activation_test_read(const char *path, - char out[ACTIVATION_TEST_CONTENT_CAP]) { +static bool activation_test_read(const char *path, char out[ACTIVATION_TEST_CONTENT_CAP]) { FILE *file = cbm_fopen(path, "rb"); if (!file) { return false; @@ -110,19 +108,16 @@ typedef struct { } activation_test_callback_allow_ace_t; static wchar_t *activation_test_windows_utf8_to_wide(const char *value) { - int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, - NULL, 0); + int needed = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, NULL, 0); wchar_t *wide = needed > 0 ? calloc((size_t)needed, sizeof(*wide)) : NULL; - if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, - wide, needed) <= 0) { + if (!wide || MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, value, -1, wide, needed) <= 0) { free(wide); return NULL; } return wide; } -static bool activation_test_windows_current_user_sid(void **information_out, - PSID *sid_out) { +static bool activation_test_windows_current_user_sid(void **information_out, PSID *sid_out) { *information_out = NULL; *sid_out = NULL; HANDLE token = NULL; @@ -132,9 +127,8 @@ static bool activation_test_windows_current_user_sid(void **information_out, DWORD needed = 0; (void)GetTokenInformation(token, TokenUser, NULL, 0, &needed); void *information = needed > 0 ? calloc(1, needed) : NULL; - bool ok = information && - GetTokenInformation(token, TokenUser, information, needed, - &needed) != 0; + bool ok = + information && GetTokenInformation(token, TokenUser, information, needed, &needed) != 0; (void)CloseHandle(token); if (!ok) { free(information); @@ -150,31 +144,27 @@ static bool activation_test_windows_current_user_sid(void **information_out, return true; } -static bool activation_test_windows_set_directory_acl( - const char *path, bool include_untrusted_callback) { +static bool activation_test_windows_set_directory_acl(const char *path, + bool include_untrusted_callback) { void *user_information = NULL; PSID user_sid = NULL; - if (!activation_test_windows_current_user_sid(&user_information, - &user_sid)) { + if (!activation_test_windows_current_user_sid(&user_information, &user_sid)) { return false; } unsigned char world_sid_storage[SECURITY_MAX_SID_SIZE]; DWORD world_sid_size = sizeof(world_sid_storage); PSID world_sid = world_sid_storage; - bool ok = CreateWellKnownSid(WinWorldSid, NULL, world_sid, - &world_sid_size) != 0; + bool ok = CreateWellKnownSid(WinWorldSid, NULL, world_sid, &world_sid_size) != 0; DWORD world_sid_length = ok ? GetLengthSid(world_sid) : 0U; - DWORD user_ace_size = (DWORD)(sizeof(ACCESS_ALLOWED_ACE) - sizeof(DWORD)) + - GetLengthSid(user_sid); + DWORD user_ace_size = + (DWORD)(sizeof(ACCESS_ALLOWED_ACE) - sizeof(DWORD)) + GetLengthSid(user_sid); DWORD callback_ace_size = - (DWORD)(sizeof(activation_test_callback_allow_ace_t) - sizeof(DWORD)) + - world_sid_length; - DWORD acl_size = (DWORD)sizeof(ACL) + user_ace_size + - (include_untrusted_callback ? callback_ace_size : 0U); + (DWORD)(sizeof(activation_test_callback_allow_ace_t) - sizeof(DWORD)) + world_sid_length; + DWORD acl_size = + (DWORD)sizeof(ACL) + user_ace_size + (include_untrusted_callback ? callback_ace_size : 0U); PACL acl = ok ? calloc(1, acl_size) : NULL; ok = acl && InitializeAcl(acl, acl_size, ACL_REVISION) != 0 && - AddAccessAllowedAceEx(acl, ACL_REVISION, 0, GENERIC_ALL, - user_sid) != 0; + AddAccessAllowedAceEx(acl, ACL_REVISION, 0, GENERIC_ALL, user_sid) != 0; unsigned char *callback_storage = include_untrusted_callback ? calloc(1, callback_ace_size) : NULL; if (ok && include_untrusted_callback) { @@ -186,27 +176,21 @@ static bool activation_test_windows_set_directory_acl( callback->header.AceFlags = 0; callback->header.AceSize = (WORD)callback_ace_size; callback->mask = FILE_ADD_FILE | FILE_DELETE_CHILD; - ok = CopySid(world_sid_length, &callback->sid_start, world_sid) != - 0 && - AddAce(acl, ACL_REVISION, MAXDWORD, callback, - callback_ace_size) != 0; + ok = CopySid(world_sid_length, &callback->sid_start, world_sid) != 0 && + AddAce(acl, ACL_REVISION, MAXDWORD, callback, callback_ace_size) != 0; } } wchar_t *wide_path = ok ? activation_test_windows_utf8_to_wide(path) : NULL; - HANDLE directory = wide_path - ? CreateFileW(wide_path, READ_CONTROL | WRITE_DAC, - FILE_SHARE_READ | FILE_SHARE_WRITE | - FILE_SHARE_DELETE, - NULL, OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS | - FILE_FLAG_OPEN_REPARSE_POINT, - NULL) - : INVALID_HANDLE_VALUE; + HANDLE directory = + wide_path ? CreateFileW(wide_path, READ_CONTROL | WRITE_DAC, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL) + : INVALID_HANDLE_VALUE; ok = directory != INVALID_HANDLE_VALUE && SetSecurityInfo(directory, SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION | - PROTECTED_DACL_SECURITY_INFORMATION, - NULL, NULL, acl, NULL) == ERROR_SUCCESS; + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, NULL, + NULL, acl, NULL) == ERROR_SUCCESS; if (directory != INVALID_HANDLE_VALUE) { (void)CloseHandle(directory); } @@ -217,28 +201,22 @@ static bool activation_test_windows_set_directory_acl( return ok; } -static bool activation_test_windows_directory_owned_by_current_user( - const char *path) { +static bool activation_test_windows_directory_owned_by_current_user(const char *path) { void *user_information = NULL; PSID user_sid = NULL; wchar_t *wide_path = activation_test_windows_utf8_to_wide(path); - HANDLE directory = wide_path - ? CreateFileW(wide_path, READ_CONTROL, - FILE_SHARE_READ | FILE_SHARE_WRITE | - FILE_SHARE_DELETE, - NULL, OPEN_EXISTING, - FILE_FLAG_BACKUP_SEMANTICS | - FILE_FLAG_OPEN_REPARSE_POINT, - NULL) - : INVALID_HANDLE_VALUE; + HANDLE directory = + wide_path ? CreateFileW(wide_path, READ_CONTROL, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL) + : INVALID_HANDLE_VALUE; PSID owner = NULL; PSECURITY_DESCRIPTOR descriptor = NULL; bool ok = directory != INVALID_HANDLE_VALUE && - activation_test_windows_current_user_sid(&user_information, - &user_sid) && - GetSecurityInfo(directory, SE_FILE_OBJECT, - OWNER_SECURITY_INFORMATION, &owner, NULL, NULL, - NULL, &descriptor) == ERROR_SUCCESS && + activation_test_windows_current_user_sid(&user_information, &user_sid) && + GetSecurityInfo(directory, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, NULL, + NULL, NULL, &descriptor) == ERROR_SUCCESS && owner && IsValidSid(owner) && EqualSid(owner, user_sid); if (descriptor) { (void)LocalFree(descriptor); @@ -258,11 +236,9 @@ typedef struct { bool created; } activation_test_competing_target_t; -static void activation_test_create_competing_target(const char *target_path, - void *opaque) { +static void activation_test_create_competing_target(const char *target_path, void *opaque) { activation_test_competing_target_t *competing = opaque; - competing->created = activation_test_write(target_path, - competing->contents); + competing->created = activation_test_write(target_path, competing->contents); } #endif @@ -273,8 +249,7 @@ typedef enum { ACTIVATION_TEST_ACL_ERROR = 2, } activation_test_acl_status_t; -static activation_test_acl_status_t activation_test_install_mutating_acl( - const char *path) { +static activation_test_acl_status_t activation_test_install_mutating_acl(const char *path) { acl_t acl = acl_init(1); if (!acl) { return ACTIVATION_TEST_ACL_ERROR; @@ -292,8 +267,7 @@ static activation_test_acl_status_t activation_test_install_mutating_acl( acl_add_perm(permissions, ACL_ADD_FILE) == 0 && acl_add_perm(permissions, ACL_ADD_SUBDIRECTORY) == 0 && acl_add_perm(permissions, ACL_DELETE_CHILD) == 0 && - acl_set_permset(entry, permissions) == 0 && - acl_valid(acl) == 0; + acl_set_permset(entry, permissions) == 0 && acl_valid(acl) == 0; activation_test_acl_status_t status = ACTIVATION_TEST_ACL_ERROR; if (valid) { errno = 0; @@ -323,8 +297,7 @@ static bool activation_test_clear_extended_acl_if_exists(const char *path) { activation_test_clear_extended_acl(path); } -static bool activation_test_acl_metadata_acceptable(const char *path, - bool expect_directory) { +static bool activation_test_acl_metadata_acceptable(const char *path, bool expect_directory) { struct stat status; if (lstat(path, &status) != 0 || status.st_uid != geteuid() || (status.st_mode & 0777) != 0700) { @@ -342,8 +315,8 @@ TEST(activation_transaction_stages_same_directory_private_executable_and_aborts) ASSERT_TRUE(activation_test_path(target, directory, "cbm")); cbm_activation_transaction_t *transaction = NULL; - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - target, "candidate", strlen("candidate"), &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(target, "candidate", strlen("candidate"), + &transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_NOT_NULL(transaction); const char *staged = cbm_activation_transaction_staged_path(transaction); @@ -365,8 +338,7 @@ TEST(activation_transaction_stages_same_directory_private_executable_and_aborts) char staged_copy[ACTIVATION_TEST_PATH_CAP]; (void)snprintf(staged_copy, sizeof(staged_copy), "%s", staged); - ASSERT_EQ(cbm_activation_transaction_close(&transaction), - CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_NULL(transaction); ASSERT_FALSE(activation_test_exists(staged_copy)); ASSERT_FALSE(activation_test_exists(target)); @@ -382,8 +354,7 @@ TEST(activation_transaction_commit_keeps_backup_until_finalize) { ASSERT_TRUE(activation_test_write(target, "old")); cbm_activation_transaction_t *transaction = NULL; - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - target, "new", strlen("new"), &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(target, "new", strlen("new"), &transaction), CBM_ACTIVATION_TRANSACTION_OK); const char *backup = cbm_activation_transaction_backup_path(transaction); ASSERT_NOT_NULL(backup); @@ -394,9 +365,7 @@ TEST(activation_transaction_commit_keeps_backup_until_finalize) { .expect_absent = false, .expected_contents = "new", }; - ASSERT_EQ(cbm_activation_transaction_commit(transaction, - activation_test_validate, - &validation), + ASSERT_EQ(cbm_activation_transaction_commit(transaction, activation_test_validate, &validation), CBM_ACTIVATION_TRANSACTION_OK); char contents[ACTIVATION_TEST_CONTENT_CAP]; ASSERT_TRUE(activation_test_read(target, contents)); @@ -404,11 +373,9 @@ TEST(activation_transaction_commit_keeps_backup_until_finalize) { ASSERT_TRUE(activation_test_read(backup_copy, contents)); ASSERT_STR_EQ(contents, "old"); - ASSERT_EQ(cbm_activation_transaction_finalize(transaction), - CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_finalize(transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_FALSE(activation_test_exists(backup_copy)); - ASSERT_EQ(cbm_activation_transaction_close(&transaction), - CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_EQ(th_rmtree(directory), 0); PASS(); } @@ -421,23 +388,20 @@ TEST(activation_transaction_validation_failure_restores_previous_target) { ASSERT_TRUE(activation_test_write(target, "old")); cbm_activation_transaction_t *transaction = NULL; - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - target, "bad", strlen("bad"), &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(target, "bad", strlen("bad"), &transaction), CBM_ACTIVATION_TRANSACTION_OK); const char *backup = cbm_activation_transaction_backup_path(transaction); ASSERT_NOT_NULL(backup); char backup_copy[ACTIVATION_TEST_PATH_CAP]; (void)snprintf(backup_copy, sizeof(backup_copy), "%s", backup); - ASSERT_EQ(cbm_activation_transaction_commit(transaction, - activation_test_reject, NULL), + ASSERT_EQ(cbm_activation_transaction_commit(transaction, activation_test_reject, NULL), CBM_ACTIVATION_TRANSACTION_VALIDATION_FAILED); char contents[ACTIVATION_TEST_CONTENT_CAP]; ASSERT_TRUE(activation_test_read(target, contents)); ASSERT_STR_EQ(contents, "old"); ASSERT_FALSE(activation_test_exists(backup_copy)); - ASSERT_EQ(cbm_activation_transaction_close(&transaction), - CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_EQ(th_rmtree(directory), 0); PASS(); } @@ -450,24 +414,19 @@ TEST(activation_transaction_explicit_rollback_restores_previous_target) { ASSERT_TRUE(activation_test_write(target, "old")); cbm_activation_transaction_t *transaction = NULL; - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - target, "new", strlen("new"), &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(target, "new", strlen("new"), &transaction), CBM_ACTIVATION_TRANSACTION_OK); activation_test_validation_t validation = { .expect_absent = false, .expected_contents = "new", }; - ASSERT_EQ(cbm_activation_transaction_commit(transaction, - activation_test_validate, - &validation), - CBM_ACTIVATION_TRANSACTION_OK); - ASSERT_EQ(cbm_activation_transaction_rollback(transaction), + ASSERT_EQ(cbm_activation_transaction_commit(transaction, activation_test_validate, &validation), CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_rollback(transaction), CBM_ACTIVATION_TRANSACTION_OK); char contents[ACTIVATION_TEST_CONTENT_CAP]; ASSERT_TRUE(activation_test_read(target, contents)); ASSERT_STR_EQ(contents, "old"); - ASSERT_EQ(cbm_activation_transaction_close(&transaction), - CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_EQ(th_rmtree(directory), 0); PASS(); } @@ -482,21 +441,16 @@ TEST(activation_transaction_stage_file_installs_new_target) { ASSERT_TRUE(activation_test_write(source, "downloaded")); cbm_activation_transaction_t *transaction = NULL; - ASSERT_EQ(cbm_activation_transaction_stage_file(target, source, - &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_file(target, source, &transaction), CBM_ACTIVATION_TRANSACTION_OK); activation_test_validation_t validation = { .expect_absent = false, .expected_contents = "downloaded", }; - ASSERT_EQ(cbm_activation_transaction_commit(transaction, - activation_test_validate, - &validation), - CBM_ACTIVATION_TRANSACTION_OK); - ASSERT_EQ(cbm_activation_transaction_finalize(transaction), - CBM_ACTIVATION_TRANSACTION_OK); - ASSERT_EQ(cbm_activation_transaction_close(&transaction), + ASSERT_EQ(cbm_activation_transaction_commit(transaction, activation_test_validate, &validation), CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_finalize(transaction), CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), CBM_ACTIVATION_TRANSACTION_OK); char contents[ACTIVATION_TEST_CONTENT_CAP]; ASSERT_TRUE(activation_test_read(target, contents)); ASSERT_STR_EQ(contents, "downloaded"); @@ -518,16 +472,12 @@ TEST(activation_transaction_removal_can_rollback_or_finalize) { cbm_activation_transaction_t *transaction = NULL; ASSERT_EQ(cbm_activation_transaction_stage_removal(target, &transaction), CBM_ACTIVATION_TRANSACTION_OK); - ASSERT_EQ(cbm_activation_transaction_commit(transaction, - activation_test_validate, - &absent), + ASSERT_EQ(cbm_activation_transaction_commit(transaction, activation_test_validate, &absent), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_FALSE(activation_test_exists(target)); - ASSERT_EQ(cbm_activation_transaction_rollback(transaction), - CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_rollback(transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_TRUE(activation_test_exists(target)); - ASSERT_EQ(cbm_activation_transaction_close(&transaction), - CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_EQ(cbm_activation_transaction_stage_removal(target, &transaction), CBM_ACTIVATION_TRANSACTION_OK); @@ -535,16 +485,12 @@ TEST(activation_transaction_removal_can_rollback_or_finalize) { ASSERT_NOT_NULL(backup); char backup_copy[ACTIVATION_TEST_PATH_CAP]; (void)snprintf(backup_copy, sizeof(backup_copy), "%s", backup); - ASSERT_EQ(cbm_activation_transaction_commit(transaction, - activation_test_validate, - &absent), - CBM_ACTIVATION_TRANSACTION_OK); - ASSERT_EQ(cbm_activation_transaction_finalize(transaction), + ASSERT_EQ(cbm_activation_transaction_commit(transaction, activation_test_validate, &absent), CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_finalize(transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_FALSE(activation_test_exists(target)); ASSERT_FALSE(activation_test_exists(backup_copy)); - ASSERT_EQ(cbm_activation_transaction_close(&transaction), - CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_EQ(th_rmtree(directory), 0); PASS(); } @@ -557,8 +503,8 @@ TEST(activation_transaction_rejects_cross_account_writable_target_directory) { ASSERT_TRUE(activation_test_path(target, directory, "cbm")); ASSERT_EQ(chmod(directory, 0777), 0); cbm_activation_transaction_t *transaction = NULL; - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - target, "candidate", strlen("candidate"), &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(target, "candidate", strlen("candidate"), + &transaction), CBM_ACTIVATION_TRANSACTION_IO); ASSERT_NULL(transaction); ASSERT_EQ(chmod(directory, 0700), 0); @@ -574,19 +520,16 @@ TEST(activation_transaction_rejects_windows_callback_allow_directory_ace) { ASSERT_TRUE(activation_test_fixture(directory)); ASSERT_TRUE(activation_test_path(target, directory, "cbm")); ASSERT_TRUE(activation_test_windows_set_directory_acl(directory, true)); - ASSERT_TRUE( - activation_test_windows_directory_owned_by_current_user(directory)); + ASSERT_TRUE(activation_test_windows_directory_owned_by_current_user(directory)); cbm_activation_transaction_t *transaction = NULL; - cbm_activation_transaction_status_t stage_status = - cbm_activation_transaction_stage_bytes( - target, "candidate", strlen("candidate"), &transaction); + cbm_activation_transaction_status_t stage_status = cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction); bool transaction_was_created = transaction != NULL; cbm_activation_transaction_status_t close_status = transaction ? cbm_activation_transaction_close(&transaction) : CBM_ACTIVATION_TRANSACTION_OK; - bool acl_restored = - activation_test_windows_set_directory_acl(directory, false); + bool acl_restored = activation_test_windows_set_directory_acl(directory, false); int cleanup_status = th_rmtree(directory); ASSERT_EQ(stage_status, CBM_ACTIVATION_TRANSACTION_IO); @@ -615,20 +558,16 @@ TEST(activation_transaction_rejects_symlink_candidate_target_and_parent) { char linked_nested_candidate[ACTIVATION_TEST_PATH_CAP]; ASSERT_TRUE(activation_test_fixture(directory)); ASSERT_TRUE(activation_test_path(candidate, directory, "candidate")); - ASSERT_TRUE(activation_test_path(candidate_link, directory, - "candidate-link")); + ASSERT_TRUE(activation_test_path(candidate_link, directory, "candidate-link")); ASSERT_TRUE(activation_test_path(target, directory, "target")); ASSERT_TRUE(activation_test_path(target_link, directory, "target-link")); ASSERT_TRUE(activation_test_path(real_parent, directory, "real-parent")); ASSERT_TRUE(activation_test_path(real_nested, real_parent, "nested")); - ASSERT_TRUE(activation_test_path(real_nested_candidate, real_nested, - "candidate")); + ASSERT_TRUE(activation_test_path(real_nested_candidate, real_nested, "candidate")); ASSERT_TRUE(activation_test_path(parent_link, directory, "parent-link")); ASSERT_TRUE(activation_test_path(linked_parent_target, parent_link, "cbm")); - ASSERT_TRUE(activation_test_path(linked_nested_target, parent_link, - "nested/cbm")); - ASSERT_TRUE(activation_test_path(linked_nested_candidate, parent_link, - "nested/candidate")); + ASSERT_TRUE(activation_test_path(linked_nested_target, parent_link, "nested/cbm")); + ASSERT_TRUE(activation_test_path(linked_nested_candidate, parent_link, "nested/candidate")); ASSERT_TRUE(activation_test_write(candidate, "candidate")); ASSERT_EQ(symlink(candidate, candidate_link), 0); ASSERT_EQ(symlink(candidate, target_link), 0); @@ -638,27 +577,22 @@ TEST(activation_transaction_rejects_symlink_candidate_target_and_parent) { ASSERT_EQ(symlink(real_parent, parent_link), 0); cbm_activation_transaction_t *transaction = NULL; - ASSERT_EQ(cbm_activation_transaction_stage_file(target, candidate_link, - &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_file(target, candidate_link, &transaction), CBM_ACTIVATION_TRANSACTION_IO); ASSERT_NULL(transaction); - ASSERT_EQ(cbm_activation_transaction_stage_file( - target, linked_nested_candidate, &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_file(target, linked_nested_candidate, &transaction), CBM_ACTIVATION_TRANSACTION_IO); ASSERT_NULL(transaction); - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - target_link, "replacement", strlen("replacement"), - &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(target_link, "replacement", + strlen("replacement"), &transaction), CBM_ACTIVATION_TRANSACTION_IO); ASSERT_NULL(transaction); - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - linked_parent_target, "replacement", strlen("replacement"), - &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(linked_parent_target, "replacement", + strlen("replacement"), &transaction), CBM_ACTIVATION_TRANSACTION_IO); ASSERT_NULL(transaction); - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - linked_nested_target, "replacement", strlen("replacement"), - &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(linked_nested_target, "replacement", + strlen("replacement"), &transaction), CBM_ACTIVATION_TRANSACTION_IO); ASSERT_NULL(transaction); @@ -685,8 +619,8 @@ TEST(activation_transaction_fails_closed_if_target_directory_is_replaced) { ASSERT_TRUE(activation_test_path(target, active, "cbm")); cbm_activation_transaction_t *transaction = NULL; - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - target, "candidate", strlen("candidate"), &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(target, "candidate", strlen("candidate"), + &transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_EQ(rename(active, moved), 0); ASSERT_TRUE(cbm_mkdir_p(active, 0700)); @@ -695,8 +629,7 @@ TEST(activation_transaction_fails_closed_if_target_directory_is_replaced) { ASSERT_FALSE(activation_test_exists(target)); ASSERT_EQ(cbm_rmdir(active), 0); ASSERT_EQ(rename(moved, active), 0); - ASSERT_EQ(cbm_activation_transaction_close(&transaction), - CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_NULL(transaction); ASSERT_EQ(th_rmtree(root), 0); PASS(); @@ -710,8 +643,8 @@ TEST(activation_transaction_does_not_replace_target_created_at_publish_boundary) ASSERT_TRUE(activation_test_path(target, directory, "cbm")); cbm_activation_transaction_t *transaction = NULL; - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - target, "candidate", strlen("candidate"), &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(target, "candidate", strlen("candidate"), + &transaction), CBM_ACTIVATION_TRANSACTION_OK); const char *staged = cbm_activation_transaction_staged_path(transaction); ASSERT_NOT_NULL(staged); @@ -764,8 +697,7 @@ TEST(activation_transaction_rejects_macos_mutating_extended_acl) { ASSERT_TRUE(activation_test_path(target, directory, "cbm")); ASSERT_EQ(chmod(directory, 0700), 0); - activation_test_acl_status_t acl_status = - activation_test_install_mutating_acl(directory); + activation_test_acl_status_t acl_status = activation_test_install_mutating_acl(directory); if (acl_status == ACTIVATION_TEST_ACL_UNSUPPORTED) { ASSERT_EQ(th_rmtree(directory), 0); SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); @@ -777,9 +709,8 @@ TEST(activation_transaction_rejects_macos_mutating_extended_acl) { ASSERT_EQ(status.st_mode & 0777, 0700); cbm_activation_transaction_t *transaction = NULL; - cbm_activation_transaction_status_t stage_status = - cbm_activation_transaction_stage_bytes( - target, "candidate", strlen("candidate"), &transaction); + cbm_activation_transaction_status_t stage_status = cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction); bool transaction_was_created = transaction != NULL; cbm_activation_transaction_status_t close_status = transaction ? cbm_activation_transaction_close(&transaction) @@ -802,27 +733,22 @@ TEST(activation_transaction_rejects_macos_existing_target_mutating_acl) { ASSERT_TRUE(activation_test_path(target, directory, "cbm")); ASSERT_TRUE(activation_test_write(target, "old")); - activation_test_acl_status_t acl_status = - activation_test_install_mutating_acl(target); + activation_test_acl_status_t acl_status = activation_test_install_mutating_acl(target); if (acl_status == ACTIVATION_TEST_ACL_UNSUPPORTED) { ASSERT_EQ(th_rmtree(directory), 0); SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); } ASSERT_EQ(acl_status, ACTIVATION_TEST_ACL_OK); - bool metadata_acceptable = - activation_test_acl_metadata_acceptable(target, false); + bool metadata_acceptable = activation_test_acl_metadata_acceptable(target, false); cbm_activation_transaction_t *transaction = NULL; - cbm_activation_transaction_status_t stage_status = - cbm_activation_transaction_stage_bytes( - target, "candidate", strlen("candidate"), &transaction); + cbm_activation_transaction_status_t stage_status = cbm_activation_transaction_stage_bytes( + target, "candidate", strlen("candidate"), &transaction); char staged[ACTIVATION_TEST_PATH_CAP] = {0}; char backup[ACTIVATION_TEST_PATH_CAP] = {0}; if (transaction) { - const char *staged_path = - cbm_activation_transaction_staged_path(transaction); - const char *backup_path = - cbm_activation_transaction_backup_path(transaction); + const char *staged_path = cbm_activation_transaction_staged_path(transaction); + const char *backup_path = cbm_activation_transaction_backup_path(transaction); if (staged_path) { (void)snprintf(staged, sizeof(staged), "%s", staged_path); } @@ -830,15 +756,12 @@ TEST(activation_transaction_rejects_macos_existing_target_mutating_acl) { (void)snprintf(backup, sizeof(backup), "%s", backup_path); } } - cbm_activation_transaction_status_t commit_status = - CBM_ACTIVATION_TRANSACTION_INVALID_STATE; + cbm_activation_transaction_status_t commit_status = CBM_ACTIVATION_TRANSACTION_INVALID_STATE; if (stage_status == CBM_ACTIVATION_TRANSACTION_OK && transaction) { - commit_status = - cbm_activation_transaction_commit(transaction, NULL, NULL); + commit_status = cbm_activation_transaction_commit(transaction, NULL, NULL); } char observed[ACTIVATION_TEST_CONTENT_CAP]; - bool target_unchanged = activation_test_read(target, observed) && - strcmp(observed, "old") == 0; + bool target_unchanged = activation_test_read(target, observed) && strcmp(observed, "old") == 0; bool acl_cleared = activation_test_clear_extended_acl_if_exists(target) && activation_test_clear_extended_acl_if_exists(staged) && activation_test_clear_extended_acl_if_exists(backup); @@ -846,8 +769,7 @@ TEST(activation_transaction_rejects_macos_existing_target_mutating_acl) { transaction ? cbm_activation_transaction_close(&transaction) : CBM_ACTIVATION_TRANSACTION_OK; char restored[ACTIVATION_TEST_CONTENT_CAP]; - bool target_restored = activation_test_read(target, restored) && - strcmp(restored, "old") == 0; + bool target_restored = activation_test_read(target, restored) && strcmp(restored, "old") == 0; int cleanup_status = th_rmtree(directory); ASSERT_TRUE(metadata_acceptable); @@ -872,26 +794,22 @@ TEST(activation_transaction_revalidates_macos_directory_acl_before_commit) { ASSERT_TRUE(activation_test_path(target, directory, "cbm")); ASSERT_TRUE(activation_test_write(target, "old")); cbm_activation_transaction_t *transaction = NULL; - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - target, "candidate", strlen("candidate"), &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(target, "candidate", strlen("candidate"), + &transaction), CBM_ACTIVATION_TRANSACTION_OK); - activation_test_acl_status_t acl_status = - activation_test_install_mutating_acl(directory); + activation_test_acl_status_t acl_status = activation_test_install_mutating_acl(directory); if (acl_status == ACTIVATION_TEST_ACL_UNSUPPORTED) { - ASSERT_EQ(cbm_activation_transaction_close(&transaction), - CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_EQ(th_rmtree(directory), 0); SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); } ASSERT_EQ(acl_status, ACTIVATION_TEST_ACL_OK); - bool metadata_acceptable = - activation_test_acl_metadata_acceptable(directory, true); + bool metadata_acceptable = activation_test_acl_metadata_acceptable(directory, true); cbm_activation_transaction_status_t commit_status = cbm_activation_transaction_commit(transaction, NULL, NULL); char observed[ACTIVATION_TEST_CONTENT_CAP]; - bool target_unchanged = activation_test_read(target, observed) && - strcmp(observed, "old") == 0; + bool target_unchanged = activation_test_read(target, observed) && strcmp(observed, "old") == 0; bool acl_cleared = activation_test_clear_extended_acl(directory); cbm_activation_transaction_status_t close_status = cbm_activation_transaction_close(&transaction); @@ -916,31 +834,26 @@ TEST(activation_transaction_revalidates_macos_staged_file_acl_before_commit) { ASSERT_TRUE(activation_test_path(target, directory, "cbm")); ASSERT_TRUE(activation_test_write(target, "old")); cbm_activation_transaction_t *transaction = NULL; - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - target, "candidate", strlen("candidate"), &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(target, "candidate", strlen("candidate"), + &transaction), CBM_ACTIVATION_TRANSACTION_OK); - const char *staged_path = - cbm_activation_transaction_staged_path(transaction); + const char *staged_path = cbm_activation_transaction_staged_path(transaction); ASSERT_NOT_NULL(staged_path); char staged[ACTIVATION_TEST_PATH_CAP]; (void)snprintf(staged, sizeof(staged), "%s", staged_path); - activation_test_acl_status_t acl_status = - activation_test_install_mutating_acl(staged); + activation_test_acl_status_t acl_status = activation_test_install_mutating_acl(staged); if (acl_status == ACTIVATION_TEST_ACL_UNSUPPORTED) { - ASSERT_EQ(cbm_activation_transaction_close(&transaction), - CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_EQ(th_rmtree(directory), 0); SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); } ASSERT_EQ(acl_status, ACTIVATION_TEST_ACL_OK); - bool metadata_acceptable = - activation_test_acl_metadata_acceptable(staged, false); + bool metadata_acceptable = activation_test_acl_metadata_acceptable(staged, false); cbm_activation_transaction_status_t commit_status = cbm_activation_transaction_commit(transaction, NULL, NULL); char observed[ACTIVATION_TEST_CONTENT_CAP]; - bool target_unchanged = activation_test_read(target, observed) && - strcmp(observed, "old") == 0; + bool target_unchanged = activation_test_read(target, observed) && strcmp(observed, "old") == 0; bool acl_cleared = activation_test_clear_extended_acl_if_exists(staged) && activation_test_clear_extended_acl_if_exists(target); cbm_activation_transaction_status_t close_status = @@ -966,31 +879,26 @@ TEST(activation_transaction_revalidates_macos_target_file_acl_before_commit) { ASSERT_TRUE(activation_test_path(target, directory, "cbm")); ASSERT_TRUE(activation_test_write(target, "old")); cbm_activation_transaction_t *transaction = NULL; - ASSERT_EQ(cbm_activation_transaction_stage_bytes( - target, "candidate", strlen("candidate"), &transaction), + ASSERT_EQ(cbm_activation_transaction_stage_bytes(target, "candidate", strlen("candidate"), + &transaction), CBM_ACTIVATION_TRANSACTION_OK); - const char *backup_path = - cbm_activation_transaction_backup_path(transaction); + const char *backup_path = cbm_activation_transaction_backup_path(transaction); ASSERT_NOT_NULL(backup_path); char backup[ACTIVATION_TEST_PATH_CAP]; (void)snprintf(backup, sizeof(backup), "%s", backup_path); - activation_test_acl_status_t acl_status = - activation_test_install_mutating_acl(target); + activation_test_acl_status_t acl_status = activation_test_install_mutating_acl(target); if (acl_status == ACTIVATION_TEST_ACL_UNSUPPORTED) { - ASSERT_EQ(cbm_activation_transaction_close(&transaction), - CBM_ACTIVATION_TRANSACTION_OK); + ASSERT_EQ(cbm_activation_transaction_close(&transaction), CBM_ACTIVATION_TRANSACTION_OK); ASSERT_EQ(th_rmtree(directory), 0); SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); } ASSERT_EQ(acl_status, ACTIVATION_TEST_ACL_OK); - bool metadata_acceptable = - activation_test_acl_metadata_acceptable(target, false); + bool metadata_acceptable = activation_test_acl_metadata_acceptable(target, false); cbm_activation_transaction_status_t commit_status = cbm_activation_transaction_commit(transaction, NULL, NULL); char observed[ACTIVATION_TEST_CONTENT_CAP]; - bool target_unchanged = activation_test_read(target, observed) && - strcmp(observed, "old") == 0; + bool target_unchanged = activation_test_read(target, observed) && strcmp(observed, "old") == 0; bool acl_cleared = activation_test_clear_extended_acl_if_exists(target) && activation_test_clear_extended_acl_if_exists(backup); cbm_activation_transaction_status_t close_status = diff --git a/tests/test_cli.c b/tests/test_cli.c index d6e187454..3e60fe103 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -40,8 +40,7 @@ * tests that exercise --yes. */ void cbm_set_auto_answer_for_test(int value); int cbm_cli_sha256_file(const char *path, char *out, size_t out_size); -int cbm_cli_checksum_manifest_digest(const char *manifest_path, - const char *archive_name, char *out, +int cbm_cli_checksum_manifest_digest(const char *manifest_path, const char *archive_name, char *out, size_t out_size); void cbm_cli_set_activation_cleanup_failure_for_test(bool enabled); int cbm_cli_activation_abort_cleanup_probe_for_test(void); @@ -54,27 +53,31 @@ TEST(cli_progress_visibility_policy) { } TEST(cli_raw_mcp_result_preserves_tool_error_status) { - ASSERT_TRUE(cbm_cli_mcp_result_is_error( - "{\"content\":[],\"isError\":true}")); - ASSERT_FALSE(cbm_cli_mcp_result_is_error( - "{\"content\":[],\"isError\":false}")); - ASSERT_FALSE(cbm_cli_mcp_result_is_error( - "{\"content\":[{\"text\":\"\\\"isError\\\":true\"}]}")); + ASSERT_TRUE(cbm_cli_mcp_result_is_error("{\"content\":[],\"isError\":true}")); + ASSERT_FALSE(cbm_cli_mcp_result_is_error("{\"content\":[],\"isError\":false}")); + ASSERT_FALSE( + cbm_cli_mcp_result_is_error("{\"content\":[{\"text\":\"\\\"isError\\\":true\"}]}")); ASSERT_FALSE(cbm_cli_mcp_result_is_error("{\"isError\":\"true\"}")); ASSERT_FALSE(cbm_cli_mcp_result_is_error("not-json")); ASSERT_FALSE(cbm_cli_mcp_result_is_error(NULL)); PASS(); } +TEST(cli_maintenance_cancellation_forces_failure_status) { + ASSERT_EQ(cbm_cli_exit_status_after_maintenance(EXIT_SUCCESS, false), EXIT_SUCCESS); + ASSERT_EQ(cbm_cli_exit_status_after_maintenance(7, false), 7); + ASSERT_EQ(cbm_cli_exit_status_after_maintenance(EXIT_SUCCESS, true), EXIT_FAILURE); + ASSERT_EQ(cbm_cli_exit_status_after_maintenance(7, true), 7); + PASS(); +} + TEST(cli_progress_sink_accepts_worker_json_logs) { FILE *out = tmpfile(); ASSERT_NOT_NULL(out); cbm_progress_sink_init(out); - cbm_progress_sink_fn( - "{\"level\":\"info\",\"event\":\"pipeline.discover\",\"files\":\"3\"}"); - cbm_progress_sink_fn( - "{\"level\":\"info\",\"event\":\"pass.start\",\"pass\":\"structure\"}"); + cbm_progress_sink_fn("{\"level\":\"info\",\"event\":\"pipeline.discover\",\"files\":\"3\"}"); + cbm_progress_sink_fn("{\"level\":\"info\",\"event\":\"pass.start\",\"pass\":\"structure\"}"); cbm_progress_sink_fini(); ASSERT_EQ(fseek(out, 0, SEEK_SET), 0); @@ -99,12 +102,11 @@ static void *cli_progress_race_worker(void *opaque) { cli_progress_race_arg_t *arg = opaque; char counts[128]; char progress[128]; - (void)snprintf(counts, sizeof(counts), - "level=info msg=gbuf.dump nodes=%d edges=%d", + (void)snprintf(counts, sizeof(counts), "level=info msg=gbuf.dump nodes=%d edges=%d", 1000 + arg->worker_id, 2000 + arg->worker_id); (void)snprintf(progress, sizeof(progress), - "level=info msg=parallel.extract.progress done=%d total=%d", - arg->worker_id + 1, CLI_PROGRESS_RACE_THREADS); + "level=info msg=parallel.extract.progress done=%d total=%d", arg->worker_id + 1, + CLI_PROGRESS_RACE_THREADS); while (!atomic_load_explicit(arg->start, memory_order_acquire)) { cbm_usleep(100); @@ -132,8 +134,8 @@ TEST(cli_progress_sink_serializes_concurrent_callbacks) { for (; created < CLI_PROGRESS_RACE_THREADS; created++) { args[created].start = &start; args[created].worker_id = created; - if (cbm_thread_create(&threads[created], 0, cli_progress_race_worker, - &args[created]) != 0) { + if (cbm_thread_create(&threads[created], 0, cli_progress_race_worker, &args[created]) != + 0) { break; } } @@ -394,8 +396,8 @@ typedef struct { char diagnostic[512]; } cli_activation_fake_t; -static int cli_activation_fake_reserve_mutation( - void *opaque, cbm_cli_activation_lock_t *lease_out) { +static int cli_activation_fake_reserve_mutation(void *opaque, + cbm_cli_activation_lock_t *lease_out) { cli_activation_fake_t *fake = opaque; fake->mutation_reserve_count++; if (fake->mutation_reserve_result != 1) { @@ -411,26 +413,20 @@ static int cli_activation_fake_reserve_mutation( return 1; } -static void cli_activation_fake_release_mutation( - void *opaque, cbm_cli_activation_lock_t lease) { +static void cli_activation_fake_release_mutation(void *opaque, cbm_cli_activation_lock_t lease) { cli_activation_fake_t *fake = opaque; if (lease == fake && fake->mutation_lease_held) { bool path_a_visible = true; bool path_b_visible = true; if (fake->guarded_path_a) { const char *data = read_test_file(fake->guarded_path_a); - path_a_visible = data && - (!fake->guarded_text_a || - strstr(data, fake->guarded_text_a)); + path_a_visible = data && (!fake->guarded_text_a || strstr(data, fake->guarded_text_a)); } if (fake->guarded_path_b) { const char *data = read_test_file(fake->guarded_path_b); - path_b_visible = data && - (!fake->guarded_text_b || - strstr(data, fake->guarded_text_b)); + path_b_visible = data && (!fake->guarded_text_b || strstr(data, fake->guarded_text_b)); } - fake->guarded_files_visible_before_unlock |= - path_a_visible && path_b_visible; + fake->guarded_files_visible_before_unlock |= path_a_visible && path_b_visible; fake->mutation_lease_held = false; fake->mutation_lease_release_count++; if (fake->release_marker) { @@ -570,9 +566,7 @@ TEST(cli_activation_quiesces_active_cohort_before_mutation) { .visible_diagnostic = cli_activation_fake_diagnostic, }; - ASSERT_EQ(cbm_cli_activation_guard_with_ops( - &ops, cli_activation_fake_mutation, &fake), - 0); + ASSERT_EQ(cbm_cli_activation_guard_with_ops(&ops, cli_activation_fake_mutation, &fake), 0); ASSERT_EQ(fake.mutation_reserve_count, 1); ASSERT_EQ(fake.quiesce_count, 1); ASSERT_FALSE(fake.participants_active); @@ -677,8 +671,7 @@ static int cli_activation_abort_cleanup_probe_mutation(void *opaque) { * and allow another daemon generation to observe an uncertain executable. */ TEST(cli_activation_cleanup_failure_fail_stops_before_lease_release) { char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), - "/tmp/cli-activation-cleanup-fail-stop-XXXXXX"); + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-activation-cleanup-fail-stop-XXXXXX"); if (!cbm_mkdtemp(tmpdir)) { FAIL("cbm_mkdtemp failed"); } @@ -742,14 +735,14 @@ TEST(cli_activation_quiesce_does_not_wait_on_bootstrap_startup) { pid_t child = fork(); if (child == 0) { close(ready_pipe[0]); - cbm_daemon_ipc_endpoint_t *endpoint = - cbm_daemon_bootstrap_endpoint_new(runtime_parent); + cbm_daemon_ipc_endpoint_t *endpoint = cbm_daemon_bootstrap_endpoint_new(runtime_parent); cbm_version_cohort_manager_t *manager = endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL; char fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; cbm_daemon_build_identity_t identity = { .semantic_version = "cli-activation-test", .build_fingerprint = fingerprint, + .cache_fingerprint = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", .protocol_abi = CBM_DAEMON_RUNTIME_WIRE_ABI, .store_abi = 1, .feature_abi = 1, @@ -757,14 +750,12 @@ TEST(cli_activation_quiesce_does_not_wait_on_bootstrap_startup) { cbm_version_cohort_lease_t *lease = NULL; cbm_daemon_conflict_t conflict; cbm_daemon_ipc_startup_lock_t *startup = NULL; - bool setup = endpoint && manager && - cbm_daemon_runtime_process_build_fingerprint( - (uint64_t)getpid(), fingerprint) && - cbm_version_cohort_acquire( - manager, &identity, cbm_now_ms() + 2000U, &lease, - &conflict) == CBM_VERSION_COHORT_OK && - cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &startup) == 1 && startup; + bool setup = + endpoint && manager && + cbm_daemon_runtime_process_build_fingerprint((uint64_t)getpid(), fingerprint) && + cbm_version_cohort_acquire(manager, &identity, cbm_now_ms() + 2000U, &lease, + &conflict) == CBM_VERSION_COHORT_OK && + cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) == 1 && startup; char ready = setup ? 'R' : 'E'; (void)write(ready_pipe[1], &ready, 1); bool saw_maintenance = false; @@ -789,14 +780,10 @@ TEST(cli_activation_quiesce_does_not_wait_on_bootstrap_startup) { if (startup) { (void)cbm_daemon_ipc_startup_lock_release(&startup); } - while (lease && - cbm_version_cohort_lease_release(&lease) != - CBM_PRIVATE_FILE_LOCK_OK) { + while (lease && cbm_version_cohort_lease_release(&lease) != CBM_PRIVATE_FILE_LOCK_OK) { cbm_usleep(1000); } - while (manager && - cbm_version_cohort_manager_free(&manager) != - CBM_PRIVATE_FILE_LOCK_OK) { + while (manager && cbm_version_cohort_manager_free(&manager) != CBM_PRIVATE_FILE_LOCK_OK) { cbm_usleep(1000); } cbm_daemon_ipc_endpoint_free(endpoint); @@ -805,8 +792,7 @@ TEST(cli_activation_quiesce_does_not_wait_on_bootstrap_startup) { } close(ready_pipe[1]); char ready = 0; - bool child_ready = child > 0 && read(ready_pipe[0], &ready, 1) == 1 && - ready == 'R'; + bool child_ready = child > 0 && read(ready_pipe[0], &ready, 1) == 1 && ready == 'R'; close(ready_pipe[0]); char *old_home = NULL; @@ -822,10 +808,8 @@ TEST(cli_activation_quiesce_does_not_wait_on_bootstrap_startup) { char activation_log[640]; snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); snprintf(install_dir, sizeof(install_dir), "%s/custom/bin", tmpdir); - snprintf(target_path, sizeof(target_path), - "%s/codebase-memory-mcp", install_dir); - snprintf(activation_log, sizeof(activation_log), - "%s/logs/activation-events.ndjson", cache_dir); + snprintf(target_path, sizeof(target_path), "%s/codebase-memory-mcp", install_dir); + snprintf(activation_log, sizeof(activation_log), "%s/logs/activation-events.ndjson", cache_dir); cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); cbm_cli_set_activation_runtime_parent_for_test(runtime_parent); char dir_arg[640]; @@ -841,15 +825,13 @@ TEST(cli_activation_quiesce_does_not_wait_on_bootstrap_startup) { struct stat target_status; struct stat log_status; bool target_exists = stat(target_path, &target_status) == 0; - bool log_private = stat(activation_log, &log_status) == 0 && - (log_status.st_mode & 0077) == 0; + bool log_private = stat(activation_log, &log_status) == 0 && (log_status.st_mode & 0077) == 0; const char *events = read_test_file(activation_log); const char *requested = events ? strstr(events, "\"phase\":\"requested\"") : NULL; const char *stopped = events ? strstr(events, "\"phase\":\"daemon_stopped\"") : NULL; const char *completed = events ? strstr(events, "\"phase\":\"completed\"") : NULL; - bool event_order = requested && stopped && completed && - requested < stopped && stopped < completed && - strstr(events, "\"restart_required\":true") != NULL; + bool event_order = requested && stopped && completed && requested < stopped && + stopped < completed && strstr(events, "\"restart_required\":true") != NULL; if (old_shell) { cbm_setenv("SHELL", old_shell, 1); @@ -953,13 +935,11 @@ TEST(cli_install_dir_and_skip_config_stage_first_install_safely) { }; cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake); cbm_cli_set_activation_ops_for_test(&ops); - char *argv[] = {"--force", "--skip-config", "--yes", "--dir", - install_dir}; + char *argv[] = {"--force", "--skip-config", "--yes", "--dir", install_dir}; int rc = cbm_cmd_install(5, argv); char equals_arg[640]; snprintf(equals_arg, sizeof(equals_arg), "--dir=%s", install_dir); - char *dry_argv[] = {"--force", "--skip-config", "--dry-run", - equals_arg}; + char *dry_argv[] = {"--force", "--skip-config", "--dry-run", equals_arg}; int dry_rc = cbm_cmd_install(4, dry_argv); cbm_cli_set_activation_ops_for_test(NULL); cbm_set_auto_answer_for_test(0); @@ -1057,8 +1037,7 @@ TEST(cli_install_reset_deletion_waits_for_final_activation_guard) { cbm_set_auto_answer_for_test(0); const char *index = read_test_file(index_path); - bool index_preserved = - index && strcmp(index, "index must survive final-guard refusal") == 0; + bool index_preserved = index && strcmp(index, "index must survive final-guard refusal") == 0; const char *installed = read_test_file(bin_target); bool binary_preserved = installed && strcmp(installed, "old binary must survive") == 0; cli_activation_restore_env(old_home, old_cache); @@ -1216,8 +1195,7 @@ TEST(cli_install_config_and_path_finish_before_guard_release) { * at the wrong (or, for a fresh install, missing) executable. */ TEST(cli_install_config_failure_keeps_published_binary) { char tmpdir[256]; - snprintf(tmpdir, sizeof(tmpdir), - "/tmp/cli-install-partial-config-XXXXXX"); + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-install-partial-config-XXXXXX"); if (!cbm_mkdtemp(tmpdir)) { FAIL("cbm_mkdtemp failed"); } @@ -1237,15 +1215,12 @@ TEST(cli_install_config_failure_keeps_published_binary) { char bin_target[640]; snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir); snprintf(openclaw_dir, sizeof(openclaw_dir), "%s/.openclaw", tmpdir); - snprintf(openclaw_config, sizeof(openclaw_config), "%s/openclaw.json", - openclaw_dir); + snprintf(openclaw_config, sizeof(openclaw_config), "%s/openclaw.json", openclaw_dir); snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", tmpdir); #ifdef _WIN32 - snprintf(bin_target, sizeof(bin_target), - "%s/codebase-memory-mcp.exe", bin_dir); + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp.exe", bin_dir); #else - snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp", - bin_dir); + snprintf(bin_target, sizeof(bin_target), "%s/codebase-memory-mcp", bin_dir); #endif cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); test_mkdirp(openclaw_dir); @@ -1471,20 +1446,17 @@ TEST(cli_update_agent_configs_finish_before_guard_release) { ASSERT_EQ(fseek(native_file, 0, SEEK_SET), 0); unsigned char *replacement = malloc((size_t)native_size); ASSERT_NOT_NULL(replacement); - ASSERT_EQ(fread(replacement, 1, (size_t)native_size, native_file), - (size_t)native_size); + ASSERT_EQ(fread(replacement, 1, (size_t)native_size, native_file), (size_t)native_size); ASSERT_EQ(fclose(native_file), 0); int archive_len = 0; unsigned char *archive = - create_test_targz("codebase-memory-mcp", replacement, - (int)native_size, &archive_len); + create_test_targz("codebase-memory-mcp", replacement, (int)native_size, &archive_len); free(replacement); ASSERT_NOT_NULL(archive); snprintf(archive_path, sizeof(archive_path), "%s/%s", release_dir, asset_name); FILE *archive_file = fopen(archive_path, "wb"); ASSERT_NOT_NULL(archive_file); - ASSERT_EQ(fwrite(archive, 1, (size_t)archive_len, archive_file), - (size_t)archive_len); + ASSERT_EQ(fwrite(archive, 1, (size_t)archive_len, archive_file), (size_t)archive_len); ASSERT_EQ(fclose(archive_file), 0); free(archive); @@ -1518,10 +1490,8 @@ TEST(cli_update_agent_configs_finish_before_guard_release) { char openclaw_config[640]; char bin_target[640]; snprintf(openclaw_dir, sizeof(openclaw_dir), "%s/.openclaw", tmpdir); - snprintf(openclaw_config, sizeof(openclaw_config), "%s/openclaw.json", - openclaw_dir); - snprintf(bin_target, sizeof(bin_target), - "%s/.local/bin/codebase-memory-mcp", tmpdir); + snprintf(openclaw_config, sizeof(openclaw_config), "%s/openclaw.json", openclaw_dir); + snprintf(bin_target, sizeof(bin_target), "%s/.local/bin/codebase-memory-mcp", tmpdir); test_mkdirp(openclaw_dir); write_test_file(openclaw_config, "{ invalid config\n"); static const char old_binary[] = "old binary before partial update"; @@ -1530,15 +1500,13 @@ TEST(cli_update_agent_configs_finish_before_guard_release) { cli_activation_fake_t config_failure = { .mutation_reserve_result = 1, }; - cbm_cli_activation_ops_t failure_ops = - cli_activation_fake_ops(&config_failure); + cbm_cli_activation_ops_t failure_ops = cli_activation_fake_ops(&config_failure); cbm_cli_set_activation_ops_for_test(&failure_ops); int config_failure_rc = cbm_cmd_update(2, argv); cbm_cli_set_activation_ops_for_test(NULL); struct stat updated_status; bool replacement_kept = stat(bin_target, &updated_status) == 0 && - updated_status.st_size != - (off_t)(sizeof(old_binary) - 1U); + updated_status.st_size != (off_t)(sizeof(old_binary) - 1U); if (old_download) { cbm_setenv("CBM_DOWNLOAD_URL", old_download, 1); @@ -1557,8 +1525,7 @@ TEST(cli_update_agent_configs_finish_before_guard_release) { ASSERT_TRUE(replacement_kept); ASSERT_EQ(config_failure.mutation_reserve_count, 1); ASSERT_EQ(config_failure.mutation_lease_release_count, 1); - ASSERT_NOT_NULL( - strstr(config_failure.diagnostic, "executable was kept")); + ASSERT_NOT_NULL(strstr(config_failure.diagnostic, "executable was kept")); PASS(); #endif } @@ -1605,8 +1572,7 @@ TEST(cli_uninstall_quiesces_active_cohort_before_removing_binary_and_index) { cbm_set_auto_answer_for_test(0); const char *index = read_test_file(index_path); - bool index_preserved = - index && strcmp(index, "index must survive active-daemon refusal") == 0; + bool index_preserved = index && strcmp(index, "index must survive active-daemon refusal") == 0; const char *installed = read_test_file(bin_target); bool binary_preserved = installed && strcmp(installed, "binary must survive active-daemon refusal") == 0; @@ -1666,8 +1632,7 @@ TEST(cli_uninstall_preserves_binary_and_index_when_cohort_does_not_drain) { cbm_set_auto_answer_for_test(0); const char *index = read_test_file(index_path); - bool index_preserved = - index && strcmp(index, "index must survive uninstall race") == 0; + bool index_preserved = index && strcmp(index, "index must survive uninstall race") == 0; const char *installed = read_test_file(bin_target); bool binary_preserved = installed && strcmp(installed, "binary must survive uninstall race") == 0; @@ -2623,7 +2588,8 @@ TEST(cli_openclaw_compaction_preserves_user_owned_section) { "OPENCLAW_STATE_DIR", "OPENCLAW_CONFIG_PATH", "OPENCLAW_WORKSPACE_DIR", - "OPENCLAW_PROFILE"}; + "OPENCLAW_PROFILE", + "CBM_CACHE_DIR"}; char *saved_env[sizeof(env_names) / sizeof(env_names[0])]; for (size_t i = 0; i < sizeof(env_names) / sizeof(env_names[0]); i++) { saved_env[i] = save_test_env(env_names[i]); @@ -2649,11 +2615,20 @@ TEST(cli_openclaw_compaction_preserves_user_owned_section) { uninstalled && !strstr(uninstalled, "Codebase Knowledge Graph (codebase-memory-mcp)"); free(uninstalled); + const size_t cache_env_index = sizeof(env_names) / sizeof(env_names[0]) - 1; + const char *cache_after_uninstall = getenv("CBM_CACHE_DIR"); + bool cache_environment_restored = + saved_env[cache_env_index] + ? cache_after_uninstall && + strcmp(cache_after_uninstall, saved_env[cache_env_index]) == 0 + : cache_after_uninstall == NULL; + for (size_t i = 0; i < sizeof(env_names) / sizeof(env_names[0]); i++) { restore_test_env(env_names[i], saved_env[i]); } test_rmdir_r(tmpdir); - if (!installed_owned || !retained_existing || rc != 0 || !preserved_user || !removed_owned) + if (!installed_owned || !retained_existing || rc != 0 || !preserved_user || !removed_owned || + !cache_environment_restored) FAIL("OpenClaw uninstall must remove only its namespaced compaction section"); PASS(); } @@ -3306,6 +3281,249 @@ static unsigned char *create_test_zip_stored(const char *filename, const unsigne return zip; } +static void test_zip_put_u16(unsigned char *output, uint16_t value) { + output[0] = (unsigned char)value; + output[1] = (unsigned char)(value >> 8); +} + +static void test_zip_put_u32(unsigned char *output, uint32_t value) { + output[0] = (unsigned char)value; + output[1] = (unsigned char)(value >> 8); + output[2] = (unsigned char)(value >> 16); + output[3] = (unsigned char)(value >> 24); +} + +typedef struct { + const char *name; + const unsigned char *content; + size_t content_size; +} test_zip_entry_t; + +static unsigned char *create_test_zip_entries(const test_zip_entry_t *entries, size_t entry_count, + int *out_len) { + enum { TEST_ZIP_ENTRY_MAX = 8 }; + if (!entries || !out_len || entry_count == 0 || entry_count > TEST_ZIP_ENTRY_MAX) { + return NULL; + } + size_t local_size = 0; + size_t central_size = 0; + for (size_t index = 0; index < entry_count; index++) { + size_t name_size = strlen(entries[index].name); + local_size += 30U + name_size + entries[index].content_size; + central_size += 46U + name_size; + } + size_t total = local_size + central_size + 22U; + if (total > INT_MAX) { + return NULL; + } + unsigned char *zip = calloc(1, total); + if (!zip) { + return NULL; + } + uint32_t local_offsets[TEST_ZIP_ENTRY_MAX]; + uint32_t crcs[TEST_ZIP_ENTRY_MAX]; + size_t cursor = 0; + for (size_t index = 0; index < entry_count; index++) { + size_t name_size = strlen(entries[index].name); + local_offsets[index] = (uint32_t)cursor; + crcs[index] = + (uint32_t)crc32(0L, entries[index].content, (uInt)entries[index].content_size); + zip[cursor] = 0x50; + zip[cursor + 1U] = 0x4b; + zip[cursor + 2U] = 0x03; + zip[cursor + 3U] = 0x04; + test_zip_put_u16(zip + cursor + 4U, 20U); + test_zip_put_u32(zip + cursor + 14U, crcs[index]); + test_zip_put_u32(zip + cursor + 18U, (uint32_t)entries[index].content_size); + test_zip_put_u32(zip + cursor + 22U, (uint32_t)entries[index].content_size); + test_zip_put_u16(zip + cursor + 26U, (uint16_t)name_size); + memcpy(zip + cursor + 30U, entries[index].name, name_size); + cursor += 30U + name_size; + memcpy(zip + cursor, entries[index].content, entries[index].content_size); + cursor += entries[index].content_size; + } + + size_t central_offset = cursor; + for (size_t index = 0; index < entry_count; index++) { + size_t name_size = strlen(entries[index].name); + zip[cursor] = 0x50; + zip[cursor + 1U] = 0x4b; + zip[cursor + 2U] = 0x01; + zip[cursor + 3U] = 0x02; + test_zip_put_u16(zip + cursor + 4U, 20U); + test_zip_put_u16(zip + cursor + 6U, 20U); + test_zip_put_u32(zip + cursor + 16U, crcs[index]); + test_zip_put_u32(zip + cursor + 20U, (uint32_t)entries[index].content_size); + test_zip_put_u32(zip + cursor + 24U, (uint32_t)entries[index].content_size); + test_zip_put_u16(zip + cursor + 28U, (uint16_t)name_size); + test_zip_put_u32(zip + cursor + 42U, local_offsets[index]); + memcpy(zip + cursor + 46U, entries[index].name, name_size); + cursor += 46U + name_size; + } + size_t central_length = cursor - central_offset; + zip[cursor] = 0x50; + zip[cursor + 1U] = 0x4b; + zip[cursor + 2U] = 0x05; + zip[cursor + 3U] = 0x06; + test_zip_put_u16(zip + cursor + 8U, (uint16_t)entry_count); + test_zip_put_u16(zip + cursor + 10U, (uint16_t)entry_count); + test_zip_put_u32(zip + cursor + 12U, (uint32_t)central_length); + test_zip_put_u32(zip + cursor + 16U, (uint32_t)central_offset); + *out_len = (int)total; + return zip; +} + +static unsigned char *create_test_zip_pair(const test_zip_entry_t entries[2], int *out_len) { + return create_test_zip_entries(entries, 2U, out_len); +} + +static unsigned char *create_test_windows_release_zip(const char *launcher_name, + const char *payload_name, int *out_len) { + static const unsigned char launcher[] = "MZ-launcher"; + static const unsigned char payload[] = "MZ-payload"; + static const unsigned char license[] = "license"; + static const unsigned char installer[] = "installer"; + static const unsigned char notices[] = "notices"; + test_zip_entry_t entries[5] = { + { + .name = launcher_name, + .content = launcher, + .content_size = sizeof(launcher) - 1U, + }, + { + .name = payload_name, + .content = payload, + .content_size = sizeof(payload) - 1U, + }, + {"LICENSE", license, sizeof(license) - 1U}, + {"install.ps1", installer, sizeof(installer) - 1U}, + {"THIRD_PARTY_NOTICES.md", notices, sizeof(notices) - 1U}, + }; + return create_test_zip_entries(entries, 5U, out_len); +} + +TEST(cli_extract_windows_release_pair_rejects_incomplete_release_namespace) { + static const unsigned char launcher[] = "MZ-launcher"; + static const unsigned char payload[] = "MZ-payload"; + const test_zip_entry_t entries[2] = { + {"codebase-memory-mcp.exe", launcher, sizeof(launcher) - 1U}, + {"codebase-memory-mcp.payload.exe", payload, sizeof(payload) - 1U}, + }; + int zip_length = 0; + unsigned char *zip = create_test_zip_pair(entries, &zip_length); + ASSERT_NOT_NULL(zip); + cbm_windows_release_pair_t pair; + ASSERT_FALSE(cbm_extract_windows_release_pair_from_zip(zip, zip_length, &pair)); + cbm_windows_release_pair_free(&pair); + free(zip); + PASS(); +} + +/* Release archives retain their legal notices and the standalone installer. + * The updater must accept that exact official namespace while extracting only + * the launcher/payload pair. Synthetic two-file fixtures previously hid that + * every published Windows update would be rejected. */ +TEST(cli_extract_windows_release_pair_accepts_official_release_namespace) { + static const unsigned char launcher[] = "MZ-launcher"; + static const unsigned char payload[] = "MZ-payload"; + static const unsigned char license[] = "license"; + static const unsigned char installer[] = "installer"; + static const unsigned char notices[] = "notices"; + const test_zip_entry_t entries[] = { + {"codebase-memory-mcp.exe", launcher, sizeof(launcher) - 1U}, + {"codebase-memory-mcp.payload.exe", payload, sizeof(payload) - 1U}, + {"LICENSE", license, sizeof(license) - 1U}, + {"install.ps1", installer, sizeof(installer) - 1U}, + {"THIRD_PARTY_NOTICES.md", notices, sizeof(notices) - 1U}, + }; + int zip_length = 0; + unsigned char *zip = + create_test_zip_entries(entries, sizeof(entries) / sizeof(entries[0]), &zip_length); + ASSERT_NOT_NULL(zip); + cbm_windows_release_pair_t pair; + ASSERT_TRUE(cbm_extract_windows_release_pair_from_zip(zip, zip_length, &pair)); + ASSERT_EQ(pair.launcher_len, 11); + ASSERT_EQ(pair.payload_len, 10); + ASSERT_MEM_EQ(pair.launcher, "MZ-launcher", 11); + ASSERT_MEM_EQ(pair.payload, "MZ-payload", 10); + cbm_windows_release_pair_free(&pair); + free(zip); + PASS(); +} + +TEST(cli_extract_windows_release_pair_rejects_unknown_release_member) { + static const unsigned char content[] = "x"; + const test_zip_entry_t entries[] = { + {"codebase-memory-mcp.exe", content, sizeof(content) - 1U}, + {"codebase-memory-mcp.payload.exe", content, sizeof(content) - 1U}, + {"LICENSE", content, sizeof(content) - 1U}, + {"install.ps1", content, sizeof(content) - 1U}, + {"unexpected.dll", content, sizeof(content) - 1U}, + }; + int zip_length = 0; + unsigned char *zip = + create_test_zip_entries(entries, sizeof(entries) / sizeof(entries[0]), &zip_length); + ASSERT_NOT_NULL(zip); + cbm_windows_release_pair_t pair; + ASSERT_FALSE(cbm_extract_windows_release_pair_from_zip(zip, zip_length, &pair)); + cbm_windows_release_pair_free(&pair); + free(zip); + PASS(); +} + +TEST(cli_extract_windows_release_pair_rejects_aliases_and_duplicates) { + static const struct { + const char *launcher; + const char *payload; + } attacks[] = { + { + "CODEBASE-MEMORY-MCP.EXE", + "codebase-memory-mcp.payload.exe", + }, + { + "codebase-memory-mcp.exe", + "codebase-memory-mcp.exe", + }, + { + ".\\codebase-memory-mcp.exe", + "codebase-memory-mcp.payload.exe", + }, + { + "codebase-memory-mcp.exe.", + "codebase-memory-mcp.payload.exe", + }, + { + "codebase-memory-mcp.exe", + "codebase-memory-mcp.payload.exe ", + }, + }; + for (size_t index = 0; index < sizeof(attacks) / sizeof(attacks[0]); index++) { + int zip_length = 0; + unsigned char *zip = create_test_windows_release_zip(attacks[index].launcher, + attacks[index].payload, &zip_length); + ASSERT_NOT_NULL(zip); + cbm_windows_release_pair_t pair; + ASSERT_FALSE(cbm_extract_windows_release_pair_from_zip(zip, zip_length, &pair)); + cbm_windows_release_pair_free(&pair); + free(zip); + } + PASS(); +} + +TEST(cli_extract_windows_release_pair_rejects_local_central_mismatch) { + int zip_length = 0; + unsigned char *zip = create_test_windows_release_zip( + "codebase-memory-mcp.exe", "codebase-memory-mcp.payload.exe", &zip_length); + ASSERT_NOT_NULL(zip); + /* Local name starts at offset 30; central metadata remains unchanged. */ + zip[30] = 'x'; + cbm_windows_release_pair_t pair; + ASSERT_FALSE(cbm_extract_windows_release_pair_from_zip(zip, zip_length, &pair)); + cbm_windows_release_pair_free(&pair); + free(zip); + PASS(); +} + TEST(cli_extract_binary_from_zip) { const char *content = "#!/bin/sh\necho test\n"; int zip_len = 0; @@ -3546,8 +3764,7 @@ TEST(cli_agent_uninstall_reports_safe_editor_refusal) { snprintf(bin_dir, sizeof(bin_dir), "%s/.local/bin", tmpdir); test_mkdirp(bin_dir); #ifdef _WIN32 - snprintf(bin_path, sizeof(bin_path), "%s/codebase-memory-mcp.exe", - bin_dir); + snprintf(bin_path, sizeof(bin_path), "%s/codebase-memory-mcp.exe", bin_dir); #else snprintf(bin_path, sizeof(bin_path), "%s/codebase-memory-mcp", bin_dir); #endif @@ -3575,10 +3792,8 @@ TEST(cli_agent_uninstall_reports_safe_editor_refusal) { restore_test_env("HOME", saved_home); restore_test_env("PATH", saved_path); test_rmdir_r(tmpdir); - if (rc == 0 || !preserved || !binary_preserved || - fake.mutation_reserve_count != 1 || - fake.mutation_lease_release_count != 1 || - !strstr(fake.diagnostic, "executable was kept")) + if (rc == 0 || !preserved || !binary_preserved || fake.mutation_reserve_count != 1 || + fake.mutation_lease_release_count != 1 || !strstr(fake.diagnostic, "executable was kept")) FAIL("agent uninstall refusal must fail before removing the live binary"); PASS(); } @@ -10760,29 +10975,24 @@ static void *cli_config_read_with_handoff(void *opaque) { const char *value = cbm_config_get(read->config, read->key, NULL); read->storage_address = (uintptr_t)value; atomic_store_explicit(read->phase, 1, memory_order_release); - for (int spins = 0; spins < 5000 && - atomic_load_explicit(read->phase, memory_order_acquire) < 2; - spins++) { + for (int spins = 0; + spins < 5000 && atomic_load_explicit(read->phase, memory_order_acquire) < 2; spins++) { cbm_usleep(1000); } - read->completed_handoff = - atomic_load_explicit(read->phase, memory_order_acquire) == 2; + read->completed_handoff = atomic_load_explicit(read->phase, memory_order_acquire) == 2; read->value_preserved = read->completed_handoff && value && strcmp(value, read->expected) == 0; return NULL; } - for (int spins = 0; spins < 5000 && - atomic_load_explicit(read->phase, memory_order_acquire) < 1; + for (int spins = 0; spins < 5000 && atomic_load_explicit(read->phase, memory_order_acquire) < 1; spins++) { cbm_usleep(1000); } - read->completed_handoff = - atomic_load_explicit(read->phase, memory_order_acquire) == 1; + read->completed_handoff = atomic_load_explicit(read->phase, memory_order_acquire) == 1; const char *value = cbm_config_get(read->config, read->key, NULL); read->storage_address = (uintptr_t)value; - read->value_preserved = - read->completed_handoff && value && strcmp(value, read->expected) == 0; + read->value_preserved = read->completed_handoff && value && strcmp(value, read->expected) == 0; atomic_store_explicit(read->phase, 2, memory_order_release); return NULL; } @@ -10805,18 +11015,12 @@ TEST(cli_config_get_result_storage_is_per_thread) { atomic_int phase; atomic_init(&phase, 0); cli_config_read_thread_t reads[2] = { - {.config = cfg, - .key = "first", - .expected = "alpha", - .phase = &phase, - .first_reader = true}, + {.config = cfg, .key = "first", .expected = "alpha", .phase = &phase, .first_reader = true}, {.config = cfg, .key = "second", .expected = "beta", .phase = &phase}, }; cbm_thread_t threads[2]; - bool started0 = - cbm_thread_create(&threads[0], 0, cli_config_read_with_handoff, &reads[0]) == 0; - bool started1 = - cbm_thread_create(&threads[1], 0, cli_config_read_with_handoff, &reads[1]) == 0; + bool started0 = cbm_thread_create(&threads[0], 0, cli_config_read_with_handoff, &reads[0]) == 0; + bool started1 = cbm_thread_create(&threads[1], 0, cli_config_read_with_handoff, &reads[1]) == 0; if (started0) { (void)cbm_thread_join(&threads[0]); } @@ -10824,8 +11028,7 @@ TEST(cli_config_get_result_storage_is_per_thread) { (void)cbm_thread_join(&threads[1]); } - bool separate_storage = reads[0].storage_address != 0 && - reads[1].storage_address != 0 && + bool separate_storage = reads[0].storage_address != 0 && reads[1].storage_address != 0 && reads[0].storage_address != reads[1].storage_address; cbm_config_close(cfg); test_rmdir_r(tmpdir); @@ -11153,8 +11356,7 @@ static int sha256_vector_ok(const void *content, size_t len, const char *expecte } static bool cli_checksum_manifest_path(char *path, size_t path_size) { - int written = snprintf(path, path_size, "%s/cbm-checksum-XXXXXX", - cbm_tmpdir()); + int written = snprintf(path, path_size, "%s/cbm-checksum-XXXXXX", cbm_tmpdir()); if (written <= 0 || (size_t)written >= path_size) { return false; } @@ -11186,15 +11388,13 @@ TEST(cli_checksum_manifest_requires_exact_filename_and_accepts_star) { char path[512]; ASSERT_TRUE(cli_checksum_manifest_path(path, sizeof(path))); char manifest[1024]; - ASSERT_TRUE(snprintf(manifest, sizeof(manifest), - "%s prefix-%s\n%s *%s\n%s %s\n", - other_digest, artifact, upper_digest, artifact, - lower_digest, artifact) > 0); + ASSERT_TRUE(snprintf(manifest, sizeof(manifest), "%s prefix-%s\n%s *%s\n%s %s\n", + other_digest, artifact, upper_digest, artifact, lower_digest, + artifact) > 0); ASSERT_EQ(write_test_file(path, manifest), 0); char parsed[65] = {0}; - int status = cbm_cli_checksum_manifest_digest( - path, artifact, parsed, sizeof(parsed)); + int status = cbm_cli_checksum_manifest_digest(path, artifact, parsed, sizeof(parsed)); (void)cbm_unlink(path); ASSERT_EQ(status, 0); @@ -11215,33 +11415,24 @@ TEST(cli_checksum_manifest_rejects_invalid_missing_and_conflicting_digest) { char manifest[1024]; char parsed[65] = {0}; - ASSERT_TRUE(snprintf(manifest, sizeof(manifest), "%s prefix-%s\n", - digest_a, artifact) > 0); + ASSERT_TRUE(snprintf(manifest, sizeof(manifest), "%s prefix-%s\n", digest_a, artifact) > 0); ASSERT_EQ(write_test_file(path, manifest), 0); - ASSERT_NEQ(cbm_cli_checksum_manifest_digest( - path, artifact, parsed, sizeof(parsed)), - 0); + ASSERT_NEQ(cbm_cli_checksum_manifest_digest(path, artifact, parsed, sizeof(parsed)), 0); - ASSERT_TRUE(snprintf(manifest, sizeof(manifest), "%s %s\n", - invalid_digest, artifact) > 0); + ASSERT_TRUE(snprintf(manifest, sizeof(manifest), "%s %s\n", invalid_digest, artifact) > 0); ASSERT_EQ(write_test_file(path, manifest), 0); - ASSERT_NEQ(cbm_cli_checksum_manifest_digest( - path, artifact, parsed, sizeof(parsed)), - 0); + ASSERT_NEQ(cbm_cli_checksum_manifest_digest(path, artifact, parsed, sizeof(parsed)), 0); - ASSERT_TRUE(snprintf(manifest, sizeof(manifest), "%s %s\n%s *%s\n", - digest_a, artifact, digest_b, artifact) > 0); + ASSERT_TRUE(snprintf(manifest, sizeof(manifest), "%s %s\n%s *%s\n", digest_a, artifact, + digest_b, artifact) > 0); ASSERT_EQ(write_test_file(path, manifest), 0); - ASSERT_NEQ(cbm_cli_checksum_manifest_digest( - path, artifact, parsed, sizeof(parsed)), - 0); + ASSERT_NEQ(cbm_cli_checksum_manifest_digest(path, artifact, parsed, sizeof(parsed)), 0); (void)cbm_unlink(path); PASS(); } TEST(cli_checksum_manifest_rejects_oversized_input) { - static const char digest[] = - "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + static const char digest[] = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; const char *artifact = "codebase-memory-mcp-windows-amd64.zip"; char path[512]; ASSERT_TRUE(cli_checksum_manifest_path(path, sizeof(path))); @@ -11251,14 +11442,12 @@ TEST(cli_checksum_manifest_rejects_oversized_input) { unsigned char padding[1024]; memset(padding, 'x', sizeof(padding)); for (int i = 0; i < 65; i++) { - ASSERT_EQ(fwrite(padding, 1, sizeof(padding), manifest), - sizeof(padding)); + ASSERT_EQ(fwrite(padding, 1, sizeof(padding), manifest), sizeof(padding)); } ASSERT_EQ(fclose(manifest), 0); char parsed[65] = {0}; - int status = cbm_cli_checksum_manifest_digest( - path, artifact, parsed, sizeof(parsed)); + int status = cbm_cli_checksum_manifest_digest(path, artifact, parsed, sizeof(parsed)); (void)cbm_unlink(path); ASSERT_NEQ(status, 0); @@ -11285,6 +11474,7 @@ TEST(cli_sha256_file_matches_known_vector) { SUITE(cli) { RUN_TEST(cli_progress_visibility_policy); RUN_TEST(cli_raw_mcp_result_preserves_tool_error_status); + RUN_TEST(cli_maintenance_cancellation_forces_failure_status); RUN_TEST(cli_progress_sink_accepts_worker_json_logs); RUN_TEST(cli_progress_sink_serializes_concurrent_callbacks); RUN_TEST(cli_sha256_file_matches_known_vector); @@ -11388,6 +11578,11 @@ SUITE(cli) { RUN_TEST(cli_extract_binary_from_targz_not_found); RUN_TEST(cli_extract_binary_from_targz_invalid_data); RUN_TEST(cli_extract_binary_from_zip); + RUN_TEST(cli_extract_windows_release_pair_rejects_incomplete_release_namespace); + RUN_TEST(cli_extract_windows_release_pair_accepts_official_release_namespace); + RUN_TEST(cli_extract_windows_release_pair_rejects_unknown_release_member); + RUN_TEST(cli_extract_windows_release_pair_rejects_aliases_and_duplicates); + RUN_TEST(cli_extract_windows_release_pair_rejects_local_central_mismatch); RUN_TEST(cli_extract_binary_from_zip_not_found); RUN_TEST(cli_extract_binary_from_zip_path_traversal); RUN_TEST(cli_extract_binary_from_zip_invalid); diff --git a/tests/test_daemon.c b/tests/test_daemon.c index 02004365e..fb0d0095a 100644 --- a/tests/test_daemon.c +++ b/tests/test_daemon.c @@ -144,8 +144,7 @@ TEST(daemon_shared_job_survives_until_final_subscriber_disconnects) { ASSERT_EQ(cbm_daemon_coordinator_state(c), CBM_DAEMON_COORDINATOR_RUNNING); ASSERT_EQ(cbm_daemon_active_jobs(c), 1); ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shared"), 0); - ASSERT_EQ(cbm_daemon_job_state(c, "project-shared"), - CBM_DAEMON_JOB_CANCEL_REQUESTED); + ASSERT_EQ(cbm_daemon_job_state(c, "project-shared"), CBM_DAEMON_JOB_CANCEL_REQUESTED); ASSERT_EQ(tracker.job_cancel_count, 1); ASSERT_EQ(tracker.shared_job_cancel_count, 1); ASSERT_EQ(tracker.unexpected_key_count, 0); @@ -230,8 +229,7 @@ TEST(daemon_heartbeat_extends_lease_and_expiry_releases_connection) { ASSERT_FALSE(cbm_daemon_client_heartbeat(c, owner, 1451)); ASSERT_FALSE(cbm_daemon_client_disconnected(c, owner, 1451)); ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-lease-job"), 0); - ASSERT_EQ(cbm_daemon_job_state(c, "project-lease-job"), - CBM_DAEMON_JOB_CANCEL_REQUESTED); + ASSERT_EQ(cbm_daemon_job_state(c, "project-lease-job"), CBM_DAEMON_JOB_CANCEL_REQUESTED); ASSERT_EQ(cbm_daemon_active_jobs(c), 1); ASSERT_EQ(cbm_daemon_active_watches(c), 0); ASSERT_EQ(tracker.job_cancel_count, 1); @@ -259,9 +257,8 @@ TEST(daemon_last_client_stops_immediately_and_releases_owned_work) { ASSERT_EQ(cbm_daemon_coordinator_state(c), CBM_DAEMON_COORDINATOR_RUNNING); ASSERT_EQ(cbm_daemon_job_subscribe(c, client, "project-shutdown-job", &job_subscription), CBM_DAEMON_SUBSCRIPTION_STARTED); - ASSERT_EQ( - cbm_daemon_watch_subscribe(c, client, "project-shutdown-watch", &watch_subscription), - CBM_DAEMON_SUBSCRIPTION_STARTED); + ASSERT_EQ(cbm_daemon_watch_subscribe(c, client, "project-shutdown-watch", &watch_subscription), + CBM_DAEMON_SUBSCRIPTION_STARTED); ASSERT_NEQ(job_subscription, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); ASSERT_NEQ(watch_subscription, CBM_DAEMON_SUBSCRIPTION_ID_INVALID); ASSERT_EQ(cbm_daemon_active_jobs(c), 1); @@ -274,8 +271,7 @@ TEST(daemon_last_client_stops_immediately_and_releases_owned_work) { ASSERT_EQ(cbm_daemon_active_jobs(c), 1); ASSERT_EQ(cbm_daemon_active_watches(c), 0); ASSERT_EQ(cbm_daemon_job_subscribers(c, "project-shutdown-job"), 0); - ASSERT_EQ(cbm_daemon_job_state(c, "project-shutdown-job"), - CBM_DAEMON_JOB_CANCEL_REQUESTED); + ASSERT_EQ(cbm_daemon_job_state(c, "project-shutdown-job"), CBM_DAEMON_JOB_CANCEL_REQUESTED); ASSERT_EQ(cbm_daemon_watch_subscribers(c, "project-shutdown-watch"), 0); ASSERT_EQ(tracker.job_cancel_count, 1); ASSERT_EQ(tracker.shutdown_job_cancel_count, 1); diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 2693e19de..04520586a 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -4,6 +4,7 @@ #include "test_framework.h" #include "test_helpers.h" +#include "cli/cli.h" #include "daemon/application.h" #include "daemon/application_internal.h" #include "daemon/ipc.h" @@ -12,7 +13,9 @@ #include "foundation/compat_fs.h" #include "foundation/compat_thread.h" #include "foundation/platform.h" +#include "foundation/subprocess.h" #include "mcp/mcp.h" +#include "mcp/mcp_internal.h" #include "pipeline/pipeline.h" #include "store/store.h" #include "ui/config.h" @@ -41,27 +44,23 @@ static bool app_project_lock_fixture_cleanup(app_project_lock_fixture_t *fixture } bool clean = true; if (fixture->daemon_locks && - cbm_project_lock_manager_free(&fixture->daemon_locks) != - CBM_PRIVATE_FILE_LOCK_OK) { + cbm_project_lock_manager_free(&fixture->daemon_locks) != CBM_PRIVATE_FILE_LOCK_OK) { clean = false; } if (fixture->external_locks && - cbm_project_lock_manager_free(&fixture->external_locks) != - CBM_PRIVATE_FILE_LOCK_OK) { + cbm_project_lock_manager_free(&fixture->external_locks) != CBM_PRIVATE_FILE_LOCK_OK) { clean = false; } if (fixture->worker_locks && - cbm_project_lock_manager_free(&fixture->worker_locks) != - CBM_PRIVATE_FILE_LOCK_OK) { + cbm_project_lock_manager_free(&fixture->worker_locks) != CBM_PRIVATE_FILE_LOCK_OK) { clean = false; } if (fixture->probe_locks && - cbm_project_lock_manager_free(&fixture->probe_locks) != - CBM_PRIVATE_FILE_LOCK_OK) { + cbm_project_lock_manager_free(&fixture->probe_locks) != CBM_PRIVATE_FILE_LOCK_OK) { clean = false; } - if (!fixture->daemon_locks && !fixture->external_locks && - !fixture->worker_locks && !fixture->probe_locks) { + if (!fixture->daemon_locks && !fixture->external_locks && !fixture->worker_locks && + !fixture->probe_locks) { cbm_daemon_ipc_endpoint_free(fixture->endpoint); fixture->endpoint = NULL; if (fixture->runtime_parent[0] && th_rmtree(fixture->runtime_parent) != 0) { @@ -83,8 +82,7 @@ static bool app_project_lock_fixture_init(app_project_lock_fixture_t *fixture) { if (!cbm_mkdtemp(fixture->runtime_parent)) { return false; } - fixture->endpoint = - cbm_daemon_ipc_endpoint_new("fedcba9876543210", fixture->runtime_parent); + fixture->endpoint = cbm_daemon_ipc_endpoint_new("fedcba9876543210", fixture->runtime_parent); if (fixture->endpoint) { fixture->external_locks = cbm_project_lock_manager_new(fixture->endpoint); fixture->daemon_locks = cbm_project_lock_manager_new(fixture->endpoint); @@ -123,12 +121,10 @@ static atomic_uint_fast64_t g_app_test_request_token = ATOMIC_VAR_INIT(1); static cbm_daemon_runtime_application_status_t app_test_request_tagged( const cbm_daemon_runtime_application_callbacks_t *callbacks, cbm_daemon_runtime_application_session_t *session, - cbm_daemon_runtime_application_token_t request_token, - const uint8_t *request, uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out) { - return callbacks->request(callbacks->context, session, request_token, - request, request_length, response_out, - response_length_out); + cbm_daemon_runtime_application_token_t request_token, const uint8_t *request, + uint32_t request_length, uint8_t **response_out, uint32_t *response_length_out) { + return callbacks->request(callbacks->context, session, request_token, request, request_length, + response_out, response_length_out); } static cbm_daemon_runtime_application_status_t app_test_request( @@ -136,26 +132,22 @@ static cbm_daemon_runtime_application_status_t app_test_request( cbm_daemon_runtime_application_session_t *session, const uint8_t *request, uint32_t request_length, uint8_t **response_out, uint32_t *response_length_out) { cbm_daemon_runtime_application_token_t request_token = - atomic_fetch_add_explicit(&g_app_test_request_token, 1, - memory_order_relaxed); - return app_test_request_tagged(callbacks, session, request_token, request, - request_length, response_out, - response_length_out); + atomic_fetch_add_explicit(&g_app_test_request_token, 1, memory_order_relaxed); + return app_test_request_tagged(callbacks, session, request_token, request, request_length, + response_out, response_length_out); } -static bool app_test_context_request_options( - const char *root, const char *allowed, - cbm_mcp_tool_profile_t tool_profile, const char *hook_event, - const char *hook_dialect, uint8_t **request_out, uint32_t *length_out) { +static bool app_test_context_request_options(const char *root, const char *allowed, + cbm_mcp_tool_profile_t tool_profile, + const char *hook_event, const char *hook_dialect, + uint8_t **request_out, uint32_t *length_out) { size_t root_length = strlen(root); size_t allowed_length = allowed ? strlen(allowed) : 0; size_t event_length = hook_event ? strlen(hook_event) : 0; size_t dialect_length = hook_dialect ? strlen(hook_dialect) : 0; - size_t total = 19U + root_length + allowed_length + event_length + - dialect_length; - if (root_length == 0 || root_length > UINT32_MAX || - allowed_length > UINT32_MAX || event_length > UINT32_MAX || - dialect_length > UINT32_MAX || total > UINT32_MAX) { + size_t total = 19U + root_length + allowed_length + event_length + dialect_length; + if (root_length == 0 || root_length > UINT32_MAX || allowed_length > UINT32_MAX || + event_length > UINT32_MAX || dialect_length > UINT32_MAX || total > UINT32_MAX) { return false; } uint8_t *request = calloc(1, total); @@ -186,24 +178,21 @@ static bool app_test_context_request_options( memcpy(request + 19 + root_length, allowed, allowed_length); } if (event_length > 0) { - memcpy(request + 19 + root_length + allowed_length, hook_event, - event_length); + memcpy(request + 19 + root_length + allowed_length, hook_event, event_length); } if (dialect_length > 0) { - memcpy(request + 19 + root_length + allowed_length + event_length, - hook_dialect, dialect_length); + memcpy(request + 19 + root_length + allowed_length + event_length, hook_dialect, + dialect_length); } *request_out = request; *length_out = (uint32_t)total; return true; } -static bool app_test_context_request(const char *root, const char *allowed, - uint8_t **request_out, +static bool app_test_context_request(const char *root, const char *allowed, uint8_t **request_out, uint32_t *length_out) { - return app_test_context_request_options( - root, allowed, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL, request_out, - length_out); + return app_test_context_request_options(root, allowed, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL, + request_out, length_out); } static bool app_test_text_request(cbm_daemon_application_request_kind_t kind, const char *text, @@ -249,8 +238,8 @@ static bool app_test_tool_request(const char *tool, const char *args, uint8_t ** static cbm_daemon_runtime_application_status_t app_test_ui_config_request( const cbm_daemon_runtime_application_callbacks_t *callbacks, - cbm_daemon_runtime_application_session_t *session, uint8_t mask, - uint8_t enabled, uint32_t port, uint32_t request_length) { + cbm_daemon_runtime_application_session_t *session, uint8_t mask, uint8_t enabled, uint32_t port, + uint32_t request_length) { uint8_t request[7] = { CBM_DAEMON_APPLICATION_REQUEST_SET_UI_CONFIG, mask, @@ -263,8 +252,7 @@ static cbm_daemon_runtime_application_status_t app_test_ui_config_request( uint8_t *response = NULL; uint32_t response_length = 0; cbm_daemon_runtime_application_status_t status = - app_test_request(callbacks, session, request, request_length, - &response, &response_length); + app_test_request(callbacks, session, request, request_length, &response, &response_length); if (response || response_length != 0) { free(response); return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; @@ -278,14 +266,12 @@ TEST(daemon_application_new_session_does_not_retain_initial_store) { cbm_daemon_application_runtime_callbacks(application); cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 300); - bool retains_store = - session && cbm_daemon_application_session_retains_store_for_test(session); + bool retains_store = session && cbm_daemon_application_session_retains_store_for_test(session); if (session) { callbacks.session_close(callbacks.context, session); } - bool stopped = - application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); ASSERT_NOT_NULL(application); @@ -297,20 +283,16 @@ TEST(daemon_application_new_session_does_not_retain_initial_store) { TEST(daemon_application_request_cancel_is_scoped_to_exact_token) { char root[APP_TEST_PATH_CAP]; - (void)snprintf(root, sizeof(root), - "%s/cbm-app-request-cancel-XXXXXX", cbm_tmpdir()); + (void)snprintf(root, sizeof(root), "%s/cbm-app-request-cancel-XXXXXX", cbm_tmpdir()); bool root_ok = cbm_mkdtemp(root) != NULL; - cbm_daemon_application_t *application = - cbm_daemon_application_new(NULL); + cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); cbm_daemon_runtime_application_callbacks_t callbacks = cbm_daemon_application_runtime_callbacks(application); - cbm_daemon_runtime_application_session_t *session = - app_test_open(&callbacks, 301); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 301); uint8_t *request = NULL; uint32_t request_length = 0; - bool request_created = root_ok && - app_test_context_request( - root, root, &request, &request_length); + bool request_created = + root_ok && app_test_context_request(root, root, &request, &request_length); cbm_daemon_runtime_application_token_t cancelled_token = UINT64_C(7001); cbm_daemon_runtime_application_token_t next_token = UINT64_C(7002); uint8_t *cancelled_response = NULL; @@ -323,18 +305,16 @@ TEST(daemon_application_request_cancel_is_scoped_to_exact_token) { CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; if (session && request_created) { - callbacks.request_cancel(callbacks.context, session, - cancelled_token); - cancelled_status = app_test_request_tagged( - &callbacks, session, cancelled_token, request, request_length, - &cancelled_response, &cancelled_response_length); + callbacks.request_cancel(callbacks.context, session, cancelled_token); + cancelled_status = + app_test_request_tagged(&callbacks, session, cancelled_token, request, request_length, + &cancelled_response, &cancelled_response_length); /* A late duplicate for the completed token must not poison the next * unique request on this still-live session. */ - callbacks.request_cancel(callbacks.context, session, - cancelled_token); - next_status = app_test_request_tagged( - &callbacks, session, next_token, request, request_length, - &next_response, &next_response_length); + callbacks.request_cancel(callbacks.context, session, cancelled_token); + next_status = + app_test_request_tagged(&callbacks, session, next_token, request, request_length, + &next_response, &next_response_length); } free(request); @@ -343,9 +323,7 @@ TEST(daemon_application_request_cancel_is_scoped_to_exact_token) { if (session) { callbacks.session_close(callbacks.context, session); } - bool stopped = application && - cbm_daemon_application_shutdown( - application, APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); if (root_ok) { (void)cbm_rmdir(root); @@ -365,12 +343,9 @@ TEST(daemon_application_request_cancel_is_scoped_to_exact_token) { TEST(daemon_application_ui_config_updates_are_masked_and_serialized) { char cache[APP_TEST_PATH_CAP]; - (void)snprintf(cache, sizeof(cache), "%s/cbm-app-ui-config-XXXXXX", - cbm_tmpdir()); + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-ui-config-XXXXXX", cbm_tmpdir()); bool cache_ok = cbm_mkdtemp(cache) != NULL; - char *old_cache = getenv("CBM_CACHE_DIR") - ? strdup(getenv("CBM_CACHE_DIR")) - : NULL; + char *old_cache = getenv("CBM_CACHE_DIR") ? strdup(getenv("CBM_CACHE_DIR")) : NULL; bool env_ok = cache_ok && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; cbm_ui_config_t initial = {.ui_enabled = false, .ui_port = 9749}; bool initial_saved = env_ok && cbm_ui_config_save(&initial); @@ -378,44 +353,34 @@ TEST(daemon_application_ui_config_updates_are_masked_and_serialized) { cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); cbm_daemon_runtime_application_callbacks_t callbacks = cbm_daemon_application_runtime_callbacks(application); - cbm_daemon_runtime_application_session_t *enabled_session = - app_test_open(&callbacks, 301); - cbm_daemon_runtime_application_session_t *port_session = - app_test_open(&callbacks, 302); + cbm_daemon_runtime_application_session_t *enabled_session = app_test_open(&callbacks, 301); + cbm_daemon_runtime_application_session_t *port_session = app_test_open(&callbacks, 302); uint8_t *context = NULL; uint32_t context_length = 0; uint8_t *context_response = NULL; uint32_t context_response_length = 0; - bool context_encoded = cache_ok && app_test_context_request( - cache, cache, &context, - &context_length); - bool contexts_set = context_encoded && enabled_session && port_session && - app_test_request( - &callbacks, enabled_session, context, - context_length, &context_response, - &context_response_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK; + bool context_encoded = + cache_ok && app_test_context_request(cache, cache, &context, &context_length); + bool contexts_set = + context_encoded && enabled_session && port_session && + app_test_request(&callbacks, enabled_session, context, context_length, &context_response, + &context_response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; free(context_response); context_response = NULL; context_response_length = 0; - contexts_set = contexts_set && - app_test_request( - &callbacks, port_session, context, context_length, - &context_response, &context_response_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK; + contexts_set = + contexts_set && + app_test_request(&callbacks, port_session, context, context_length, &context_response, + &context_response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; free(context_response); /* Each request deliberately carries a stale value for the unmasked field. * Whole-struct replacement would lose one update; masked daemon-side * read-modify-write must preserve both regardless of request order. */ - cbm_daemon_runtime_application_status_t enabled_status = - app_test_ui_config_request( - &callbacks, enabled_session, - CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 1, 0, 7); - cbm_daemon_runtime_application_status_t port_status = - app_test_ui_config_request( - &callbacks, port_session, - CBM_DAEMON_APPLICATION_UI_CONFIG_PORT, 0, 18432, 7); + cbm_daemon_runtime_application_status_t enabled_status = app_test_ui_config_request( + &callbacks, enabled_session, CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 1, 0, 7); + cbm_daemon_runtime_application_status_t port_status = app_test_ui_config_request( + &callbacks, port_session, CBM_DAEMON_APPLICATION_UI_CONFIG_PORT, 0, 18432, 7); cbm_ui_config_t updated = {0}; cbm_ui_config_load(&updated); @@ -426,9 +391,7 @@ TEST(daemon_application_ui_config_updates_are_masked_and_serialized) { if (port_session) { callbacks.session_close(callbacks.context, port_session); } - bool stopped = application && - cbm_daemon_application_shutdown(application, - APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); free(context); if (old_cache) { @@ -456,12 +419,9 @@ TEST(daemon_application_ui_config_updates_are_masked_and_serialized) { TEST(daemon_application_ui_config_rejects_noncanonical_frames) { char cache[APP_TEST_PATH_CAP]; - (void)snprintf(cache, sizeof(cache), "%s/cbm-app-ui-frame-XXXXXX", - cbm_tmpdir()); + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-ui-frame-XXXXXX", cbm_tmpdir()); bool cache_ok = cbm_mkdtemp(cache) != NULL; - char *old_cache = getenv("CBM_CACHE_DIR") - ? strdup(getenv("CBM_CACHE_DIR")) - : NULL; + char *old_cache = getenv("CBM_CACHE_DIR") ? strdup(getenv("CBM_CACHE_DIR")) : NULL; bool env_ok = cache_ok && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; cbm_ui_config_t initial = {.ui_enabled = false, .ui_port = 9749}; bool initial_saved = env_ok && cbm_ui_config_save(&initial); @@ -469,51 +429,38 @@ TEST(daemon_application_ui_config_rejects_noncanonical_frames) { cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); cbm_daemon_runtime_application_callbacks_t callbacks = cbm_daemon_application_runtime_callbacks(application); - cbm_daemon_runtime_application_session_t *session = - app_test_open(&callbacks, 303); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 303); uint8_t *context = NULL; uint32_t context_length = 0; uint8_t *context_response = NULL; uint32_t context_response_length = 0; - bool context_encoded = cache_ok && app_test_context_request( - cache, cache, &context, - &context_length); - bool context_set = context_encoded && session && - app_test_request( - &callbacks, session, context, context_length, - &context_response, &context_response_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK; + bool context_encoded = + cache_ok && app_test_context_request(cache, cache, &context, &context_length); + bool context_set = + context_encoded && session && + app_test_request(&callbacks, session, context, context_length, &context_response, + &context_response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; free(context_response); - cbm_daemon_runtime_application_status_t short_frame = - app_test_ui_config_request( - &callbacks, session, - CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 1, 0, 6); + cbm_daemon_runtime_application_status_t short_frame = app_test_ui_config_request( + &callbacks, session, CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 1, 0, 6); cbm_daemon_runtime_application_status_t empty_mask = app_test_ui_config_request(&callbacks, session, 0, 0, 0, 7); cbm_daemon_runtime_application_status_t unknown_mask = app_test_ui_config_request(&callbacks, session, 0x80, 0, 0, 7); - cbm_daemon_runtime_application_status_t invalid_boolean = - app_test_ui_config_request( - &callbacks, session, - CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 2, 0, 7); - cbm_daemon_runtime_application_status_t invalid_port = - app_test_ui_config_request( - &callbacks, session, - CBM_DAEMON_APPLICATION_UI_CONFIG_PORT, 0, 65536, 7); - cbm_daemon_runtime_application_status_t nonzero_unused = - app_test_ui_config_request( - &callbacks, session, - CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 1, 9749, 7); + cbm_daemon_runtime_application_status_t invalid_boolean = app_test_ui_config_request( + &callbacks, session, CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 2, 0, 7); + cbm_daemon_runtime_application_status_t invalid_port = app_test_ui_config_request( + &callbacks, session, CBM_DAEMON_APPLICATION_UI_CONFIG_PORT, 0, 65536, 7); + cbm_daemon_runtime_application_status_t nonzero_unused = app_test_ui_config_request( + &callbacks, session, CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 1, 9749, 7); cbm_ui_config_t unchanged = {0}; cbm_ui_config_load(&unchanged); if (session) { callbacks.session_close(callbacks.context, session); } - bool stopped = application && - cbm_daemon_application_shutdown(application, - APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); free(context); if (old_cache) { @@ -657,6 +604,17 @@ static bool app_test_create_empty_file(const char *path) { return file && fclose(file) == 0; } +static int app_test_git(const char *root, const char *operation, const char *argument_one, + const char *argument_two) { + const char *argv[] = {"git", "-C", root, operation, argument_one, argument_two, NULL}; + cbm_proc_opts_t options = {0}; + cbm_proc_result_t result = {0}; + options.bin = "git"; + options.argv = argv; + options.quiet_timeout_ms = 10000; + return cbm_subprocess_run(&options, &result) == 0 && result.outcome == CBM_PROC_CLEAN ? 0 : -1; +} + /* Rebase guard: restricted MCP profiles are a property of one authenticated * daemon session. Before profile propagation, the daemon silently created a * full-surface MCP server, subscribed that session to the shared watcher, and @@ -667,32 +625,26 @@ TEST(daemon_application_restricted_profile_owns_no_background_surfaces) { char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; char root[APP_TEST_PATH_CAP]; char cache[APP_TEST_PATH_CAP]; - (void)snprintf(root, sizeof(root), - "%s/cbm-app-profile-root-XXXXXX", cbm_tmpdir()); - (void)snprintf(cache, sizeof(cache), - "%s/cbm-app-profile-cache-XXXXXX", cbm_tmpdir()); + (void)snprintf(root, sizeof(root), "%s/cbm-app-profile-root-XXXXXX", cbm_tmpdir()); + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-profile-cache-XXXXXX", cbm_tmpdir()); bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; - bool env_ok = dirs_ok && (!had_cache || saved_cache) && - cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + bool env_ok = + dirs_ok && (!had_cache || saved_cache) && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; cbm_ui_config_t initial_ui = {.ui_enabled = false, .ui_port = 9749}; bool ui_ready = env_ok && cbm_ui_config_save(&initial_ui); char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL; char db_path[APP_TEST_PATH_CAP] = {0}; if (project) { - (void)snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, - project); + (void)snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); } bool db_ok = project && app_test_create_empty_file(db_path); cbm_store_t *store = cbm_store_open_memory(); - cbm_watcher_t *watcher = - cbm_watcher_new(store, app_test_index_noop, NULL); + cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL); cbm_daemon_application_config_t config = {.watcher = watcher}; - cbm_daemon_application_t *application = - cbm_daemon_application_new(&config); + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); cbm_daemon_runtime_application_callbacks_t callbacks = cbm_daemon_application_runtime_callbacks(application); - cbm_daemon_runtime_application_session_t *session = - app_test_open(&callbacks, 304); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 304); uint8_t *context = NULL; uint32_t context_length = 0; @@ -700,66 +652,55 @@ TEST(daemon_application_restricted_profile_owns_no_background_surfaces) { uint32_t initialize_length = 0; uint8_t *index_call = NULL; uint32_t index_call_length = 0; - bool encoded = db_ok && session && - app_test_context_request_options( - root, root, CBM_MCP_TOOL_PROFILE_SCOUT, NULL, NULL, - &context, &context_length) && - app_test_text_request( - CBM_DAEMON_APPLICATION_REQUEST_MCP, - "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}", - &initialize, &initialize_length) && - app_test_text_request( - CBM_DAEMON_APPLICATION_REQUEST_MCP, - "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," - "\"params\":{\"name\":\"index_repository\",\"arguments\":{}}}", - &index_call, &index_call_length); + bool encoded = + db_ok && session && + app_test_context_request_options(root, root, CBM_MCP_TOOL_PROFILE_SCOUT, NULL, NULL, + &context, &context_length) && + app_test_text_request( + CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}", &initialize, + &initialize_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"index_repository\",\"arguments\":{}}}", + &index_call, &index_call_length); uint8_t *response = NULL; uint32_t response_length = 0; cbm_daemon_runtime_application_status_t context_status = - encoded ? app_test_request(&callbacks, session, context, - context_length, &response, + encoded ? app_test_request(&callbacks, session, context, context_length, &response, &response_length) : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; free(response); response = NULL; cbm_daemon_runtime_application_status_t initialize_status = context_status == CBM_DAEMON_RUNTIME_APPLICATION_OK - ? app_test_request(&callbacks, session, initialize, - initialize_length, &response, + ? app_test_request(&callbacks, session, initialize, initialize_length, &response, &response_length) : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; - bool scout_surface = - response && strstr((const char *)response, "scout tool profile") && - !strstr((const char *)response, "index_repository"); + bool scout_surface = response && strstr((const char *)response, "scout tool profile") && + !strstr((const char *)response, "index_repository"); free(response); response = NULL; cbm_daemon_runtime_application_status_t index_status = initialize_status == CBM_DAEMON_RUNTIME_APPLICATION_OK - ? app_test_request(&callbacks, session, index_call, - index_call_length, &response, + ? app_test_request(&callbacks, session, index_call, index_call_length, &response, &response_length) : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; bool index_refused = - response && strstr((const char *)response, - "not available in the scout tool profile"); + response && strstr((const char *)response, "not available in the scout tool profile"); free(response); response = NULL; - cbm_daemon_runtime_application_status_t ui_status = - app_test_ui_config_request( - &callbacks, session, - CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 1, 0, 7); + cbm_daemon_runtime_application_status_t ui_status = app_test_ui_config_request( + &callbacks, session, CBM_DAEMON_APPLICATION_UI_CONFIG_ENABLED, 1, 0, 7); int watch_count = watcher ? cbm_watcher_watch_count(watcher) : -1; - size_t active_jobs = application - ? cbm_daemon_application_active_jobs(application) - : SIZE_MAX; + size_t active_jobs = application ? cbm_daemon_application_active_jobs(application) : SIZE_MAX; cbm_ui_config_t final_ui = {0}; cbm_ui_config_load(&final_ui); if (session) { callbacks.session_close(callbacks.context, session); } - bool stopped = application && cbm_daemon_application_shutdown( - application, APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); if (watcher) { cbm_watcher_stop(watcher); @@ -803,46 +744,39 @@ TEST(daemon_application_restricted_profile_owns_no_background_surfaces) { * Copilot deliberately omits the event from stdin, making this non-vacuous. */ TEST(daemon_application_hook_context_preserves_event_and_dialect) { char root[APP_TEST_PATH_CAP]; - (void)snprintf(root, sizeof(root), - "%s/cbm-app-hook-context-XXXXXX", cbm_tmpdir()); + (void)snprintf(root, sizeof(root), "%s/cbm-app-hook-context-XXXXXX", cbm_tmpdir()); bool root_ok = cbm_mkdtemp(root) != NULL; - cbm_daemon_application_t *application = - cbm_daemon_application_new(NULL); + cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); cbm_daemon_runtime_application_callbacks_t callbacks = cbm_daemon_application_runtime_callbacks(application); - cbm_daemon_runtime_application_session_t *session = - app_test_open(&callbacks, 305); + cbm_daemon_runtime_application_session_t *session = app_test_open(&callbacks, 305); uint8_t *context = NULL; uint32_t context_length = 0; char input[APP_TEST_PATH_CAP + 32]; (void)snprintf(input, sizeof(input), "{\"cwd\":\"%s\"}", root); uint8_t *hook = NULL; uint32_t hook_length = 0; - bool encoded = root_ok && session && - app_test_context_request_options( - root, root, CBM_MCP_TOOL_PROFILE_ALL, "SessionStart", - "copilot", &context, &context_length) && - app_test_text_request( - CBM_DAEMON_APPLICATION_REQUEST_HOOK_AUGMENT, input, - &hook, &hook_length); + bool encoded = + root_ok && session && + app_test_context_request_options(root, root, CBM_MCP_TOOL_PROFILE_ALL, "SessionStart", + "copilot", &context, &context_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_HOOK_AUGMENT, input, &hook, + &hook_length); uint8_t *response = NULL; uint32_t response_length = 0; cbm_daemon_runtime_application_status_t context_status = - encoded ? app_test_request(&callbacks, session, context, - context_length, &response, + encoded ? app_test_request(&callbacks, session, context, context_length, &response, &response_length) : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; free(response); response = NULL; cbm_daemon_runtime_application_status_t hook_status = context_status == CBM_DAEMON_RUNTIME_APPLICATION_OK - ? app_test_request(&callbacks, session, hook, hook_length, - &response, &response_length) + ? app_test_request(&callbacks, session, hook, hook_length, &response, &response_length) : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; - bool copilot_shape = - response && strstr((const char *)response, "additionalContext") && - strstr((const char *)response, "Session context") && - !strstr((const char *)response, "hookSpecificOutput"); + bool copilot_shape = response && strstr((const char *)response, "additionalContext") && + strstr((const char *)response, "Session context") && + !strstr((const char *)response, "hookSpecificOutput"); free(response); free(context); @@ -850,8 +784,7 @@ TEST(daemon_application_hook_context_preserves_event_and_dialect) { if (session) { callbacks.session_close(callbacks.context, session); } - bool stopped = application && cbm_daemon_application_shutdown( - application, APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); if (root_ok) { (void)cbm_rmdir(root); @@ -963,8 +896,8 @@ TEST(daemon_application_free_releases_live_watch_once) { char survivor_root[APP_TEST_PATH_CAP]; char cache[APP_TEST_PATH_CAP]; snprintf(root, sizeof(root), "%s/cbm-app-free-watch-root-XXXXXX", cbm_tmpdir()); - snprintf(survivor_root, sizeof(survivor_root), - "%s/cbm-app-free-survivor-root-XXXXXX", cbm_tmpdir()); + snprintf(survivor_root, sizeof(survivor_root), "%s/cbm-app-free-survivor-root-XXXXXX", + cbm_tmpdir()); snprintf(cache, sizeof(cache), "%s/cbm-app-free-watch-cache-XXXXXX", cbm_tmpdir()); bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(survivor_root) != NULL && cbm_mkdtemp(cache) != NULL; @@ -979,11 +912,10 @@ TEST(daemon_application_free_releases_live_watch_once) { snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); } if (survivor_project) { - snprintf(survivor_db_path, sizeof(survivor_db_path), "%s/%s.db", cache, - survivor_project); + snprintf(survivor_db_path, sizeof(survivor_db_path), "%s/%s.db", cache, survivor_project); } - bool db_ok = app_test_create_empty_file(db_path) && - app_test_create_empty_file(survivor_db_path); + bool db_ok = + app_test_create_empty_file(db_path) && app_test_create_empty_file(survivor_db_path); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL); cbm_daemon_application_config_t config = {.watcher = watcher}; @@ -1002,14 +934,14 @@ TEST(daemon_application_free_releases_live_watch_once) { app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"ping\"}", &ping, &ping_length); - bool requested = encoded && - app_test_request(&callbacks, session, context, context_length, &response, - &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + bool requested = + encoded && app_test_request(&callbacks, session, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; free(response); response = NULL; - requested = requested && - app_test_request(&callbacks, session, ping, ping_length, &response, - &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + requested = + requested && app_test_request(&callbacks, session, ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; bool watch_live = requested && cbm_watcher_watch_count(watcher) == 1; /* Keep one watcher entry outside the application's subscription table. @@ -1092,8 +1024,7 @@ TEST(daemon_application_prune_clears_logical_watch_for_reregistration) { snprintf(wal_path, sizeof(wal_path), "%s/%s.db-wal", cache, project); snprintf(shm_path, sizeof(shm_path), "%s/%s.db-shm", cache, project); } - bool files_ok = app_test_create_empty_file(db_path) && - app_test_create_empty_file(wal_path) && + bool files_ok = app_test_create_empty_file(db_path) && app_test_create_empty_file(wal_path) && app_test_create_empty_file(shm_path); cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL); @@ -1119,11 +1050,10 @@ TEST(daemon_application_prune_clears_logical_watch_for_reregistration) { &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; free(response); response = NULL; - first_registered = - first_registered && app_test_request(&callbacks, first, ping, ping_length, &response, - &response_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK && - cbm_watcher_watch_count(watcher) == 1; + first_registered = first_registered && + app_test_request(&callbacks, first, ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK && + cbm_watcher_watch_count(watcher) == 1; free(response); response = NULL; @@ -1136,8 +1066,7 @@ TEST(daemon_application_prune_clears_logical_watch_for_reregistration) { !cbm_file_exists(db_path) && !cbm_file_exists(wal_path) && !cbm_file_exists(shm_path); - bool restored = pruned && cbm_mkdir_p(root, 0755) && - app_test_create_empty_file(db_path); + bool restored = pruned && cbm_mkdir_p(root, 0755) && app_test_create_empty_file(db_path); if (restored) { second = app_test_open(&callbacks, 14); } @@ -1146,11 +1075,10 @@ TEST(daemon_application_prune_clears_logical_watch_for_reregistration) { &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; free(response); response = NULL; - second_registered = - second_registered && app_test_request(&callbacks, second, ping, ping_length, &response, - &response_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK && - cbm_watcher_watch_count(watcher) == 1; + second_registered = second_registered && + app_test_request(&callbacks, second, ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK && + cbm_watcher_watch_count(watcher) == 1; free(response); response = NULL; @@ -1262,9 +1190,8 @@ static void app_fake_worker_read_file(const char *path, char *out, size_t out_si (void)fclose(file); } -static int app_fake_worker_start(void *opaque, const char *args_json, - size_t memory_budget_bytes, const char *marker_file, - const char *quarantine_file, +static int app_fake_worker_start(void *opaque, const char *args_json, size_t memory_budget_bytes, + const char *marker_file, const char *quarantine_file, cbm_daemon_application_worker_t *worker_out) { app_fake_worker_context_t *context = opaque; app_fake_worker_t *worker = calloc(1, sizeof(*worker)); @@ -1350,13 +1277,11 @@ static cbm_index_worker_poll_t app_fake_worker_poll(void *opaque, worker->result.exit_code = outcome == CBM_PROC_CLEAN ? 0 : -1; worker->result.tree_quiesced = true; const char *response = - worker->attempt < APP_FAKE_MAX_ATTEMPTS && - worker->context->responses[worker->attempt] + worker->attempt < APP_FAKE_MAX_ATTEMPTS && worker->context->responses[worker->attempt] ? worker->context->responses[worker->attempt] : "{\"content\":[{\"type\":\"text\",\"text\":\"{" "\\\"status\\\":\\\"indexed\\\"}\"}]}"; - worker->result.response = - outcome == CBM_PROC_CLEAN ? cbm_strdup(response) : NULL; + worker->result.response = outcome == CBM_PROC_CLEAN ? cbm_strdup(response) : NULL; *result_out = &worker->result; return CBM_INDEX_WORKER_POLL_TERMINAL; } @@ -1416,17 +1341,14 @@ typedef struct { static void *app_request_thread(void *opaque) { app_request_thread_t *request = opaque; - request->status = request->request_token == - CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID - ? app_test_request( - &request->callbacks, request->session, - request->request, request->request_length, - &request->response, &request->response_length) - : app_test_request_tagged( - &request->callbacks, request->session, - request->request_token, request->request, - request->request_length, &request->response, - &request->response_length); + request->status = + request->request_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID + ? app_test_request(&request->callbacks, request->session, request->request, + request->request_length, &request->response, + &request->response_length) + : app_test_request_tagged(&request->callbacks, request->session, request->request_token, + request->request, request->request_length, &request->response, + &request->response_length); atomic_store(&request->done, true); return NULL; } @@ -1487,13 +1409,13 @@ static bool app_wait_for_active_jobs(cbm_daemon_application_t *application, size return false; } -static bool app_wait_for_terminal_job_with_subscribers( - cbm_daemon_application_t *application, const char *project, size_t minimum_subscribers) { +static bool app_wait_for_terminal_job_with_subscribers(cbm_daemon_application_t *application, + const char *project, + size_t minimum_subscribers) { uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; while (cbm_now_ms() < deadline) { if (cbm_daemon_application_active_jobs(application) == 0 && - cbm_daemon_application_job_subscribers(application, project) >= - minimum_subscribers) { + cbm_daemon_application_job_subscribers(application, project) >= minimum_subscribers) { return true; } } @@ -1537,21 +1459,18 @@ static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture, fixture->had_cache = old_cache != NULL; fixture->saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; bool saved_cache_ok = !fixture->had_cache || fixture->saved_cache; - (void)snprintf(fixture->root, sizeof(fixture->root), - "%s/cbm-app-watch-race-root-XXXXXX", cbm_tmpdir()); - (void)snprintf(fixture->cache, sizeof(fixture->cache), - "%s/cbm-app-watch-race-cache-XXXXXX", cbm_tmpdir()); - bool dirs_ok = cbm_mkdtemp(fixture->root) != NULL && - cbm_mkdtemp(fixture->cache) != NULL; - bool env_ok = saved_cache_ok && - cbm_setenv("CBM_CACHE_DIR", fixture->cache, 1) == 0; + (void)snprintf(fixture->root, sizeof(fixture->root), "%s/cbm-app-watch-race-root-XXXXXX", + cbm_tmpdir()); + (void)snprintf(fixture->cache, sizeof(fixture->cache), "%s/cbm-app-watch-race-cache-XXXXXX", + cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(fixture->root) != NULL && cbm_mkdtemp(fixture->cache) != NULL; + bool env_ok = saved_cache_ok && cbm_setenv("CBM_CACHE_DIR", fixture->cache, 1) == 0; fixture->project = dirs_ok ? cbm_project_name_from_path(fixture->root) : NULL; if (fixture->project) { - (void)snprintf(fixture->db_path, sizeof(fixture->db_path), "%s/%s.db", - fixture->cache, fixture->project); + (void)snprintf(fixture->db_path, sizeof(fixture->db_path), "%s/%s.db", fixture->cache, + fixture->project); } - bool db_ok = fixture->project && - app_test_create_empty_file(fixture->db_path); + bool db_ok = fixture->project && app_test_create_empty_file(fixture->db_path); cbm_daemon_application_worker_ops_t worker_ops = { .context = &fixture->fake, .start = app_fake_worker_start, @@ -1561,108 +1480,931 @@ static bool app_watch_race_fixture_init(app_watch_race_fixture_t *fixture, .destroy = app_fake_worker_destroy, }; fixture->store = cbm_store_open_memory(); - fixture->watcher = - cbm_watcher_new(fixture->store, app_test_index_noop, NULL); + fixture->watcher = cbm_watcher_new(fixture->store, app_test_index_noop, NULL); cbm_daemon_application_config_t config = { .watcher = fixture->watcher, .worker_ops = &worker_ops, }; fixture->application = cbm_daemon_application_new(&config); - fixture->callbacks = - cbm_daemon_application_runtime_callbacks(fixture->application); + fixture->callbacks = cbm_daemon_application_runtime_callbacks(fixture->application); if (fixture->application) { fixture->session = app_test_open(&fixture->callbacks, client_id); } - uint8_t *context = NULL; - uint32_t context_length = 0; - uint8_t *ping = NULL; - uint32_t ping_length = 0; - uint8_t *response = NULL; - uint32_t response_length = 0; - bool encoded = env_ok && dirs_ok && db_ok && fixture->store && - fixture->watcher && fixture->application && fixture->session && - app_test_context_request(fixture->root, fixture->root, - &context, &context_length) && - app_test_text_request( - CBM_DAEMON_APPLICATION_REQUEST_MCP, - "{\"jsonrpc\":\"2.0\",\"id\":19,\"method\":\"ping\"}", - &ping, &ping_length); - bool initialized = encoded && - app_test_request(&fixture->callbacks, fixture->session, - context, context_length, &response, - &response_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK; - free(response); - response = NULL; - response_length = 0; - initialized = initialized && - app_test_request(&fixture->callbacks, fixture->session, ping, - ping_length, &response, &response_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK; - free(response); - free(context); - free(ping); - return initialized && cbm_watcher_watch_count(fixture->watcher) == 1; + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool encoded = + env_ok && dirs_ok && db_ok && fixture->store && fixture->watcher && fixture->application && + fixture->session && + app_test_context_request(fixture->root, fixture->root, &context, &context_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":19,\"method\":\"ping\"}", &ping, + &ping_length); + bool initialized = encoded && app_test_request(&fixture->callbacks, fixture->session, context, + context_length, &response, &response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + response_length = 0; + initialized = initialized && app_test_request(&fixture->callbacks, fixture->session, ping, + ping_length, &response, &response_length) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + free(context); + free(ping); + return initialized && cbm_watcher_watch_count(fixture->watcher) == 1; +} + +static bool app_watch_race_fixture_finish(app_watch_race_fixture_t *fixture) { + if (fixture->session) { + fixture->callbacks.session_close(fixture->callbacks.context, fixture->session); + fixture->session = NULL; + } + bool stopped = !fixture->application || + cbm_daemon_application_shutdown(fixture->application, APP_TEST_TIMEOUT_MS); + if (stopped) { + cbm_daemon_application_free(fixture->application); + fixture->application = NULL; + cbm_watcher_stop(fixture->watcher); + cbm_watcher_free(fixture->watcher); + fixture->watcher = NULL; + cbm_store_close(fixture->store); + fixture->store = NULL; + } + free(fixture->project); + fixture->project = NULL; + (void)th_rmtree(fixture->root); + (void)th_rmtree(fixture->cache); + bool restored = fixture->saved_cache + ? cbm_setenv("CBM_CACHE_DIR", fixture->saved_cache, 1) == 0 + : (!fixture->had_cache && cbm_unsetenv("CBM_CACHE_DIR") == 0); + free(fixture->saved_cache); + fixture->saved_cache = NULL; + return stopped && restored; +} + +typedef struct { + cbm_daemon_application_t *application; + const char *project; + const char *root; + bool pause_before_subscribe; + atomic_bool ready; + atomic_bool proceed; + atomic_bool done; + int result; +} app_watcher_index_thread_t; + +static void *app_watcher_index_thread(void *opaque) { + app_watcher_index_thread_t *request = opaque; + if (request->pause_before_subscribe) { + atomic_store(&request->ready, true); + while (!atomic_load(&request->proceed)) { + cbm_usleep(1000); + } + } + request->result = + cbm_daemon_application_watcher_index(request->project, request->root, request->application); + atomic_store(&request->done, true); + return NULL; +} + +typedef struct { + const char *name; + bool had_value; + char *value; +} app_env_backup_t; + +static bool app_env_backup_capture(app_env_backup_t *backup, const char *name) { + memset(backup, 0, sizeof(*backup)); + backup->name = name; + const char *value = getenv(name); + backup->had_value = value != NULL; + backup->value = value ? cbm_strdup(value) : NULL; + return !backup->had_value || backup->value != NULL; +} + +static bool app_env_backup_restore(app_env_backup_t *backup) { + if (!backup || !backup->name) { + return true; + } + if (backup->had_value && !backup->value) { + backup->name = NULL; + return false; + } + int status = + backup->had_value ? cbm_setenv(backup->name, backup->value, 1) : cbm_unsetenv(backup->name); + free(backup->value); + backup->value = NULL; + backup->name = NULL; + return status == 0; +} + +typedef struct { + atomic_int starts; + atomic_int start_failures_remaining; + atomic_int cancels; + atomic_int destroys; + atomic_bool allow_completion; + const char *latest_version; +} app_fake_update_context_t; + +typedef struct { + app_fake_update_context_t *context; + atomic_bool cancelled; +} app_fake_update_worker_t; + +static void app_fake_update_context_init(app_fake_update_context_t *context, + bool allow_completion) { + memset(context, 0, sizeof(*context)); + atomic_init(&context->starts, 0); + atomic_init(&context->start_failures_remaining, 0); + atomic_init(&context->cancels, 0); + atomic_init(&context->destroys, 0); + atomic_init(&context->allow_completion, allow_completion); + context->latest_version = "v999.0.0"; +} + +static int app_fake_update_start(void *opaque, cbm_daemon_application_update_worker_t *worker_out) { + app_fake_update_context_t *context = opaque; + if (!context || !worker_out) { + return -1; + } + atomic_fetch_add(&context->starts, 1); + int failures = atomic_load(&context->start_failures_remaining); + while (failures > 0 && !atomic_compare_exchange_weak(&context->start_failures_remaining, + &failures, failures - 1)) {} + if (failures > 0) { + *worker_out = NULL; + return -1; + } + app_fake_update_worker_t *worker = calloc(1, sizeof(*worker)); + if (!worker) { + return -1; + } + worker->context = context; + atomic_init(&worker->cancelled, false); + *worker_out = worker; + return 0; +} + +static cbm_daemon_application_update_poll_t app_fake_update_poll( + void *opaque, cbm_daemon_application_update_worker_t handle, const char **latest_version_out) { + (void)opaque; + app_fake_update_worker_t *worker = handle; + *latest_version_out = NULL; + if (atomic_load(&worker->cancelled)) { + return CBM_DAEMON_APPLICATION_UPDATE_POLL_TERMINAL; + } + if (!atomic_load(&worker->context->allow_completion)) { + return CBM_DAEMON_APPLICATION_UPDATE_POLL_RUNNING; + } + *latest_version_out = worker->context->latest_version; + return CBM_DAEMON_APPLICATION_UPDATE_POLL_TERMINAL; +} + +static bool app_fake_update_cancel(void *opaque, cbm_daemon_application_update_worker_t handle) { + app_fake_update_context_t *context = opaque; + app_fake_update_worker_t *worker = handle; + if (!atomic_exchange(&worker->cancelled, true)) { + atomic_fetch_add(&context->cancels, 1); + } + return true; +} + +static void app_fake_update_destroy(void *opaque, cbm_daemon_application_update_worker_t handle) { + app_fake_update_context_t *context = opaque; + atomic_fetch_add(&context->destroys, 1); + free(handle); +} + +static cbm_daemon_application_update_ops_t app_fake_update_ops(app_fake_update_context_t *context) { + return (cbm_daemon_application_update_ops_t){ + .context = context, + .start = app_fake_update_start, + .poll = app_fake_update_poll, + .cancel = app_fake_update_cancel, + .destroy = app_fake_update_destroy, + }; +} + +static bool app_test_initialize_profile(const cbm_daemon_runtime_application_callbacks_t *callbacks, + cbm_daemon_runtime_application_session_t *session, + const char *root, cbm_mcp_tool_profile_t profile, + const char *hook_event, const char *hook_dialect) { + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *initialize = NULL; + uint32_t initialize_length = 0; + bool encoded = + session && + app_test_context_request_options(root, root, profile, hook_event, hook_dialect, &context, + &context_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":4100,\"method\":\"initialize\"," + "\"params\":{}}", + &initialize, &initialize_length); + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t context_status = + encoded ? app_test_request(callbacks, session, context, context_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + free(response); + response = NULL; + response_length = 0; + cbm_daemon_runtime_application_status_t initialize_status = + context_status == CBM_DAEMON_RUNTIME_APPLICATION_OK + ? app_test_request(callbacks, session, initialize, initialize_length, &response, + &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + bool initialized = initialize_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response && + strstr((char *)response, "\"result\""); + free(response); + free(context); + free(initialize); + return initialized; +} + +static cbm_daemon_runtime_application_status_t app_test_list_projects( + const cbm_daemon_runtime_application_callbacks_t *callbacks, + cbm_daemon_runtime_application_session_t *session, uint64_t id, uint8_t **response_out, + uint32_t *response_length_out) { + char message[256]; + (void)snprintf(message, sizeof(message), + "{\"jsonrpc\":\"2.0\",\"id\":%llu,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}", + (unsigned long long)id); + uint8_t *request = NULL; + uint32_t request_length = 0; + if (!app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, message, &request, + &request_length)) { + return CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + } + cbm_daemon_runtime_application_status_t status = app_test_request( + callbacks, session, request, request_length, response_out, response_length_out); + free(request); + return status; +} + +static bool app_wait_for_update_notice(const cbm_daemon_runtime_application_callbacks_t *callbacks, + cbm_daemon_runtime_application_session_t *session, + uint64_t *id, uint8_t **notice_out) { + uint64_t deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; + while (cbm_now_ms() < deadline) { + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t status = + app_test_list_projects(callbacks, session, (*id)++, &response, &response_length); + if (status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response && + strstr((char *)response, "Update available:")) { + *notice_out = response; + return true; + } + free(response); + cbm_usleep(1000); + } + *notice_out = NULL; + return false; +} + +/* Regression contract: initialize is the ownership boundary for daemon background + * indexing. Only normal full MCP sessions participate; identical roots share + * one physical worker but retain one subscription per live session. */ +TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions) { + app_env_backup_t cache_environment; + bool cache_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR"); + app_fake_update_context_t update; + app_fake_update_context_init(&update, false); + cbm_daemon_application_update_ops_t update_ops = app_fake_update_ops(&update); + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), "%s/cbm-app-auto-index-root-XXXXXX", cbm_tmpdir()); + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-auto-index-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; + bool cache_set = dirs_ok && cache_saved && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + cbm_config_t *stored_config = cache_set ? cbm_config_open(cache) : NULL; + bool config_ready = stored_config && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX, "true") == 0 && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_WATCH, "false") == 0; + char canonical_root[APP_TEST_PATH_CAP] = {0}; + bool canonical = dirs_ok && cbm_canonical_path(root, canonical_root, sizeof(canonical_root)); + char *project = canonical ? cbm_project_name_from_path(canonical_root) : NULL; + + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = { + .config = stored_config, + .worker_ops = &worker_ops, + .update_ops = &update_ops, + }; + cbm_daemon_application_t *application = + config_ready ? cbm_daemon_application_new(&config) : NULL; + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *sessions[4] = {NULL, NULL, NULL, NULL}; + for (size_t i = 0; application && i < 4; i++) { + sessions[i] = app_test_open(&callbacks, (cbm_daemon_client_id_t)(4110U + i)); + } + + bool scout_initialized = app_test_initialize_profile(&callbacks, sessions[2], root, + CBM_MCP_TOOL_PROFILE_SCOUT, NULL, NULL); + bool hook_initialized = app_test_initialize_profile( + &callbacks, sessions[3], root, CBM_MCP_TOOL_PROFILE_ALL, "SessionStart", "copilot"); + cbm_usleep(50000); + bool restricted_started_nothing = + atomic_load(&fake.starts) == 0 && application && project && + cbm_daemon_application_job_subscribers(application, project) == 0 && + atomic_load(&update.starts) == 0; + + bool first_initialized = app_test_initialize_profile(&callbacks, sessions[0], root, + CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + bool first_owned = first_initialized && project && + app_wait_for_subscribers(application, project, 1) && + app_wait_for_atomic_int(&fake.starts, 1); + bool second_initialized = app_test_initialize_profile(&callbacks, sessions[1], root, + CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + bool coalesced = first_owned && second_initialized && project && + app_wait_for_subscribers(application, project, 2) && + cbm_daemon_application_active_jobs(application) == 1 && + atomic_load(&fake.starts) == 1; + + for (size_t i = 2; i < 4; i++) { + if (sessions[i]) { + callbacks.session_cancel(callbacks.context, sessions[i]); + callbacks.session_close(callbacks.context, sessions[i]); + sessions[i] = NULL; + } + } + bool restricted_disconnect_kept_job = + coalesced && project && cbm_daemon_application_job_subscribers(application, project) == 2 && + atomic_load(&fake.cancels) == 0; + if (sessions[0]) { + callbacks.session_cancel(callbacks.context, sessions[0]); + callbacks.session_close(callbacks.context, sessions[0]); + sessions[0] = NULL; + } + bool one_owner_left = coalesced && project && + app_wait_for_subscribers(application, project, 1) && + atomic_load(&fake.cancels) == 0; + if (sessions[1]) { + callbacks.session_cancel(callbacks.context, sessions[1]); + callbacks.session_close(callbacks.context, sessions[1]); + sessions[1] = NULL; + } + bool final_cancelled = coalesced && app_wait_for_atomic_int(&fake.cancels, 1); + bool final_reaped = final_cancelled && app_wait_for_atomic_int(&fake.destroys, 1) && + app_wait_for_active_jobs(application, 0); + + for (size_t i = 0; i < 4; i++) { + if (sessions[i]) { + callbacks.session_cancel(callbacks.context, sessions[i]); + callbacks.session_close(callbacks.context, sessions[i]); + } + } + if (!final_reaped) { + atomic_store(&fake.allow_completion, true); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + cbm_config_close(stored_config); + free(project); + (void)th_rmtree(root); + (void)th_rmtree(cache); + bool cache_restored = app_env_backup_restore(&cache_environment); + + ASSERT_TRUE(cache_saved); + ASSERT_TRUE(dirs_ok); + ASSERT_TRUE(cache_set); + ASSERT_TRUE(config_ready); + ASSERT_TRUE(canonical); + ASSERT_TRUE(scout_initialized); + ASSERT_TRUE(hook_initialized); + ASSERT_TRUE(restricted_started_nothing); + ASSERT_TRUE(first_initialized); + ASSERT_TRUE(first_owned); + ASSERT_TRUE(second_initialized); + ASSERT_TRUE(coalesced); + ASSERT_TRUE(restricted_disconnect_kept_job); + ASSERT_TRUE(one_owner_left); + ASSERT_TRUE(final_cancelled); + ASSERT_TRUE(final_reaped); + ASSERT_EQ(atomic_load(&update.starts), 1); + ASSERT_EQ(atomic_load(&update.cancels), 1); + ASSERT_EQ(atomic_load(&update.destroys), 1); + ASSERT_TRUE(stopped); + ASSERT_TRUE(cache_restored); + PASS(); +} + +TEST(daemon_application_auto_index_honors_tracked_file_limit) { + app_env_backup_t cache_environment; + bool cache_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR"); + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + char tracked[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), "%s/cbm-app-auto-limit-root-XXXXXX", cbm_tmpdir()); + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-auto-limit-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; + bool cache_set = dirs_ok && cache_saved && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + (void)snprintf(tracked, sizeof(tracked), "%s/tracked.c", root); + bool git_ready = dirs_ok && app_test_create_empty_file(tracked) && + app_test_git(root, "init", "-q", NULL) == 0 && + app_test_git(root, "add", "--", "tracked.c") == 0; + cbm_config_t *stored_config = cache_set ? cbm_config_open(cache) : NULL; + bool config_ready = stored_config && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX, "true") == 0 && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX_LIMIT, "0") == 0 && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_WATCH, "false") == 0; + + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + app_fake_update_context_t update; + app_fake_update_context_init(&update, true); + cbm_daemon_application_update_ops_t update_ops = app_fake_update_ops(&update); + cbm_daemon_application_config_t config = { + .config = stored_config, + .worker_ops = &worker_ops, + .update_ops = &update_ops, + }; + cbm_daemon_application_t *application = + config_ready && git_ready ? cbm_daemon_application_new(&config) : NULL; + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = + application ? app_test_open(&callbacks, 4190) : NULL; + bool initialized = app_test_initialize_profile(&callbacks, session, root, + CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + cbm_usleep(50000); + bool limit_prevented_admission = initialized && atomic_load(&fake.starts) == 0 && + cbm_daemon_application_active_jobs(application) == 0; + + if (session) { + callbacks.session_cancel(callbacks.context, session); + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + cbm_config_close(stored_config); + (void)th_rmtree(root); + (void)th_rmtree(cache); + bool cache_restored = app_env_backup_restore(&cache_environment); + + ASSERT_TRUE(cache_saved); + ASSERT_TRUE(dirs_ok); + ASSERT_TRUE(cache_set); + ASSERT_TRUE(git_ready); + ASSERT_TRUE(config_ready); + ASSERT_TRUE(initialized); + ASSERT_TRUE(limit_prevented_admission); + ASSERT_TRUE(stopped); + ASSERT_TRUE(cache_restored); + PASS(); +} + +/* Native discovery never interprets a repository path as command text. Valid + * non-Git paths containing shell metacharacters remain countable literally. */ +TEST(daemon_application_auto_index_file_count_handles_literal_metacharacter_path) { + char original[APP_TEST_PATH_CAP]; + char literal[APP_TEST_PATH_CAP]; + char tracked[APP_TEST_PATH_CAP]; + (void)snprintf(original, sizeof(original), "%s/cbm-app-auto-count-XXXXXX", cbm_tmpdir()); + bool root_ready = cbm_mkdtemp(original) != NULL; + (void)snprintf(tracked, sizeof(tracked), "%s/tracked.c", original); + bool source_ready = root_ready && app_test_create_empty_file(tracked); + int literal_written = snprintf(literal, sizeof(literal), "%s&literal", original); + bool renamed = source_ready && literal_written > 0 && + (size_t)literal_written < sizeof(literal) && rename(original, literal) == 0; + int file_count = -1; + bool within_limit = renamed && cbm_mcp_auto_index_within_file_limit(literal, 1, &file_count); + + (void)th_rmtree(renamed ? literal : original); + + ASSERT_TRUE(root_ready); + ASSERT_TRUE(source_ready); + ASSERT_TRUE(renamed); + ASSERT_TRUE(within_limit); + ASSERT_EQ(file_count, 1); + PASS(); +} + +/* Auto-index is documented for every project, not only Git worktrees. The + * native count must preserve that behavior while enforcing the same limit. */ +TEST(daemon_application_auto_index_file_count_supports_non_git_roots) { + char root[APP_TEST_PATH_CAP]; + char source[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), "%s/cbm-app-auto-count-non-git-XXXXXX", cbm_tmpdir()); + bool root_ready = cbm_mkdtemp(root) != NULL; + (void)snprintf(source, sizeof(source), "%s/source.c", root); + bool source_ready = root_ready && app_test_create_empty_file(source); + int rejected_count = -1; + bool rejected = source_ready && cbm_mcp_auto_index_within_file_limit(root, 0, &rejected_count); + int admitted_count = -1; + bool admitted = source_ready && cbm_mcp_auto_index_within_file_limit(root, 1, &admitted_count); + + (void)th_rmtree(root); + + ASSERT_TRUE(root_ready); + ASSERT_TRUE(source_ready); + ASSERT_FALSE(rejected); + ASSERT_EQ(rejected_count, 1); + ASSERT_TRUE(admitted); + ASSERT_EQ(admitted_count, 1); + PASS(); +} + +TEST(daemon_application_auto_index_retries_transient_busy_admission) { + app_env_backup_t cache_environment; + bool cache_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR"); + char occupied_root[APP_TEST_PATH_CAP]; + char auto_root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + (void)snprintf(occupied_root, sizeof(occupied_root), "%s/cbm-app-auto-busy-first-XXXXXX", + cbm_tmpdir()); + (void)snprintf(auto_root, sizeof(auto_root), "%s/cbm-app-auto-busy-second-XXXXXX", + cbm_tmpdir()); + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-auto-busy-cache-XXXXXX", cbm_tmpdir()); + bool dirs_ok = cbm_mkdtemp(occupied_root) != NULL && cbm_mkdtemp(auto_root) != NULL && + cbm_mkdtemp(cache) != NULL; + bool cache_set = dirs_ok && cache_saved && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + cbm_config_t *stored_config = cache_set ? cbm_config_open(cache) : NULL; + bool config_ready = stored_config && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX, "true") == 0 && + cbm_config_set(stored_config, CBM_CONFIG_AUTO_WATCH, "false") == 0; + + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + app_fake_update_context_t update; + app_fake_update_context_init(&update, true); + cbm_daemon_application_update_ops_t update_ops = app_fake_update_ops(&update); + cbm_daemon_application_config_t config = { + .config = stored_config, + .worker_ops = &worker_ops, + .update_ops = &update_ops, + .physical_job_limit = 1, + }; + cbm_daemon_application_t *application = + config_ready ? cbm_daemon_application_new(&config) : NULL; + app_index_thread_t occupied = { + .application = application, + .project = "auto-busy-first", + .root = occupied_root, + .result = -1, + }; + cbm_thread_t occupied_thread; + bool occupied_started = + application && dirs_ok && + cbm_thread_create(&occupied_thread, 0, app_index_thread, &occupied) == 0; + bool occupied_admitted = occupied_started && app_wait_for_atomic_int(&fake.starts, 1); + + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = + application ? app_test_open(&callbacks, 4191) : NULL; + bool initialized = + occupied_admitted && app_test_initialize_profile(&callbacks, session, auto_root, + CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + cbm_usleep(20000); + bool initially_deferred = initialized && atomic_load(&fake.starts) == 1; + + atomic_store(&fake.allow_completion, true); + if (occupied_started) { + (void)cbm_thread_join(&occupied_thread); + } + bool retried_without_request = + occupied.result == 0 && app_wait_for_atomic_int(&fake.starts, 2) && + app_wait_for_atomic_int(&fake.destroys, 2) && app_wait_for_active_jobs(application, 0); + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t retry_request = + initialized + ? app_test_list_projects(&callbacks, session, 41910, &response, &response_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + free(response); + bool retry_admitted = + retried_without_request && retry_request == CBM_DAEMON_RUNTIME_APPLICATION_OK; + + if (session) { + callbacks.session_cancel(callbacks.context, session); + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + cbm_config_close(stored_config); + (void)th_rmtree(occupied_root); + (void)th_rmtree(auto_root); + (void)th_rmtree(cache); + bool cache_restored = app_env_backup_restore(&cache_environment); + + ASSERT_TRUE(cache_saved); + ASSERT_TRUE(dirs_ok); + ASSERT_TRUE(cache_set); + ASSERT_TRUE(config_ready); + ASSERT_TRUE(occupied_started); + ASSERT_TRUE(occupied_admitted); + ASSERT_TRUE(initialized); + ASSERT_TRUE(initially_deferred); + ASSERT_EQ(occupied.result, 0); + ASSERT_TRUE(retried_without_request); + ASSERT_TRUE(retry_admitted); + ASSERT_TRUE(stopped); + ASSERT_TRUE(cache_restored); + PASS(); +} + +/* Regression contract: the daemon owns one update generation, not one update thread + * per MCP server. Its completed result is replayed exactly once to every + * eligible full session, including a session initialized after completion. */ +TEST(daemon_application_update_generation_notifies_initial_and_late_sessions_once) { + app_fake_update_context_t update; + app_fake_update_context_init(&update, true); + cbm_daemon_application_update_ops_t update_ops = app_fake_update_ops(&update); + cbm_daemon_application_config_t config = {.update_ops = &update_ops}; + char root[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), "%s/cbm-app-update-root-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + cbm_daemon_application_t *application = root_ok ? cbm_daemon_application_new(&config) : NULL; + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *initial = + application ? app_test_open(&callbacks, 4211) : NULL; + bool initial_initialized = app_test_initialize_profile(&callbacks, initial, root, + CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + + uint64_t request_id = 42000; + uint8_t *initial_notice = NULL; + bool initial_notified = + initial_initialized && + app_wait_for_update_notice(&callbacks, initial, &request_id, &initial_notice); + uint8_t *initial_second = NULL; + uint32_t initial_second_length = 0; + cbm_daemon_runtime_application_status_t initial_second_status = + initial_notified ? app_test_list_projects(&callbacks, initial, request_id++, + &initial_second, &initial_second_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + bool initial_once = initial_second_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + initial_second && !strstr((char *)initial_second, "Update available:"); + + cbm_daemon_runtime_application_session_t *late = + application ? app_test_open(&callbacks, 4212) : NULL; + bool late_initialized = + initial_notified && + app_test_initialize_profile(&callbacks, late, root, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + uint8_t *late_notice = NULL; + bool late_notified = + late_initialized && app_wait_for_update_notice(&callbacks, late, &request_id, &late_notice); + uint8_t *late_second = NULL; + uint32_t late_second_length = 0; + cbm_daemon_runtime_application_status_t late_second_status = + late_notified ? app_test_list_projects(&callbacks, late, request_id++, &late_second, + &late_second_length) + : CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + bool late_once = late_second_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && late_second && + !strstr((char *)late_second, "Update available:"); + int generation_starts = atomic_load(&update.starts); + bool generation_completed = initial_notified && app_wait_for_atomic_int(&update.destroys, 1); + + if (initial) { + callbacks.session_cancel(callbacks.context, initial); + callbacks.session_close(callbacks.context, initial); + } + if (late) { + callbacks.session_cancel(callbacks.context, late); + callbacks.session_close(callbacks.context, late); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + free(initial_notice); + free(initial_second); + free(late_notice); + free(late_second); + (void)th_rmtree(root); + + ASSERT_TRUE(root_ok); + ASSERT_TRUE(initial_initialized); + ASSERT_TRUE(initial_notified); + ASSERT_TRUE(initial_once); + ASSERT_TRUE(late_initialized); + ASSERT_TRUE(late_notified); + ASSERT_TRUE(late_once); + ASSERT_EQ(generation_starts, 1); + ASSERT_TRUE(generation_completed); + ASSERT_TRUE(stopped); + ASSERT_EQ(atomic_load(&update.cancels), 0); + ASSERT_EQ(atomic_load(&update.destroys), 1); + PASS(); +} + +TEST(daemon_application_update_generation_retries_worker_start_failure) { + app_fake_update_context_t update; + app_fake_update_context_init(&update, true); + atomic_store(&update.start_failures_remaining, 1); + cbm_daemon_application_update_ops_t update_ops = app_fake_update_ops(&update); + cbm_daemon_application_config_t config = {.update_ops = &update_ops}; + char root[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), "%s/cbm-app-update-retry-start-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + cbm_daemon_application_t *application = root_ok ? cbm_daemon_application_new(&config) : NULL; + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = + application ? app_test_open(&callbacks, 4261) : NULL; + bool initialized = app_test_initialize_profile(&callbacks, session, root, + CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + bool failed_generation_started = initialized && app_wait_for_atomic_int(&update.starts, 1); + uint64_t request_id = 42610; + uint8_t *notice = NULL; + bool retry_notified = failed_generation_started && + app_wait_for_update_notice(&callbacks, session, &request_id, ¬ice); + + if (session) { + callbacks.session_cancel(callbacks.context, session); + callbacks.session_close(callbacks.context, session); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + free(notice); + (void)th_rmtree(root); + + ASSERT_TRUE(root_ok); + ASSERT_TRUE(initialized); + ASSERT_TRUE(failed_generation_started); + ASSERT_TRUE(retry_notified); + ASSERT_EQ(atomic_load(&update.starts), 2); + ASSERT_EQ(atomic_load(&update.cancels), 0); + ASSERT_EQ(atomic_load(&update.destroys), 1); + ASSERT_TRUE(stopped); + PASS(); } -static bool app_watch_race_fixture_finish(app_watch_race_fixture_t *fixture) { - if (fixture->session) { - fixture->callbacks.session_close(fixture->callbacks.context, - fixture->session); - fixture->session = NULL; +TEST(daemon_application_update_generation_retries_cancelled_check) { + app_fake_update_context_t update; + app_fake_update_context_init(&update, false); + cbm_daemon_application_update_ops_t update_ops = app_fake_update_ops(&update); + cbm_daemon_application_config_t config = {.update_ops = &update_ops}; + char root[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), "%s/cbm-app-update-retry-cancel-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + cbm_daemon_application_t *application = root_ok ? cbm_daemon_application_new(&config) : NULL; + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *first = + application ? app_test_open(&callbacks, 4271) : NULL; + cbm_daemon_runtime_application_session_t *scout = + application ? app_test_open(&callbacks, 4272) : NULL; + bool scout_initialized = app_test_initialize_profile(&callbacks, scout, root, + CBM_MCP_TOOL_PROFILE_SCOUT, NULL, NULL); + bool first_initialized = + scout_initialized && + app_test_initialize_profile(&callbacks, first, root, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + bool first_started = first_initialized && app_wait_for_atomic_int(&update.starts, 1); + if (first) { + callbacks.session_cancel(callbacks.context, first); + callbacks.session_close(callbacks.context, first); + first = NULL; } - bool stopped = !fixture->application || - cbm_daemon_application_shutdown(fixture->application, - APP_TEST_TIMEOUT_MS); - if (stopped) { - cbm_daemon_application_free(fixture->application); - fixture->application = NULL; - cbm_watcher_stop(fixture->watcher); - cbm_watcher_free(fixture->watcher); - fixture->watcher = NULL; - cbm_store_close(fixture->store); - fixture->store = NULL; + bool first_cancelled = + first_started && atomic_load(&update.cancels) == 1 && atomic_load(&update.destroys) == 1; + + atomic_store(&update.allow_completion, true); + cbm_daemon_runtime_application_session_t *retry = + application ? app_test_open(&callbacks, 4273) : NULL; + bool retry_initialized = + first_cancelled && + app_test_initialize_profile(&callbacks, retry, root, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + uint64_t request_id = 42710; + uint8_t *notice = NULL; + bool retry_notified = + retry_initialized && app_wait_for_update_notice(&callbacks, retry, &request_id, ¬ice); + + if (retry) { + callbacks.session_cancel(callbacks.context, retry); + callbacks.session_close(callbacks.context, retry); } - free(fixture->project); - fixture->project = NULL; - (void)th_rmtree(fixture->root); - (void)th_rmtree(fixture->cache); - bool restored = fixture->saved_cache - ? cbm_setenv("CBM_CACHE_DIR", fixture->saved_cache, 1) == 0 - : (!fixture->had_cache && - cbm_unsetenv("CBM_CACHE_DIR") == 0); - free(fixture->saved_cache); - fixture->saved_cache = NULL; - return stopped && restored; + if (scout) { + callbacks.session_cancel(callbacks.context, scout); + callbacks.session_close(callbacks.context, scout); + } + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + free(notice); + (void)th_rmtree(root); + + ASSERT_TRUE(root_ok); + ASSERT_TRUE(scout_initialized); + ASSERT_TRUE(first_initialized); + ASSERT_TRUE(first_started); + ASSERT_TRUE(first_cancelled); + ASSERT_TRUE(retry_initialized); + ASSERT_TRUE(retry_notified); + ASSERT_EQ(atomic_load(&update.starts), 2); + ASSERT_EQ(atomic_load(&update.cancels), 1); + ASSERT_EQ(atomic_load(&update.destroys), 2); + ASSERT_TRUE(stopped); + PASS(); } typedef struct { + cbm_daemon_runtime_application_callbacks_t callbacks; + cbm_daemon_runtime_application_session_t *session; cbm_daemon_application_t *application; - const char *project; - const char *root; - bool pause_before_subscribe; - atomic_bool ready; - atomic_bool proceed; + bool shutdown_ok; atomic_bool done; - int result; -} app_watcher_index_thread_t; +} app_final_disconnect_thread_t; + +static void *app_final_disconnect_thread(void *opaque) { + app_final_disconnect_thread_t *disconnect = opaque; + disconnect->callbacks.session_cancel(disconnect->callbacks.context, disconnect->session); + disconnect->callbacks.session_close(disconnect->callbacks.context, disconnect->session); + disconnect->shutdown_ok = + cbm_daemon_application_shutdown(disconnect->application, APP_TEST_TIMEOUT_MS); + atomic_store(&disconnect->done, true); + return NULL; +} -static void *app_watcher_index_thread(void *opaque) { - app_watcher_index_thread_t *request = opaque; - if (request->pause_before_subscribe) { - atomic_store(&request->ready, true); - while (!atomic_load(&request->proceed)) { - cbm_usleep(1000); - } +/* Regression contract: the last eligible disconnect is also the update-generation + * cancellation and join boundary. */ +TEST(daemon_application_final_disconnect_cancels_and_joins_update_generation) { + app_fake_update_context_t update; + app_fake_update_context_init(&update, false); + cbm_daemon_application_update_ops_t update_ops = app_fake_update_ops(&update); + cbm_daemon_application_config_t config = {.update_ops = &update_ops}; + char root[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), "%s/cbm-app-update-cancel-root-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + cbm_daemon_application_t *application = root_ok ? cbm_daemon_application_new(&config) : NULL; + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_runtime_application_session_t *session = + application ? app_test_open(&callbacks, 4311) : NULL; + bool initialized = app_test_initialize_profile(&callbacks, session, root, + CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL); + bool generation_started = initialized && app_wait_for_atomic_int(&update.starts, 1); + + app_final_disconnect_thread_t disconnect = { + .callbacks = callbacks, + .session = session, + .application = application, + .shutdown_ok = false, + }; + atomic_init(&disconnect.done, false); + cbm_thread_t disconnect_thread; + bool disconnect_started = + generation_started && + cbm_thread_create(&disconnect_thread, 0, app_final_disconnect_thread, &disconnect) == 0; + bool joined_before_return = + disconnect_started && app_wait_for_atomic_bool(&disconnect.done, true); + if (disconnect_started) { + (void)cbm_thread_join(&disconnect_thread); + session = NULL; + } else if (session) { + callbacks.session_cancel(callbacks.context, session); + callbacks.session_close(callbacks.context, session); + session = NULL; + disconnect.shutdown_ok = cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); } - request->result = cbm_daemon_application_watcher_index( - request->project, request->root, request->application); - atomic_store(&request->done, true); - return NULL; + cbm_daemon_application_free(application); + (void)th_rmtree(root); + + ASSERT_TRUE(root_ok); + ASSERT_TRUE(initialized); + ASSERT_TRUE(generation_started); + ASSERT_TRUE(disconnect_started); + ASSERT_TRUE(joined_before_return); + ASSERT_EQ(atomic_load(&update.cancels), 1); + ASSERT_EQ(atomic_load(&update.destroys), 1); + ASSERT_TRUE(disconnect.shutdown_ok); + PASS(); } -TEST(daemon_application_coalesces_identical_index_requests) { +TEST(daemon_application_coalesces_semantically_identical_index_requests) { app_fake_worker_context_t fake; app_fake_worker_context_init(&fake); cbm_daemon_application_worker_ops_t worker_ops = { @@ -1688,13 +2430,20 @@ TEST(daemon_application_coalesces_identical_index_requests) { char *project = root_ok ? cbm_project_name_from_path(root) : NULL; uint8_t *context = NULL; uint32_t context_length = 0; - char args[APP_TEST_PATH_CAP + 32]; - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", root); - uint8_t *tool = NULL; - uint32_t tool_length = 0; - bool encoded = root_ok && project && - app_test_context_request(root, root, &context, &context_length) && - app_test_tool_request("index_repository", args, &tool, &tool_length); + char first_args[APP_TEST_PATH_CAP + 96]; + char second_args[APP_TEST_PATH_CAP + 96]; + snprintf(first_args, sizeof(first_args), + "{\"repo_path\":\"%s\",\"mode\":\"full\"," + "\"persistence\":false}", + root); + snprintf(second_args, sizeof(second_args), + "{ \"persistence\" : false, \"repo_path\" : \"%s\" }", root); + uint8_t *tools[2] = {NULL, NULL}; + uint32_t tool_lengths[2] = {0, 0}; + bool encoded = + root_ok && project && app_test_context_request(root, root, &context, &context_length) && + app_test_tool_request("index_repository", first_args, &tools[0], &tool_lengths[0]) && + app_test_tool_request("index_repository", second_args, &tools[1], &tool_lengths[1]); bool contexts_ok = encoded; uint8_t *empty = NULL; uint32_t empty_length = 0; @@ -1707,12 +2456,12 @@ TEST(daemon_application_coalesces_identical_index_requests) { app_request_thread_t requests[2] = { {.callbacks = callbacks, .session = sessions[0], - .request = tool, - .request_length = tool_length}, + .request = tools[0], + .request_length = tool_lengths[0]}, {.callbacks = callbacks, .session = sessions[1], - .request = tool, - .request_length = tool_length}, + .request = tools[1], + .request_length = tool_lengths[1]}, }; cbm_thread_t threads[2]; bool thread_started[2] = {false, false}; @@ -1752,7 +2501,8 @@ TEST(daemon_application_coalesces_identical_index_requests) { free(requests[0].response); free(requests[1].response); free(context); - free(tool); + free(tools[0]); + free(tools[1]); free(project); (void)cbm_rmdir(root); PASS(); @@ -1827,8 +2577,7 @@ TEST(daemon_application_fresh_request_does_not_reuse_terminal_subscribed_job) { }; atomic_init(&prior[i].done, false); started[i] = cbm_thread_create(&threads[i], 0, app_request_thread, &prior[i]) == 0; - all_subscribed = started[i] && - app_wait_for_subscribers(application, project, i + 1U); + all_subscribed = started[i] && app_wait_for_subscribers(application, project, i + 1U); if (all_subscribed) { cbm_usleep(1000); } @@ -1837,16 +2586,15 @@ TEST(daemon_application_fresh_request_does_not_reuse_terminal_subscribed_job) { all_subscribed && app_wait_for_atomic_int(&fake.destroys, 1); atomic_store(&fake.release_destroy, true); bool terminal_with_prior_subscribers = - first_worker_ready_to_publish && app_wait_for_terminal_job_with_subscribers( - application, project, PRIOR_SUBSCRIBERS / 2U); + first_worker_ready_to_publish && + app_wait_for_terminal_job_with_subscribers(application, project, PRIOR_SUBSCRIBERS / 2U); uint8_t *fresh = NULL; uint32_t fresh_length = 0; cbm_daemon_runtime_application_status_t fresh_status = - terminal_with_prior_subscribers - ? app_test_request(&callbacks, sessions[PRIOR_SUBSCRIBERS], tool, tool_length, &fresh, - &fresh_length) - : CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; + terminal_with_prior_subscribers ? app_test_request(&callbacks, sessions[PRIOR_SUBSCRIBERS], + tool, tool_length, &fresh, &fresh_length) + : CBM_DAEMON_RUNTIME_APPLICATION_REJECTED; for (size_t i = 0; i < PRIOR_SUBSCRIBERS; i++) { if (started[i]) { (void)cbm_thread_join(&threads[i]); @@ -1857,8 +2605,7 @@ TEST(daemon_application_fresh_request_does_not_reuse_terminal_subscribed_job) { callbacks.session_close(callbacks.context, sessions[i]); } } - bool stopped = - application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); ASSERT_TRUE(setup); @@ -1898,15 +2645,13 @@ TEST(daemon_application_request_cancel_detaches_only_one_coalesced_subscriber) { .destroy = app_fake_worker_destroy, }; cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; - cbm_daemon_application_t *application = - cbm_daemon_application_new(&config); + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); cbm_daemon_runtime_application_callbacks_t callbacks = cbm_daemon_application_runtime_callbacks(application); - cbm_daemon_runtime_application_session_t *sessions[2] = { - app_test_open(&callbacks, 3011), app_test_open(&callbacks, 3012)}; + cbm_daemon_runtime_application_session_t *sessions[2] = {app_test_open(&callbacks, 3011), + app_test_open(&callbacks, 3012)}; char root[APP_TEST_PATH_CAP]; - (void)snprintf(root, sizeof(root), - "%s/cbm-app-request-cancel-coalesce-XXXXXX", cbm_tmpdir()); + (void)snprintf(root, sizeof(root), "%s/cbm-app-request-cancel-coalesce-XXXXXX", cbm_tmpdir()); bool root_ok = cbm_mkdtemp(root) != NULL; char *project = root_ok ? cbm_project_name_from_path(root) : NULL; uint8_t *context = NULL; @@ -1915,18 +2660,14 @@ TEST(daemon_application_request_cancel_detaches_only_one_coalesced_subscriber) { (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", root); uint8_t *tool = NULL; uint32_t tool_length = 0; - bool setup = application && sessions[0] && sessions[1] && root_ok && - project && - app_test_context_request(root, root, &context, - &context_length) && - app_test_tool_request("index_repository", args, &tool, - &tool_length); + bool setup = application && sessions[0] && sessions[1] && root_ok && project && + app_test_context_request(root, root, &context, &context_length) && + app_test_tool_request("index_repository", args, &tool, &tool_length); uint8_t *empty = NULL; uint32_t empty_length = 0; for (size_t i = 0; setup && i < 2; i++) { - setup = app_test_request(&callbacks, sessions[i], context, - context_length, &empty, &empty_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK; + setup = app_test_request(&callbacks, sessions[i], context, context_length, &empty, + &empty_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; free(empty); empty = NULL; empty_length = 0; @@ -1949,27 +2690,18 @@ TEST(daemon_application_request_cancel_detaches_only_one_coalesced_subscriber) { cbm_thread_t threads[2]; bool started[2] = {false, false}; if (setup) { - started[0] = - cbm_thread_create(&threads[0], 0, app_request_thread, - &requests[0]) == 0; + started[0] = cbm_thread_create(&threads[0], 0, app_request_thread, &requests[0]) == 0; } - bool first_joined = started[0] && - app_wait_for_subscribers(application, project, 1); + bool first_joined = started[0] && app_wait_for_subscribers(application, project, 1); if (first_joined) { - started[1] = - cbm_thread_create(&threads[1], 0, app_request_thread, - &requests[1]) == 0; + started[1] = cbm_thread_create(&threads[1], 0, app_request_thread, &requests[1]) == 0; } - bool both_joined = started[1] && - app_wait_for_subscribers(application, project, 2); + bool both_joined = started[1] && app_wait_for_subscribers(application, project, 2); if (both_joined) { - callbacks.request_cancel(callbacks.context, sessions[0], - requests[0].request_token); + callbacks.request_cancel(callbacks.context, sessions[0], requests[0].request_token); } - bool one_left = both_joined && - app_wait_for_subscribers(application, project, 1); - bool cancelled_returned = - one_left && app_wait_for_atomic_bool(&requests[0].done, true); + bool one_left = both_joined && app_wait_for_subscribers(application, project, 1); + bool cancelled_returned = one_left && app_wait_for_atomic_bool(&requests[0].done, true); int cancels_after_first = atomic_load(&fake.cancels); /* Always release the fake worker so a failed RED assertion cannot strand @@ -1986,12 +2718,10 @@ TEST(daemon_application_request_cancel_detaches_only_one_coalesced_subscriber) { callbacks.session_close(callbacks.context, sessions[i]); } } - bool stopped = application && cbm_daemon_application_shutdown( - application, APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); bool second_indexed = - requests[1].response && - strstr((char *)requests[1].response, "indexed") != NULL; + requests[1].response && strstr((char *)requests[1].response, "indexed") != NULL; int final_cancels = atomic_load(&fake.cancels); int final_starts = atomic_load(&fake.starts); int final_destroys = atomic_load(&fake.destroys); @@ -2131,10 +2861,8 @@ TEST(daemon_application_disconnect_before_request_callback_is_sticky) { cbm_daemon_application_t *application = cbm_daemon_application_new(&config); cbm_daemon_runtime_application_callbacks_t callbacks = cbm_daemon_application_runtime_callbacks(application); - cbm_daemon_runtime_application_session_t *cancelled_session = - app_test_open(&callbacks, 37); - cbm_daemon_runtime_application_session_t *healthy_session = - app_test_open(&callbacks, 38); + cbm_daemon_runtime_application_session_t *cancelled_session = app_test_open(&callbacks, 37); + cbm_daemon_runtime_application_session_t *healthy_session = app_test_open(&callbacks, 38); char root[APP_TEST_PATH_CAP]; snprintf(root, sizeof(root), "%s/cbm-app-pre-callback-cancel-XXXXXX", cbm_tmpdir()); bool root_ok = cbm_mkdtemp(root) != NULL; @@ -2185,8 +2913,7 @@ TEST(daemon_application_disconnect_before_request_callback_is_sticky) { if (healthy_session) { callbacks.session_close(callbacks.context, healthy_session); } - bool stopped = application && - cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); ASSERT_TRUE(setup); @@ -2213,20 +2940,16 @@ TEST(daemon_application_request_cancel_preserves_persistent_watch_and_session) { app_watch_race_fixture_t fixture; bool fixture_ready = app_watch_race_fixture_init(&fixture, 3021); char args[APP_TEST_PATH_CAP + 32]; - (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", - fixture.root); + (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", fixture.root); uint8_t *tool = NULL; uint32_t tool_length = 0; uint8_t *ping = NULL; uint32_t ping_length = 0; - bool encoded = - fixture_ready && - app_test_tool_request("index_repository", args, &tool, - &tool_length) && - app_test_text_request( - CBM_DAEMON_APPLICATION_REQUEST_MCP, - "{\"jsonrpc\":\"2.0\",\"id\":3021,\"method\":\"ping\"}", - &ping, &ping_length); + bool encoded = fixture_ready && + app_test_tool_request("index_repository", args, &tool, &tool_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":3021,\"method\":\"ping\"}", + &ping, &ping_length); app_request_thread_t request = { .callbacks = fixture.callbacks, .session = fixture.session, @@ -2238,43 +2961,34 @@ TEST(daemon_application_request_cancel_preserves_persistent_watch_and_session) { atomic_init(&request.done, false); cbm_thread_t request_thread; bool thread_started = - encoded && cbm_thread_create(&request_thread, 0, app_request_thread, - &request) == 0; + encoded && cbm_thread_create(&request_thread, 0, app_request_thread, &request) == 0; bool subscribed = - thread_started && - app_wait_for_subscribers(fixture.application, fixture.project, 1); + thread_started && app_wait_for_subscribers(fixture.application, fixture.project, 1); if (subscribed) { - fixture.callbacks.request_cancel( - fixture.callbacks.context, fixture.session, - request.request_token); + fixture.callbacks.request_cancel(fixture.callbacks.context, fixture.session, + request.request_token); } - bool request_returned = - subscribed && app_wait_for_atomic_bool(&request.done, true); + bool request_returned = subscribed && app_wait_for_atomic_bool(&request.done, true); if (!request_returned) { /* Keep cleanup bounded if the RED path fails to detach the request. */ atomic_store(&fixture.fake.allow_completion, true); } - bool thread_joined = - thread_started && cbm_thread_join(&request_thread) == 0; - int watch_count_after_cancel = - fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool thread_joined = thread_started && cbm_thread_join(&request_thread) == 0; + int watch_count_after_cancel = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; uint8_t *ping_response = NULL; uint32_t ping_response_length = 0; cbm_daemon_runtime_application_status_t ping_status = CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; if (thread_joined && encoded) { - ping_status = app_test_request_tagged( - &fixture.callbacks, fixture.session, UINT64_C(8202), ping, - ping_length, &ping_response, &ping_response_length); - } - int watch_count_after_ping = - fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; - bool worker_cancelled = - app_wait_for_atomic_int(&fixture.fake.cancels, 1); + ping_status = + app_test_request_tagged(&fixture.callbacks, fixture.session, UINT64_C(8202), ping, + ping_length, &ping_response, &ping_response_length); + } + int watch_count_after_ping = fixture.watcher ? cbm_watcher_watch_count(fixture.watcher) : -1; + bool worker_cancelled = app_wait_for_atomic_int(&fixture.fake.cancels, 1); bool cleaned = app_watch_race_fixture_finish(&fixture); - bool ping_matches = - ping_response && strstr((char *)ping_response, "\"id\":3021") != NULL; + bool ping_matches = ping_response && strstr((char *)ping_response, "\"id\":3021") != NULL; int worker_starts = atomic_load(&fixture.fake.starts); int worker_destroys = atomic_load(&fixture.fake.destroys); free(request.response); @@ -2310,10 +3024,8 @@ TEST(daemon_application_cancel_drops_watch_before_inflight_request_returns) { bool saved_cache_ok = !had_cache || saved_cache; char root[APP_TEST_PATH_CAP]; char cache[APP_TEST_PATH_CAP]; - (void)snprintf(root, sizeof(root), "%s/cbm-app-cancel-watch-root-XXXXXX", - cbm_tmpdir()); - (void)snprintf(cache, sizeof(cache), "%s/cbm-app-cancel-watch-cache-XXXXXX", - cbm_tmpdir()); + (void)snprintf(root, sizeof(root), "%s/cbm-app-cancel-watch-root-XXXXXX", cbm_tmpdir()); + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-cancel-watch-cache-XXXXXX", cbm_tmpdir()); bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; bool env_ok = saved_cache_ok && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; char *project = dirs_ok ? cbm_project_name_from_path(root) : NULL; @@ -2356,24 +3068,20 @@ TEST(daemon_application_cancel_drops_watch_before_inflight_request_returns) { uint32_t response_length = 0; bool setup = env_ok && db_ok && store && watcher && application && session && app_test_context_request(root, root, &context, &context_length) && - app_test_text_request( - CBM_DAEMON_APPLICATION_REQUEST_MCP, - "{\"jsonrpc\":\"2.0\",\"id\":17,\"method\":\"ping\"}", - &ping, &ping_length) && - app_test_tool_request("index_repository", args, &tool, - &tool_length); + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":17,\"method\":\"ping\"}", &ping, + &ping_length) && + app_test_tool_request("index_repository", args, &tool, &tool_length); if (setup) { - setup = app_test_request(&callbacks, session, context, context_length, - &response, &response_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK; + setup = app_test_request(&callbacks, session, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; free(response); response = NULL; response_length = 0; } if (setup) { - setup = app_test_request(&callbacks, session, ping, ping_length, - &response, &response_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK; + setup = app_test_request(&callbacks, session, ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; free(response); response = NULL; response_length = 0; @@ -2389,11 +3097,9 @@ TEST(daemon_application_cancel_drops_watch_before_inflight_request_returns) { }; atomic_init(&request.done, false); cbm_thread_t request_thread; - bool thread_started = - watch_registered && - cbm_thread_create(&request_thread, 0, app_request_thread, &request) == 0; - bool worker_started = - thread_started && app_wait_for_atomic_int(&fake.starts, 1); + bool thread_started = watch_registered && + cbm_thread_create(&request_thread, 0, app_request_thread, &request) == 0; + bool worker_started = thread_started && app_wait_for_atomic_int(&fake.starts, 1); if (thread_started) { /* Runtime close is intentionally delayed until the in-flight request * returns; cancel is the disconnect ownership boundary. */ @@ -2402,16 +3108,13 @@ TEST(daemon_application_cancel_drops_watch_before_inflight_request_returns) { int watch_count_at_cancel = cbm_watcher_watch_count(watcher); bool thread_joined = thread_started && cbm_thread_join(&request_thread) == 0; int watch_count_after_request = cbm_watcher_watch_count(watcher); - bool response_cancelled = - request.status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && - request.response == NULL && request.response_length == 0; + bool response_cancelled = request.status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && + request.response == NULL && request.response_length == 0; if (session) { callbacks.session_close(callbacks.context, session); } - bool stopped = application && - cbm_daemon_application_shutdown(application, - APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); cbm_watcher_stop(watcher); cbm_watcher_free(watcher); @@ -2460,16 +3163,13 @@ TEST(daemon_application_stale_watcher_callback_is_rejected_at_job_admission) { atomic_init(&request.done, false); cbm_thread_t thread; bool thread_started = - fixture_ready && - cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0; - bool paused_before_subscribe = - thread_started && app_wait_for_atomic_bool(&request.ready, true); + fixture_ready && cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0; + bool paused_before_subscribe = thread_started && app_wait_for_atomic_bool(&request.ready, true); if (thread_started) { /* Models a watcher callback selected before unwatch but scheduled * after the last owning session crosses its cancel boundary. */ - fixture.callbacks.session_cancel(fixture.callbacks.context, - fixture.session); + fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session); } int watches_after_cancel = cbm_watcher_watch_count(fixture.watcher); atomic_store(&request.proceed, true); @@ -2477,12 +3177,10 @@ TEST(daemon_application_stale_watcher_callback_is_rejected_at_job_admission) { * finish instead of hanging the suite; the zero-start assertion remains * the reproduction oracle. */ atomic_store(&fixture.fake.allow_completion, true); - bool request_done = thread_started && - app_wait_for_atomic_bool(&request.done, true); + bool request_done = thread_started && app_wait_for_atomic_bool(&request.done, true); bool thread_joined = request_done && cbm_thread_join(&thread) == 0; int worker_starts = atomic_load(&fixture.fake.starts); - size_t active_jobs = - cbm_daemon_application_active_jobs(fixture.application); + size_t active_jobs = cbm_daemon_application_active_jobs(fixture.application); bool cleaned = app_watch_race_fixture_finish(&fixture); ASSERT_TRUE(fixture_ready); @@ -2512,17 +3210,13 @@ TEST(daemon_application_final_cancel_drains_admitted_watcher_job) { atomic_init(&request.done, false); cbm_thread_t thread; bool thread_started = - fixture_ready && - cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0; - bool worker_started = - thread_started && app_wait_for_atomic_int(&fixture.fake.starts, 1); - bool job_active = worker_started && - cbm_daemon_application_active_jobs( - fixture.application) == 1; + fixture_ready && cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0; + bool worker_started = thread_started && app_wait_for_atomic_int(&fixture.fake.starts, 1); + bool job_active = + worker_started && cbm_daemon_application_active_jobs(fixture.application) == 1; if (thread_started) { - fixture.callbacks.session_cancel(fixture.callbacks.context, - fixture.session); + fixture.callbacks.session_cancel(fixture.callbacks.context, fixture.session); } int watches_after_cancel = cbm_watcher_watch_count(fixture.watcher); bool cancelled_by_final_session = @@ -2532,11 +3226,9 @@ TEST(daemon_application_final_cancel_drains_admitted_watcher_job) { * while another daemon client could keep the process running. */ atomic_store(&fixture.fake.allow_completion, true); } - bool request_done = thread_started && - app_wait_for_atomic_bool(&request.done, true); + bool request_done = thread_started && app_wait_for_atomic_bool(&request.done, true); bool thread_joined = request_done && cbm_thread_join(&thread) == 0; - size_t active_jobs_after_join = - cbm_daemon_application_active_jobs(fixture.application); + size_t active_jobs_after_join = cbm_daemon_application_active_jobs(fixture.application); int worker_cancels = atomic_load(&fixture.fake.cancels); int worker_destroys = atomic_load(&fixture.fake.destroys); bool cleaned = app_watch_race_fixture_finish(&fixture); @@ -2573,27 +3265,22 @@ TEST(daemon_application_watcher_job_follows_exact_live_watch_owners) { uint32_t ping_length = 0; uint8_t *response = NULL; uint32_t response_length = 0; - bool encoded = fixture_ready && second && unrelated && - app_test_context_request(fixture.root, fixture.root, - &context, &context_length) && - app_test_text_request( - CBM_DAEMON_APPLICATION_REQUEST_MCP, - "{\"jsonrpc\":\"2.0\",\"id\":20,\"method\":\"ping\"}", - &ping, &ping_length); + bool encoded = + fixture_ready && second && unrelated && + app_test_context_request(fixture.root, fixture.root, &context, &context_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":20,\"method\":\"ping\"}", &ping, + &ping_length); bool second_watching = - encoded && - app_test_request(&fixture.callbacks, second, context, context_length, - &response, &response_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK; + encoded && app_test_request(&fixture.callbacks, second, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; free(response); response = NULL; response_length = 0; - second_watching = - second_watching && - app_test_request(&fixture.callbacks, second, ping, ping_length, - &response, &response_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK && - cbm_watcher_watch_count(fixture.watcher) == 1; + second_watching = second_watching && + app_test_request(&fixture.callbacks, second, ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK && + cbm_watcher_watch_count(fixture.watcher) == 1; free(response); response = NULL; @@ -2609,25 +3296,20 @@ TEST(daemon_application_watcher_job_follows_exact_live_watch_owners) { atomic_init(&request.done, false); cbm_thread_t thread; bool thread_started = - second_watching && - cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0; - bool worker_started = - thread_started && app_wait_for_atomic_int(&fixture.fake.starts, 1); + second_watching && cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0; + bool worker_started = thread_started && app_wait_for_atomic_int(&fixture.fake.starts, 1); size_t initial_subscribers = worker_started - ? cbm_daemon_application_job_subscribers(fixture.application, - fixture.project) + ? cbm_daemon_application_job_subscribers(fixture.application, fixture.project) : 0; if (fixture.session) { - fixture.callbacks.session_close(fixture.callbacks.context, - fixture.session); + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); fixture.session = NULL; } int watches_after_first = cbm_watcher_watch_count(fixture.watcher); size_t subscribers_after_first = - cbm_daemon_application_job_subscribers(fixture.application, - fixture.project); + cbm_daemon_application_job_subscribers(fixture.application, fixture.project); int cancels_after_first = atomic_load(&fixture.fake.cancels); if (second) { @@ -2636,8 +3318,7 @@ TEST(daemon_application_watcher_job_follows_exact_live_watch_owners) { } int watches_after_second = cbm_watcher_watch_count(fixture.watcher); size_t subscribers_after_second = - cbm_daemon_application_job_subscribers(fixture.application, - fixture.project); + cbm_daemon_application_job_subscribers(fixture.application, fixture.project); bool cancelled_without_final_session = thread_started && app_wait_for_atomic_int(&fixture.fake.cancels, 1); if (!cancelled_without_final_session) { @@ -2645,8 +3326,7 @@ TEST(daemon_application_watcher_job_follows_exact_live_watch_owners) { * alive here, so let it finish without leaking the test thread. */ atomic_store(&fixture.fake.allow_completion, true); } - bool request_done = - thread_started && app_wait_for_atomic_bool(&request.done, true); + bool request_done = thread_started && app_wait_for_atomic_bool(&request.done, true); bool thread_joined = request_done && cbm_thread_join(&thread) == 0; if (unrelated) { @@ -2676,6 +3356,100 @@ TEST(daemon_application_watcher_job_follows_exact_live_watch_owners) { PASS(); } +TEST(daemon_application_late_watcher_session_owns_active_watcher_job) { + app_watch_race_fixture_t fixture; + bool fixture_ready = app_watch_race_fixture_init(&fixture, 45); + cbm_daemon_runtime_application_session_t *unrelated = + fixture_ready ? app_test_open(&fixture.callbacks, 46) : NULL; + app_watcher_index_thread_t request = { + .application = fixture.application, + .project = fixture.project, + .root = fixture.root, + .pause_before_subscribe = false, + .result = -1, + }; + atomic_init(&request.ready, false); + atomic_init(&request.proceed, true); + atomic_init(&request.done, false); + cbm_thread_t thread; + bool thread_started = fixture_ready && unrelated && + cbm_thread_create(&thread, 0, app_watcher_index_thread, &request) == 0; + bool worker_started = thread_started && app_wait_for_atomic_int(&fixture.fake.starts, 1); + size_t initial_subscribers = + worker_started + ? cbm_daemon_application_job_subscribers(fixture.application, fixture.project) + : 0; + + cbm_daemon_runtime_application_session_t *late = + worker_started ? app_test_open(&fixture.callbacks, 47) : NULL; + uint8_t *context = NULL; + uint32_t context_length = 0; + uint8_t *ping = NULL; + uint32_t ping_length = 0; + uint8_t *response = NULL; + uint32_t response_length = 0; + bool encoded = + late && app_test_context_request(fixture.root, fixture.root, &context, &context_length) && + app_test_text_request(CBM_DAEMON_APPLICATION_REQUEST_MCP, + "{\"jsonrpc\":\"2.0\",\"id\":47,\"method\":\"ping\"}", &ping, + &ping_length); + bool late_watching = + encoded && app_test_request(&fixture.callbacks, late, context, context_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; + free(response); + response = NULL; + response_length = 0; + late_watching = late_watching && + app_test_request(&fixture.callbacks, late, ping, ping_length, &response, + &response_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK && + cbm_watcher_watch_count(fixture.watcher) == 1; + free(response); + response = NULL; + size_t subscribers_with_late = + late_watching ? cbm_daemon_application_job_subscribers(fixture.application, fixture.project) + : 0; + + if (fixture.session) { + fixture.callbacks.session_close(fixture.callbacks.context, fixture.session); + fixture.session = NULL; + } + size_t subscribers_after_initial = + cbm_daemon_application_job_subscribers(fixture.application, fixture.project); + int cancels_after_initial = atomic_load(&fixture.fake.cancels); + if (late) { + fixture.callbacks.session_close(fixture.callbacks.context, late); + late = NULL; + } + bool cancelled_after_late = app_wait_for_atomic_int(&fixture.fake.cancels, 1); + if (!cancelled_after_late) { + atomic_store(&fixture.fake.allow_completion, true); + } + bool request_done = app_wait_for_atomic_bool(&request.done, true); + bool thread_joined = request_done && cbm_thread_join(&thread) == 0; + if (unrelated) { + fixture.callbacks.session_close(fixture.callbacks.context, unrelated); + unrelated = NULL; + } + bool cleaned = app_watch_race_fixture_finish(&fixture); + free(context); + free(ping); + free(response); + + ASSERT_TRUE(fixture_ready); + ASSERT_TRUE(thread_started); + ASSERT_TRUE(worker_started); + ASSERT_EQ(initial_subscribers, 1); + ASSERT_TRUE(late_watching); + ASSERT_EQ(subscribers_with_late, 2); + ASSERT_EQ(subscribers_after_initial, 1); + ASSERT_EQ(cancels_after_initial, 0); + ASSERT_TRUE(cancelled_after_late); + ASSERT_TRUE(thread_joined); + ASSERT_EQ(request.result, 1); + ASSERT_TRUE(cleaned); + PASS(); +} + /* A staged index snapshots the current DB before it publishes a replacement. * Any ADR write accepted during that lifetime can therefore be silently lost * (and can make replacement fail on Windows). The daemon job must keep the @@ -2701,8 +3475,8 @@ TEST(daemon_application_serializes_adr_mutation_with_index_job) { snprintf(root, sizeof(root), "%s/cbm-app-mutation-root-XXXXXX", cbm_tmpdir()); snprintf(cache, sizeof(cache), "%s/cbm-app-mutation-cache-XXXXXX", cbm_tmpdir()); bool dirs_ok = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; - bool env_ok = dirs_ok && (!had_cache || saved_cache) && - cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + bool env_ok = + dirs_ok && (!had_cache || saved_cache) && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; char *project = env_ok ? cbm_project_name_from_path(root) : NULL; char db_path[APP_TEST_PATH_CAP] = {0}; cbm_store_t *seed = NULL; @@ -2714,8 +3488,7 @@ TEST(daemon_application_serializes_adr_mutation_with_index_job) { cbm_store_close(seed); } - cbm_daemon_application_t *application = - seeded ? cbm_daemon_application_new(&config) : NULL; + cbm_daemon_application_t *application = seeded ? cbm_daemon_application_new(&config) : NULL; cbm_daemon_runtime_application_callbacks_t callbacks = cbm_daemon_application_runtime_callbacks(application); cbm_daemon_runtime_application_session_t *index_session = @@ -2735,11 +3508,11 @@ TEST(daemon_application_serializes_adr_mutation_with_index_job) { project ? project : ""); uint8_t *adr_tool = NULL; uint32_t adr_tool_length = 0; - bool setup = application && index_session && adr_session && - app_test_context_request(root, root, &context, &context_length) && - app_test_tool_request("index_repository", index_args, &index_tool, - &index_tool_length) && - app_test_tool_request("manage_adr", adr_args, &adr_tool, &adr_tool_length); + bool setup = + application && index_session && adr_session && + app_test_context_request(root, root, &context, &context_length) && + app_test_tool_request("index_repository", index_args, &index_tool, &index_tool_length) && + app_test_tool_request("manage_adr", adr_args, &adr_tool, &adr_tool_length); uint8_t *response = NULL; uint32_t response_length = 0; if (setup) { @@ -2774,8 +3547,8 @@ TEST(daemon_application_serializes_adr_mutation_with_index_job) { bool index_active = index_started && project && app_wait_for_subscribers(application, project, 1) && app_wait_for_atomic_int(&fake.starts, 1); - bool adr_started = index_active && - cbm_thread_create(&adr_thread, 0, app_request_thread, &adr_request) == 0; + bool adr_started = + index_active && cbm_thread_create(&adr_thread, 0, app_request_thread, &adr_request) == 0; uint64_t early_deadline = cbm_now_ms() + 100; while (adr_started && !atomic_load(&adr_request.done) && cbm_now_ms() < early_deadline) { cbm_usleep(1000); @@ -2861,8 +3634,8 @@ TEST(daemon_application_reserved_mutation_delays_worker_start) { snprintf(root, sizeof(root), "%s/cbm-app-mutation-first-XXXXXX", cbm_tmpdir()); bool root_ok = cbm_mkdtemp(root) != NULL; const char *project = "mutation-first"; - bool lease_held = application && - cbm_daemon_application_project_mutation_try_begin(application, project); + bool lease_held = + application && cbm_daemon_application_project_mutation_try_begin(application, project); app_index_thread_t request = { .application = application, .project = project, @@ -2870,8 +3643,8 @@ TEST(daemon_application_reserved_mutation_delays_worker_start) { .result = -1, }; cbm_thread_t thread; - bool thread_started = root_ok && lease_held && - cbm_thread_create(&thread, 0, app_index_thread, &request) == 0; + bool thread_started = + root_ok && lease_held && cbm_thread_create(&thread, 0, app_index_thread, &request) == 0; bool reserved = thread_started && app_wait_for_subscribers(application, project, 1); uint64_t early_deadline = cbm_now_ms() + 100; while (atomic_load(&fake.starts) == 0 && cbm_now_ms() < early_deadline) { @@ -2917,9 +3690,8 @@ TEST(daemon_application_mutation_does_not_block_other_project) { char root[APP_TEST_PATH_CAP]; snprintf(root, sizeof(root), "%s/cbm-app-mutation-other-XXXXXX", cbm_tmpdir()); bool root_ok = cbm_mkdtemp(root) != NULL; - bool lease_held = application && - cbm_daemon_application_project_mutation_try_begin(application, - "project-a"); + bool lease_held = + application && cbm_daemon_application_project_mutation_try_begin(application, "project-a"); app_index_thread_t request = { .application = application, .project = "project-b", @@ -2927,8 +3699,8 @@ TEST(daemon_application_mutation_does_not_block_other_project) { .result = -1, }; cbm_thread_t thread; - bool thread_started = root_ok && lease_held && - cbm_thread_create(&thread, 0, app_index_thread, &request) == 0; + bool thread_started = + root_ok && lease_held && cbm_thread_create(&thread, 0, app_index_thread, &request) == 0; bool worker_started = thread_started && app_wait_for_atomic_int(&fake.starts, 1); atomic_store(&fake.allow_completion, true); if (thread_started) { @@ -2960,18 +3732,13 @@ TEST(daemon_application_mutation_still_owns_os_project_lock) { locks_ready ? cbm_daemon_application_new(&config) : NULL; const char *project = "daemon-mutation-native-lock"; bool mutation_started = - application && - cbm_daemon_application_project_mutation_try_begin(application, - project); + application && cbm_daemon_application_project_mutation_try_begin(application, project); cbm_project_lock_lease_t *active_probe = NULL; cbm_private_file_lock_status_t active_status = - mutation_started - ? cbm_project_lock_try_acquire(locks.probe_locks, project, - &active_probe) - : CBM_PRIVATE_FILE_LOCK_IO; - bool mutation_holds_lock = - active_status == CBM_PRIVATE_FILE_LOCK_BUSY && !active_probe; + mutation_started ? cbm_project_lock_try_acquire(locks.probe_locks, project, &active_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool mutation_holds_lock = active_status == CBM_PRIVATE_FILE_LOCK_BUSY && !active_probe; bool active_probe_released = app_project_lock_release(&active_probe); if (mutation_started) { cbm_daemon_application_project_mutation_end(application, project); @@ -2979,16 +3746,11 @@ TEST(daemon_application_mutation_still_owns_os_project_lock) { cbm_project_lock_lease_t *terminal_probe = NULL; cbm_private_file_lock_status_t terminal_status = - mutation_started - ? cbm_project_lock_try_acquire(locks.probe_locks, project, - &terminal_probe) - : CBM_PRIVATE_FILE_LOCK_IO; - bool mutation_released_lock = - terminal_status == CBM_PRIVATE_FILE_LOCK_OK && terminal_probe; + mutation_started ? cbm_project_lock_try_acquire(locks.probe_locks, project, &terminal_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool mutation_released_lock = terminal_status == CBM_PRIVATE_FILE_LOCK_OK && terminal_probe; bool terminal_probe_released = app_project_lock_release(&terminal_probe); - bool stopped = application && - cbm_daemon_application_shutdown(application, - APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); bool locks_clean = app_project_lock_fixture_cleanup(&locks); @@ -3030,8 +3792,7 @@ TEST(daemon_application_worker_boundary_is_sole_os_project_lock_owner) { locks_ready ? cbm_daemon_application_new(&config) : NULL; char root[APP_TEST_PATH_CAP]; - (void)snprintf(root, sizeof(root), - "%s/cbm-app-worker-lock-owner-XXXXXX", cbm_tmpdir()); + (void)snprintf(root, sizeof(root), "%s/cbm-app-worker-lock-owner-XXXXXX", cbm_tmpdir()); bool root_ok = cbm_mkdtemp(root) != NULL; const char *project = "worker-lock-owner"; app_index_thread_t request = { @@ -3041,22 +3802,16 @@ TEST(daemon_application_worker_boundary_is_sole_os_project_lock_owner) { .result = -1, }; cbm_thread_t thread; - bool thread_started = root_ok && application && - cbm_thread_create(&thread, 0, app_index_thread, - &request) == 0; - bool boundary_started = - thread_started && app_wait_for_atomic_int(&fake.starts, 1); + bool thread_started = + root_ok && application && cbm_thread_create(&thread, 0, app_index_thread, &request) == 0; + bool boundary_started = thread_started && app_wait_for_atomic_int(&fake.starts, 1); bool worker_acquired = - boundary_started && - app_wait_for_atomic_int(&fake.project_lock_acquisitions, 1); + boundary_started && app_wait_for_atomic_int(&fake.project_lock_acquisitions, 1); cbm_project_lock_lease_t *active_probe = NULL; cbm_private_file_lock_status_t active_probe_status = - worker_acquired - ? cbm_project_lock_try_acquire(locks.probe_locks, project, - &active_probe) - : CBM_PRIVATE_FILE_LOCK_IO; - bool worker_holds_lock = - active_probe_status == CBM_PRIVATE_FILE_LOCK_BUSY && !active_probe; + worker_acquired ? cbm_project_lock_try_acquire(locks.probe_locks, project, &active_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool worker_holds_lock = active_probe_status == CBM_PRIVATE_FILE_LOCK_BUSY && !active_probe; bool active_probe_released = app_project_lock_release(&active_probe); atomic_store(&fake.allow_completion, true); if (thread_started) { @@ -3064,17 +3819,12 @@ TEST(daemon_application_worker_boundary_is_sole_os_project_lock_owner) { } cbm_project_lock_lease_t *terminal_probe = NULL; cbm_private_file_lock_status_t terminal_probe_status = - thread_started - ? cbm_project_lock_try_acquire(locks.probe_locks, project, - &terminal_probe) - : CBM_PRIVATE_FILE_LOCK_IO; - bool worker_released_lock = - terminal_probe_status == CBM_PRIVATE_FILE_LOCK_OK && terminal_probe; + thread_started ? cbm_project_lock_try_acquire(locks.probe_locks, project, &terminal_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool worker_released_lock = terminal_probe_status == CBM_PRIVATE_FILE_LOCK_OK && terminal_probe; bool terminal_probe_released = app_project_lock_release(&terminal_probe); - bool stopped = application && - cbm_daemon_application_shutdown(application, - APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); bool locks_clean = app_project_lock_fixture_cleanup(&locks); (void)th_rmtree(root); @@ -3120,10 +3870,10 @@ TEST(daemon_application_worker_lock_serializes_external_mutation) { char blocked_root[APP_TEST_PATH_CAP]; char other_root[APP_TEST_PATH_CAP]; - (void)snprintf(blocked_root, sizeof(blocked_root), - "%s/cbm-app-external-blocked-XXXXXX", cbm_tmpdir()); - (void)snprintf(other_root, sizeof(other_root), - "%s/cbm-app-external-other-XXXXXX", cbm_tmpdir()); + (void)snprintf(blocked_root, sizeof(blocked_root), "%s/cbm-app-external-blocked-XXXXXX", + cbm_tmpdir()); + (void)snprintf(other_root, sizeof(other_root), "%s/cbm-app-external-other-XXXXXX", + cbm_tmpdir()); bool blocked_root_ok = cbm_mkdtemp(blocked_root) != NULL; bool other_root_ok = cbm_mkdtemp(other_root) != NULL; const char *blocked_project = "external-blocked"; @@ -3132,9 +3882,8 @@ TEST(daemon_application_worker_lock_serializes_external_mutation) { cbm_project_lock_lease_t *external_lease = NULL; bool external_lease_held = application && blocked_root_ok && other_root_ok && - cbm_project_lock_acquire(locks.external_locks, blocked_project, - UINT64_MAX, NULL, &external_lease) == - CBM_PRIVATE_FILE_LOCK_OK && + cbm_project_lock_acquire(locks.external_locks, blocked_project, UINT64_MAX, NULL, + &external_lease) == CBM_PRIVATE_FILE_LOCK_OK && external_lease; app_index_thread_t blocked_request = { .application = application, @@ -3145,15 +3894,14 @@ TEST(daemon_application_worker_lock_serializes_external_mutation) { cbm_thread_t blocked_thread; bool blocked_thread_started = external_lease_held && - cbm_thread_create(&blocked_thread, 0, app_index_thread, - &blocked_request) == 0; + cbm_thread_create(&blocked_thread, 0, app_index_thread, &blocked_request) == 0; bool blocked_boundary_started = blocked_thread_started && app_wait_for_atomic_int(&fake.starts, 1); bool blocked_boundary_attempted = blocked_boundary_started && app_wait_for_atomic_int_at_least(&fake.project_lock_attempts, 1); - bool blocked_waiting = blocked_boundary_attempted && - atomic_load(&fake.project_lock_acquisitions) == 0; + bool blocked_waiting = + blocked_boundary_attempted && atomic_load(&fake.project_lock_acquisitions) == 0; app_index_thread_t other_request = { .application = application, @@ -3164,19 +3912,15 @@ TEST(daemon_application_worker_lock_serializes_external_mutation) { cbm_thread_t other_thread; bool other_thread_started = blocked_waiting && - cbm_thread_create(&other_thread, 0, app_index_thread, - &other_request) == 0; - bool both_boundaries_started = - other_thread_started && app_wait_for_atomic_int(&fake.starts, 2); + cbm_thread_create(&other_thread, 0, app_index_thread, &other_request) == 0; + bool both_boundaries_started = other_thread_started && app_wait_for_atomic_int(&fake.starts, 2); bool other_worker_acquired = - both_boundaries_started && - app_wait_for_atomic_int(&fake.project_lock_acquisitions, 1); + both_boundaries_started && app_wait_for_atomic_int(&fake.project_lock_acquisitions, 1); cbm_project_lock_lease_t *other_probe = NULL; cbm_private_file_lock_status_t active_other_status = other_worker_acquired - ? cbm_project_lock_try_acquire(locks.probe_locks, other_project, - &other_probe) + ? cbm_project_lock_try_acquire(locks.probe_locks, other_project, &other_probe) : CBM_PRIVATE_FILE_LOCK_IO; bool other_lock_held_while_worker_active = active_other_status == CBM_PRIVATE_FILE_LOCK_BUSY && !other_probe; @@ -3184,8 +3928,7 @@ TEST(daemon_application_worker_lock_serializes_external_mutation) { atomic_store(&fake.allow_completion, true); if (other_thread_started && !other_worker_acquired && application) { - (void)cbm_daemon_application_shutdown(application, - APP_TEST_TIMEOUT_MS); + (void)cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); } if (other_thread_started) { (void)cbm_thread_join(&other_thread); @@ -3193,24 +3936,18 @@ TEST(daemon_application_worker_lock_serializes_external_mutation) { cbm_project_lock_lease_t *terminal_other_probe = NULL; cbm_private_file_lock_status_t terminal_other_status = other_worker_acquired - ? cbm_project_lock_try_acquire(locks.probe_locks, other_project, - &terminal_other_probe) + ? cbm_project_lock_try_acquire(locks.probe_locks, other_project, &terminal_other_probe) : CBM_PRIVATE_FILE_LOCK_IO; bool other_lock_released_at_terminal = - terminal_other_status == CBM_PRIVATE_FILE_LOCK_OK && - terminal_other_probe; - bool terminal_other_probe_released = - app_project_lock_release(&terminal_other_probe); - bool blocked_still_waiting = - atomic_load(&fake.project_lock_acquisitions) == 1; + terminal_other_status == CBM_PRIVATE_FILE_LOCK_OK && terminal_other_probe; + bool terminal_other_probe_released = app_project_lock_release(&terminal_other_probe); + bool blocked_still_waiting = atomic_load(&fake.project_lock_acquisitions) == 1; bool external_lease_released = app_project_lock_release(&external_lease); bool blocked_worker_acquired = - blocked_thread_started && - app_wait_for_atomic_int(&fake.project_lock_acquisitions, 2); + blocked_thread_started && app_wait_for_atomic_int(&fake.project_lock_acquisitions, 2); if (blocked_thread_started && !blocked_worker_acquired && application) { - (void)cbm_daemon_application_shutdown(application, - APP_TEST_TIMEOUT_MS); + (void)cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); } if (blocked_thread_started) { (void)cbm_thread_join(&blocked_thread); @@ -3218,16 +3955,13 @@ TEST(daemon_application_worker_lock_serializes_external_mutation) { cbm_project_lock_lease_t *blocked_probe = NULL; cbm_private_file_lock_status_t terminal_blocked_status = blocked_worker_acquired - ? cbm_project_lock_try_acquire(locks.probe_locks, - blocked_project, &blocked_probe) + ? cbm_project_lock_try_acquire(locks.probe_locks, blocked_project, &blocked_probe) : CBM_PRIVATE_FILE_LOCK_IO; bool blocked_lock_released_at_terminal = terminal_blocked_status == CBM_PRIVATE_FILE_LOCK_OK && blocked_probe; bool blocked_probe_released = app_project_lock_release(&blocked_probe); - bool stopped = application && - cbm_daemon_application_shutdown(application, - APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); bool locks_clean = app_project_lock_fixture_cleanup(&locks); (void)th_rmtree(blocked_root); @@ -3289,16 +4023,14 @@ TEST(daemon_application_final_unsubscribe_cancels_external_lock_wait) { application ? app_test_open(&callbacks, 37) : NULL; char root[APP_TEST_PATH_CAP]; - (void)snprintf(root, sizeof(root), - "%s/cbm-app-external-cancel-XXXXXX", cbm_tmpdir()); + (void)snprintf(root, sizeof(root), "%s/cbm-app-external-cancel-XXXXXX", cbm_tmpdir()); bool root_ok = cbm_mkdtemp(root) != NULL; char *project = root_ok ? cbm_project_name_from_path(root) : NULL; cbm_project_lock_lease_t *external_lease = NULL; bool external_lease_held = session && project && - cbm_project_lock_acquire(locks.external_locks, project, UINT64_MAX, - NULL, &external_lease) == - CBM_PRIVATE_FILE_LOCK_OK && + cbm_project_lock_acquire(locks.external_locks, project, UINT64_MAX, NULL, + &external_lease) == CBM_PRIVATE_FILE_LOCK_OK && external_lease; uint8_t *context = NULL; @@ -3308,16 +4040,13 @@ TEST(daemon_application_final_unsubscribe_cancels_external_lock_wait) { uint8_t *tool = NULL; uint32_t tool_length = 0; bool setup = external_lease_held && - app_test_context_request(root, root, &context, - &context_length) && - app_test_tool_request("index_repository", args, &tool, - &tool_length); + app_test_context_request(root, root, &context, &context_length) && + app_test_tool_request("index_repository", args, &tool, &tool_length); uint8_t *empty = NULL; uint32_t empty_length = 0; if (setup) { - setup = app_test_request(&callbacks, session, context, context_length, - &empty, &empty_length) == - CBM_DAEMON_RUNTIME_APPLICATION_OK; + setup = app_test_request(&callbacks, session, context, context_length, &empty, + &empty_length) == CBM_DAEMON_RUNTIME_APPLICATION_OK; } free(empty); @@ -3330,60 +4059,44 @@ TEST(daemon_application_final_unsubscribe_cancels_external_lock_wait) { atomic_init(&request.done, false); cbm_thread_t request_thread; bool request_started = - setup && cbm_thread_create(&request_thread, 0, app_request_thread, - &request) == 0; - bool subscribed = request_started && - app_wait_for_subscribers(application, project, 1); - bool worker_boundary_started = - subscribed && app_wait_for_atomic_int(&fake.starts, 1); + setup && cbm_thread_create(&request_thread, 0, app_request_thread, &request) == 0; + bool subscribed = request_started && app_wait_for_subscribers(application, project, 1); + bool worker_boundary_started = subscribed && app_wait_for_atomic_int(&fake.starts, 1); bool worker_lock_attempted = - worker_boundary_started && - app_wait_for_atomic_int_at_least(&fake.project_lock_attempts, 1); - bool worker_waiting = worker_lock_attempted && - atomic_load(&fake.project_lock_acquisitions) == 0; + worker_boundary_started && app_wait_for_atomic_int_at_least(&fake.project_lock_attempts, 1); + bool worker_waiting = + worker_lock_attempted && atomic_load(&fake.project_lock_acquisitions) == 0; if (subscribed) { callbacks.session_cancel(callbacks.context, session); } - bool final_unsubscribed = - subscribed && - app_wait_for_subscribers(application, project, 0); + bool final_unsubscribed = subscribed && app_wait_for_subscribers(application, project, 0); uint64_t request_deadline = cbm_now_ms() + APP_TEST_TIMEOUT_MS; - while (request_started && !atomic_load(&request.done) && - cbm_now_ms() < request_deadline) { + while (request_started && !atomic_load(&request.done) && cbm_now_ms() < request_deadline) { cbm_usleep(1000); } bool request_finished = request_started && atomic_load(&request.done); - bool job_terminal = - final_unsubscribed && app_wait_for_active_jobs(application, 0); + bool job_terminal = final_unsubscribed && app_wait_for_active_jobs(application, 0); if (request_started) { (void)cbm_thread_join(&request_thread); } - bool cancellation_reported = - request.status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && - request.response == NULL && request.response_length == 0; - bool worker_never_acquired = - atomic_load(&fake.project_lock_acquisitions) == 0; + bool cancellation_reported = request.status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && + request.response == NULL && request.response_length == 0; + bool worker_never_acquired = atomic_load(&fake.project_lock_acquisitions) == 0; bool external_lease_released = app_project_lock_release(&external_lease); cbm_project_lock_lease_t *leak_probe = NULL; - bool can_probe_for_leak = job_terminal && external_lease_held && - external_lease_released && locks.probe_locks && - project; + bool can_probe_for_leak = job_terminal && external_lease_held && external_lease_released && + locks.probe_locks && project; cbm_private_file_lock_status_t leak_probe_status = - can_probe_for_leak - ? cbm_project_lock_try_acquire(locks.probe_locks, project, - &leak_probe) - : CBM_PRIVATE_FILE_LOCK_IO; - bool no_daemon_lease_leaked = - leak_probe_status == CBM_PRIVATE_FILE_LOCK_OK && leak_probe; + can_probe_for_leak ? cbm_project_lock_try_acquire(locks.probe_locks, project, &leak_probe) + : CBM_PRIVATE_FILE_LOCK_IO; + bool no_daemon_lease_leaked = leak_probe_status == CBM_PRIVATE_FILE_LOCK_OK && leak_probe; bool leak_probe_released = app_project_lock_release(&leak_probe); if (session) { callbacks.session_close(callbacks.context, session); } - bool stopped = application && - cbm_daemon_application_shutdown(application, - APP_TEST_TIMEOUT_MS); + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); bool locks_clean = app_project_lock_fixture_cleanup(&locks); @@ -3448,16 +4161,14 @@ TEST(daemon_application_cancels_mutation_wait_without_cancelling_shared_job) { uint32_t index_tool_length = 0; char mutation_args[APP_TEST_PATH_CAP + 96]; snprintf(mutation_args, sizeof(mutation_args), - "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"x\"}", - project ? project : ""); + "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"x\"}", project ? project : ""); uint8_t *mutation_tool = NULL; uint32_t mutation_tool_length = 0; - bool setup = application && index_session && mutation_session && project && - app_test_context_request(root, root, &context, &context_length) && - app_test_tool_request("index_repository", index_args, &index_tool, - &index_tool_length) && - app_test_tool_request("manage_adr", mutation_args, &mutation_tool, - &mutation_tool_length); + bool setup = + application && index_session && mutation_session && project && + app_test_context_request(root, root, &context, &context_length) && + app_test_tool_request("index_repository", index_args, &index_tool, &index_tool_length) && + app_test_tool_request("manage_adr", mutation_args, &mutation_tool, &mutation_tool_length); uint8_t *response = NULL; uint32_t response_length = 0; cbm_daemon_runtime_application_session_t *sessions[2] = {index_session, mutation_session}; @@ -3482,15 +4193,13 @@ TEST(daemon_application_cancels_mutation_wait_without_cancelling_shared_job) { }; cbm_thread_t index_thread; cbm_thread_t mutation_thread; - bool index_started = setup && - cbm_thread_create(&index_thread, 0, app_request_thread, - &index_request) == 0; - bool index_active = index_started && - app_wait_for_subscribers(application, project, 1) && + bool index_started = + setup && cbm_thread_create(&index_thread, 0, app_request_thread, &index_request) == 0; + bool index_active = index_started && app_wait_for_subscribers(application, project, 1) && app_wait_for_atomic_int(&fake.starts, 1); - bool mutation_started = index_active && - cbm_thread_create(&mutation_thread, 0, app_request_thread, - &mutation_request) == 0; + bool mutation_started = + index_active && + cbm_thread_create(&mutation_thread, 0, app_request_thread, &mutation_request) == 0; uint64_t blocked_deadline = cbm_now_ms() + 100; while (mutation_started && !atomic_load(&mutation_request.done) && cbm_now_ms() < blocked_deadline) { @@ -3516,8 +4225,7 @@ TEST(daemon_application_cancels_mutation_wait_without_cancelling_shared_job) { } bool cancellation_reported = mutation_request.status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && - mutation_request.response == NULL && - mutation_request.response_length == 0; + mutation_request.response == NULL && mutation_request.response_length == 0; callbacks.session_close(callbacks.context, index_session); callbacks.session_close(callbacks.context, mutation_session); bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); @@ -3715,6 +4423,50 @@ TEST(daemon_application_cancellation_between_recovery_attempts_stops_retry) { PASS(); } +TEST(daemon_application_thread_start_failure_rolls_back_job_reservation) { + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + atomic_store(&fake.allow_completion, true); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); + char root[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), "%s/cbm-app-thread-start-XXXXXX", cbm_tmpdir()); + bool root_ok = cbm_mkdtemp(root) != NULL; + + int failed = 0; + if (application && root_ok) { + cbm_daemon_application_fail_next_job_thread_start_for_test(); + failed = cbm_daemon_application_index(application, "thread-start", root); + } + size_t active_after_failure = application ? cbm_daemon_application_active_jobs(application) : 1; + size_t subscribers_after_failure = + application ? cbm_daemon_application_job_subscribers(application, "thread-start") : 1; + int retried = application && root_ok + ? cbm_daemon_application_index(application, "thread-start", root) + : -1; + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + (void)cbm_rmdir(root); + + ASSERT_TRUE(root_ok); + ASSERT_LT(failed, 0); + ASSERT_EQ(active_after_failure, 0); + ASSERT_EQ(subscribers_after_failure, 0); + ASSERT_EQ(retried, 0); + ASSERT_EQ(atomic_load(&fake.starts), 1); + ASSERT_EQ(atomic_load(&fake.destroys), 1); + ASSERT_TRUE(stopped); + PASS(); +} + TEST(daemon_application_enforces_global_physical_job_limit) { app_fake_worker_context_t fake; app_fake_worker_context_init(&fake); @@ -3875,6 +4627,32 @@ TEST(daemon_application_default_limit_admits_four_and_rejects_fifth) { PASS(); } +/* RED on the former void destruction contract: free could retain an + * application with live owners, but the daemon host had no way to observe that + * refusal and freed the application's borrowed dependencies anyway. */ +TEST(daemon_application_free_reports_retained_live_ownership) { + cbm_daemon_application_t *application = cbm_daemon_application_new(NULL); + bool created = application != NULL; + bool mutation_held = application && cbm_daemon_application_project_mutation_try_begin( + application, "free-contract"); + bool freed_while_busy = + application && mutation_held && cbm_daemon_application_free_with_timeout(application, 0); + bool retained_usable = application && mutation_held && !freed_while_busy && + cbm_daemon_application_active_jobs(application) == 0; + if (retained_usable) { + cbm_daemon_application_project_mutation_end(application, "free-contract"); + } + bool freed_after_release = retained_usable && cbm_daemon_application_free_with_timeout( + application, APP_TEST_TIMEOUT_MS); + + ASSERT_TRUE(created); + ASSERT_TRUE(mutation_held); + ASSERT_FALSE(freed_while_busy); + ASSERT_TRUE(retained_usable); + ASSERT_TRUE(freed_after_release); + PASS(); +} + TEST(daemon_application_rejects_clean_exit_when_process_tree_is_not_contained) { app_fake_worker_context_t fake; app_fake_worker_context_init(&fake); @@ -3891,10 +4669,9 @@ TEST(daemon_application_rejects_clean_exit_when_process_tree_is_not_contained) { cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; cbm_daemon_application_t *application = cbm_daemon_application_new(&config); - int result = application - ? cbm_daemon_application_index( - application, "unsafe-clean-project", cbm_tmpdir()) - : -1; + int result = application ? cbm_daemon_application_index(application, "unsafe-clean-project", + cbm_tmpdir()) + : -1; bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); cbm_daemon_application_free(application); @@ -3918,7 +4695,16 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_reference_counts_one_shared_watch); RUN_TEST(daemon_application_free_releases_live_watch_once); RUN_TEST(daemon_application_prune_clears_logical_watch_for_reregistration); - RUN_TEST(daemon_application_coalesces_identical_index_requests); + RUN_TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions); + RUN_TEST(daemon_application_auto_index_honors_tracked_file_limit); + RUN_TEST(daemon_application_auto_index_file_count_handles_literal_metacharacter_path); + RUN_TEST(daemon_application_auto_index_file_count_supports_non_git_roots); + RUN_TEST(daemon_application_auto_index_retries_transient_busy_admission); + RUN_TEST(daemon_application_update_generation_notifies_initial_and_late_sessions_once); + RUN_TEST(daemon_application_update_generation_retries_worker_start_failure); + RUN_TEST(daemon_application_update_generation_retries_cancelled_check); + RUN_TEST(daemon_application_final_disconnect_cancels_and_joins_update_generation); + RUN_TEST(daemon_application_coalesces_semantically_identical_index_requests); RUN_TEST(daemon_application_fresh_request_does_not_reuse_terminal_subscribed_job); RUN_TEST(daemon_application_request_cancel_detaches_only_one_coalesced_subscriber); RUN_TEST(daemon_application_cancels_physical_job_only_after_final_session); @@ -3928,6 +4714,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_stale_watcher_callback_is_rejected_at_job_admission); RUN_TEST(daemon_application_final_cancel_drains_admitted_watcher_job); RUN_TEST(daemon_application_watcher_job_follows_exact_live_watch_owners); + RUN_TEST(daemon_application_late_watcher_session_owns_active_watcher_job); RUN_TEST(daemon_application_serializes_adr_mutation_with_index_job); RUN_TEST(daemon_application_reserved_mutation_delays_worker_start); RUN_TEST(daemon_application_mutation_does_not_block_other_project); @@ -3938,7 +4725,9 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_cancels_mutation_wait_without_cancelling_shared_job); RUN_TEST(daemon_application_recovers_with_unique_per_job_quarantine_files); RUN_TEST(daemon_application_cancellation_between_recovery_attempts_stops_retry); + RUN_TEST(daemon_application_thread_start_failure_rolls_back_job_reservation); RUN_TEST(daemon_application_enforces_global_physical_job_limit); RUN_TEST(daemon_application_default_limit_admits_four_and_rejects_fifth); + RUN_TEST(daemon_application_free_reports_retained_live_ownership); RUN_TEST(daemon_application_rejects_clean_exit_when_process_tree_is_not_contained); } diff --git a/tests/test_daemon_bootstrap.c b/tests/test_daemon_bootstrap.c index 402dfb4a0..a6369a321 100644 --- a/tests/test_daemon_bootstrap.c +++ b/tests/test_daemon_bootstrap.c @@ -62,6 +62,7 @@ static cbm_daemon_build_identity_t bootstrap_identity(const char *version, const cbm_daemon_build_identity_t identity = { .semantic_version = version, .build_fingerprint = build, + .cache_fingerprint = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", .protocol_abi = 3, .store_abi = 11, .feature_abi = 7, @@ -111,9 +112,8 @@ static cbm_daemon_bootstrap_probe_status_t bootstrap_fake_probe( *client_out = NULL; int reserved_remaining = atomic_load(&fake->reserved_probes_remaining); while (reserved_remaining > 0 && - !atomic_compare_exchange_weak(&fake->reserved_probes_remaining, - &reserved_remaining, reserved_remaining - 1)) { - } + !atomic_compare_exchange_weak(&fake->reserved_probes_remaining, &reserved_remaining, + reserved_remaining - 1)) {} if (reserved_remaining > 0) { if (reserved_remaining == 1 && atomic_load(&fake->connect_after_reserved)) { atomic_store(&fake->available, true); @@ -122,10 +122,8 @@ static cbm_daemon_bootstrap_probe_status_t bootstrap_fake_probe( } int terminal_remaining = atomic_load(&fake->terminal_probes_remaining); while (terminal_remaining > 0 && - !atomic_compare_exchange_weak(&fake->terminal_probes_remaining, - &terminal_remaining, - terminal_remaining - 1)) { - } + !atomic_compare_exchange_weak(&fake->terminal_probes_remaining, &terminal_remaining, + terminal_remaining - 1)) {} if (terminal_remaining > 0) { return CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL; } @@ -155,8 +153,7 @@ static cbm_daemon_bootstrap_probe_status_t bootstrap_fake_probe( static cbm_version_cohort_status_t bootstrap_fake_cohort_acquire( void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms, - cbm_daemon_bootstrap_cohort_t *cohort_out, - cbm_daemon_conflict_t *conflict_out) { + cbm_daemon_bootstrap_cohort_t *cohort_out, cbm_daemon_conflict_t *conflict_out) { (void)endpoint; (void)deadline_ms; bootstrap_fake_ops_t *fake = opaque; @@ -164,8 +161,7 @@ static cbm_version_cohort_status_t bootstrap_fake_cohort_acquire( *cohort_out = NULL; memset(conflict_out, 0, sizeof(*conflict_out)); if (fake->forced_cohort == CBM_VERSION_COHORT_CONFLICT) { - cbm_daemon_build_identity_t active = - bootstrap_identity("2.3.0", BOOTSTRAP_BUILD_A); + cbm_daemon_build_identity_t active = bootstrap_identity("2.3.0", BOOTSTRAP_BUILD_A); (void)cbm_daemon_hello_compare(&active, identity, conflict_out); return fake->forced_cohort; } @@ -176,8 +172,7 @@ static cbm_version_cohort_status_t bootstrap_fake_cohort_acquire( return CBM_VERSION_COHORT_OK; } -static void bootstrap_fake_cohort_release( - void *opaque, cbm_daemon_bootstrap_cohort_t cohort) { +static void bootstrap_fake_cohort_release(void *opaque, cbm_daemon_bootstrap_cohort_t cohort) { bootstrap_fake_ops_t *fake = opaque; if (cohort == fake) { atomic_fetch_add(&fake->cohort_release_count, 1); @@ -197,8 +192,7 @@ static int bootstrap_fake_lock(void *opaque, const cbm_daemon_ipc_endpoint_t *en return 1; } -static bool bootstrap_fake_unlock( - void *opaque, cbm_daemon_bootstrap_lock_t *lock_io) { +static bool bootstrap_fake_unlock(void *opaque, cbm_daemon_bootstrap_lock_t *lock_io) { bootstrap_fake_ops_t *fake = opaque; if (lock_io && *lock_io == fake) { atomic_store(&fake->lock_held, 0); @@ -208,8 +202,7 @@ static bool bootstrap_fake_unlock( return lock_io && !*lock_io; } -static bool bootstrap_fake_handoff(void *opaque, - cbm_daemon_bootstrap_lock_t lock) { +static bool bootstrap_fake_handoff(void *opaque, cbm_daemon_bootstrap_lock_t lock) { bootstrap_fake_ops_t *fake = opaque; if (lock != fake || atomic_load(&fake->lock_held) != 1) { return false; @@ -224,8 +217,7 @@ static bool bootstrap_fake_spawn(void *opaque, const cbm_daemon_bootstrap_launch spec->argv[1] && !spec->argv[2] && strcmp(spec->argv[1], CBM_DAEMON_INTERNAL_ARG) == 0 && spec->detached && !spec->inherit_standard_handles && !spec->use_shell && - atomic_load(&fake->handoff_count) > 0 && - atomic_load(&fake->lock_held) == 1; + atomic_load(&fake->handoff_count) > 0 && atomic_load(&fake->lock_held) == 1; if (!exact) { return false; } @@ -296,14 +288,12 @@ TEST(daemon_bootstrap_classifies_stateless_commands_without_client) { TEST(daemon_bootstrap_classifies_config_as_coordinated_local_cli) { char *list[] = {"codebase-memory-mcp", "config", "list", NULL}; - char *set[] = {"codebase-memory-mcp", "config", "set", "auto_watch", - "false", NULL}; + char *set[] = {"codebase-memory-mcp", "config", "set", "auto_watch", "false", NULL}; char *help[] = {"codebase-memory-mcp", "config", "--help", NULL}; ASSERT_EQ(classify(3, list), CBM_DAEMON_PROCESS_LOCAL_CLI); ASSERT_EQ(classify(5, set), CBM_DAEMON_PROCESS_LOCAL_CLI); ASSERT_EQ(classify(3, help), CBM_DAEMON_PROCESS_STATELESS); - ASSERT_FALSE(cbm_daemon_process_role_requires_client( - CBM_DAEMON_PROCESS_LOCAL_CLI)); + ASSERT_FALSE(cbm_daemon_process_role_requires_client(CBM_DAEMON_PROCESS_LOCAL_CLI)); PASS(); } @@ -317,26 +307,24 @@ TEST(daemon_bootstrap_cli_help_is_stateless_but_tool_calls_are_local) { } TEST(daemon_bootstrap_cli_arguments_cannot_reclassify_the_process) { - char *install_value[] = {"codebase-memory-mcp", "cli", "search_code", - "--query", "install", NULL}; - char *version_value[] = {"codebase-memory-mcp", "cli", "search_code", - "--query", "--version", NULL}; + char *install_value[] = { + "codebase-memory-mcp", "cli", "search_code", "--query", "install", NULL}; + char *version_value[] = {"codebase-memory-mcp", "cli", "search_code", "--query", + "--version", NULL}; ASSERT_EQ(classify(5, install_value), CBM_DAEMON_PROCESS_LOCAL_CLI); ASSERT_EQ(classify(5, version_value), CBM_DAEMON_PROCESS_LOCAL_CLI); PASS(); } TEST(daemon_bootstrap_internal_roles_never_take_client_leases) { - static char build[] = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + static char build[] = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; char *daemon[] = {"codebase-memory-mcp", CBM_DAEMON_INTERNAL_ARG, NULL}; - char *worker[] = {"codebase-memory-mcp", "cli", "--index-worker", - "--index-worker-build", build, "index_repository", - "{}", "--response-out", "/tmp/response", NULL}; + char *worker[] = {"codebase-memory-mcp", "cli", "--index-worker", "--index-worker-build", build, + "index_repository", "{}", "--response-out", "/tmp/response", NULL}; char *malformed_worker[] = {"codebase-memory-mcp", "cli", "--index-worker", - "index_repository", "{}", NULL}; + "index_repository", "{}", NULL}; char *reserved_user_value[] = {"codebase-memory-mcp", "cli", "search_code", "--query", - "--index-worker", NULL}; + "--index-worker", NULL}; char *hook[] = {"codebase-memory-mcp", "hook-augment", NULL}; ASSERT_EQ(classify(2, daemon), CBM_DAEMON_PROCESS_DAEMON); ASSERT_EQ(classify(9, worker), CBM_DAEMON_PROCESS_WORKER); @@ -407,8 +395,7 @@ TEST(daemon_bootstrap_cohort_conflict_is_visible_before_probe_or_spawn) { bootstrap_fake_ops_t fake = {0}; fake.forced_cohort = CBM_VERSION_COHORT_CONFLICT; cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); - cbm_daemon_build_identity_t identity = - bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_B); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_B); cbm_daemon_bootstrap_config_t config = { .role = CBM_DAEMON_PROCESS_MCP_CLIENT, .endpoint = fixture.endpoint, @@ -428,8 +415,7 @@ TEST(daemon_bootstrap_cohort_conflict_is_visible_before_probe_or_spawn) { ASSERT_EQ(atomic_load(&fake.spawn_count), 0); ASSERT_EQ(atomic_load(&fake.diagnostic_count), 1); ASSERT_NOT_NULL(strstr(fake.diagnostic, "conflicting CBM process is active")); - ASSERT_NOT_NULL(strstr(fake.diagnostic, - "Close all CBM sessions and commands")); + ASSERT_NOT_NULL(strstr(fake.diagnostic, "Close all CBM sessions and commands")); bootstrap_endpoint_fixture_finish(&fixture); PASS(); } @@ -601,8 +587,7 @@ TEST(daemon_bootstrap_reserved_then_absent_spawns_replacement) { TEST(daemon_bootstrap_rejected_connect_is_reserved_and_never_unavailable) { cbm_daemon_runtime_connect_result_t capacity = {0}; capacity.status = CBM_DAEMON_RUNTIME_CONNECT_REJECTED; - snprintf(capacity.message, sizeof(capacity.message), - "CBM daemon connection capacity reached"); + snprintf(capacity.message, sizeof(capacity.message), "CBM daemon connection capacity reached"); ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, 1), CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED); ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, 0), diff --git a/tests/test_daemon_frontend.c b/tests/test_daemon_frontend.c index 3dda3f174..c24639be2 100644 --- a/tests/test_daemon_frontend.c +++ b/tests/test_daemon_frontend.c @@ -9,6 +9,7 @@ #include "daemon/service.h" #include "daemon/version_cohort.h" #include "foundation/compat.h" +#include "foundation/compat_thread.h" #include "foundation/platform.h" #include @@ -32,28 +33,22 @@ typedef struct { cbm_version_cohort_manager_t *manager; } frontend_maintenance_fixture_t; -static bool frontend_maintenance_fixture_start( - frontend_maintenance_fixture_t *fixture, const char *tag) { +static bool frontend_maintenance_fixture_start(frontend_maintenance_fixture_t *fixture, + const char *tag) { memset(fixture, 0, sizeof(*fixture)); - int written = snprintf(fixture->parent, sizeof(fixture->parent), - "%s/cbm-frontend-%s-XXXXXX", cbm_tmpdir(), tag); - if (written <= 0 || written >= (int)sizeof(fixture->parent) || - !cbm_mkdtemp(fixture->parent)) { + int written = snprintf(fixture->parent, sizeof(fixture->parent), "%s/cbm-frontend-%s-XXXXXX", + cbm_tmpdir(), tag); + if (written <= 0 || written >= (int)sizeof(fixture->parent) || !cbm_mkdtemp(fixture->parent)) { return false; } - fixture->endpoint = - cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture->parent); - fixture->manager = fixture->endpoint - ? cbm_version_cohort_manager_new(fixture->endpoint) - : NULL; + fixture->endpoint = cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture->parent); + fixture->manager = fixture->endpoint ? cbm_version_cohort_manager_new(fixture->endpoint) : NULL; return fixture->endpoint && fixture->manager; } -static void frontend_maintenance_fixture_finish( - frontend_maintenance_fixture_t *fixture) { +static void frontend_maintenance_fixture_finish(frontend_maintenance_fixture_t *fixture) { while (fixture->manager && - cbm_version_cohort_manager_free(&fixture->manager) != - CBM_PRIVATE_FILE_LOCK_OK) { + cbm_version_cohort_manager_free(&fixture->manager) != CBM_PRIVATE_FILE_LOCK_OK) { cbm_usleep(1000); } cbm_daemon_ipc_endpoint_free(fixture->endpoint); @@ -66,11 +61,64 @@ static void frontend_maintenance_fixture_finish( #ifndef _WIN32 static const char FRONTEND_TEST_BUILD[] = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +static const char FRONTEND_TEST_CACHE[] = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +enum { + FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS = 2000, + FRONTEND_EOF_TEST_OVERFLOW_TIMEOUT_S = 4, + FRONTEND_EOF_TEST_DRAIN_TIMEOUT_S = 10, + FRONTEND_BACKPRESSURE_MESSAGE_BYTES = 2 * 1024 * 1024, + FRONTEND_BACKPRESSURE_FRONTEND_TIMEOUT_S = 12, + FRONTEND_BACKPRESSURE_DAEMON_TIMEOUT_S = 20, + FRONTEND_BACKPRESSURE_RUNTIME_TIMEOUT_MS = 15000, + FRONTEND_BACKPRESSURE_CLEANUP_TIMEOUT_MS = 2000, + /* The production queue is deliberately bounded below this count. Keep the + * regression black-box: it must remain valid if the exact capacity changes + * while still proving that overload cannot hide an already-pending EOF. */ + FRONTEND_EOF_TEST_OVERFLOW_MESSAGES = 32, +}; + +typedef struct { + atomic_int requests; + atomic_int session_cancels; + atomic_int sessions_opened; + atomic_int first_session_cancels; + atomic_int second_session_cancels; + atomic_bool block_first_request; + atomic_bool first_request_started; + int request_observed_fd; + int session_cancel_fd; +} frontend_eof_application_context_t; + +typedef struct { + frontend_eof_application_context_t *context; + atomic_bool cancelled; + int session_index; +} frontend_eof_application_session_t; + +typedef struct { + int fd; + bool overflow; + frontend_eof_application_context_t *application; + atomic_bool finished; + atomic_bool succeeded; +} frontend_eof_writer_t; + +typedef struct { + char conflict_log[FRONTEND_TEST_PATH_CAP]; + cbm_daemon_ipc_endpoint_t *endpoint; + cbm_version_cohort_manager_t *manager; + cbm_daemon_runtime_service_t *service; + cbm_daemon_runtime_client_t *client; + frontend_eof_application_context_t application; +} frontend_eof_fixture_t; static cbm_daemon_build_identity_t frontend_test_identity(void) { cbm_daemon_build_identity_t identity = { .semantic_version = "2.4.0", .build_fingerprint = FRONTEND_TEST_BUILD, + .cache_fingerprint = FRONTEND_TEST_CACHE, .protocol_abi = 3, .store_abi = 11, .feature_abi = 7, @@ -78,17 +126,345 @@ static cbm_daemon_build_identity_t frontend_test_identity(void) { return identity; } -static void frontend_test_release_lease( - cbm_version_cohort_lease_t **lease) { - while (lease && *lease && - cbm_version_cohort_lease_release(lease) != - CBM_PRIVATE_FILE_LOCK_OK) { +static cbm_daemon_runtime_application_session_t *frontend_eof_application_session_open( + void *opaque, cbm_daemon_client_id_t client_id, uint64_t authenticated_process_id) { + frontend_eof_application_context_t *context = opaque; + frontend_eof_application_session_t *session = calloc(1, sizeof(*session)); + if (!context || !session || client_id == CBM_DAEMON_CLIENT_ID_INVALID || + authenticated_process_id == 0) { + free(session); + return NULL; + } + session->context = context; + session->session_index = + atomic_fetch_add_explicit(&context->sessions_opened, 1, memory_order_relaxed); + atomic_init(&session->cancelled, false); + return (cbm_daemon_runtime_application_session_t *)session; +} + +static cbm_daemon_runtime_application_status_t frontend_eof_application_request( + void *opaque, cbm_daemon_runtime_application_session_t *opaque_session, + cbm_daemon_runtime_application_token_t request_token, const uint8_t *request, + uint32_t request_length, uint8_t **response_out, uint32_t *response_length_out) { + (void)request_token; + frontend_eof_application_context_t *context = opaque; + frontend_eof_application_session_t *session = + (frontend_eof_application_session_t *)opaque_session; + if (!context || !session || session->context != context || !response_out || + !response_length_out || (request_length > 0 && !request)) { + return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + *response_out = NULL; + *response_length_out = 0; + int request_index = atomic_fetch_add_explicit(&context->requests, 1, memory_order_relaxed); + if (request_index == 0 && + atomic_load_explicit(&context->block_first_request, memory_order_acquire)) { + atomic_store_explicit(&context->first_request_started, true, memory_order_release); + while (!atomic_load_explicit(&session->cancelled, memory_order_acquire)) { + cbm_usleep(1000); + } + return CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED; + } + if (request_length == 0) { + return CBM_DAEMON_RUNTIME_APPLICATION_OK; + } + uint8_t *response = malloc(request_length); + if (!response) { + return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; + } + memcpy(response, request, request_length); + *response_out = response; + *response_length_out = request_length; + if (context->request_observed_fd >= 0) { + const char marker = 'Q'; + (void)write(context->request_observed_fd, &marker, 1); + } + return CBM_DAEMON_RUNTIME_APPLICATION_OK; +} + +static void frontend_eof_application_request_cancel( + void *opaque, cbm_daemon_runtime_application_session_t *opaque_session, + cbm_daemon_runtime_application_token_t request_token) { + (void)request_token; + frontend_eof_application_context_t *context = opaque; + frontend_eof_application_session_t *session = + (frontend_eof_application_session_t *)opaque_session; + if (context && session && session->context == context) { + atomic_store_explicit(&session->cancelled, true, memory_order_release); + } +} + +static void frontend_eof_application_session_cancel( + void *opaque, cbm_daemon_runtime_application_session_t *opaque_session) { + frontend_eof_application_context_t *context = opaque; + frontend_eof_application_session_t *session = + (frontend_eof_application_session_t *)opaque_session; + if (context && session && session->context == context) { + atomic_store_explicit(&session->cancelled, true, memory_order_release); + if (session->session_index == 0) { + (void)atomic_fetch_add_explicit(&context->first_session_cancels, 1, + memory_order_relaxed); + } else if (session->session_index == 1) { + (void)atomic_fetch_add_explicit(&context->second_session_cancels, 1, + memory_order_relaxed); + } + /* Publish the total only after the exact-session counter. The daemon + * fixture acquires this value before inspecting attribution. */ + (void)atomic_fetch_add_explicit(&context->session_cancels, 1, memory_order_release); + if (context->session_cancel_fd >= 0) { + const char marker = 'C'; + (void)write(context->session_cancel_fd, &marker, 1); + } + } +} + +static void frontend_eof_application_session_close( + void *opaque, cbm_daemon_runtime_application_session_t *opaque_session) { + frontend_eof_application_context_t *context = opaque; + frontend_eof_application_session_t *session = + (frontend_eof_application_session_t *)opaque_session; + if (context && session && session->context == context) { + free(session); + } +} + +static bool frontend_eof_write_all(int fd, const char *bytes, size_t length) { + size_t offset = 0; + while (offset < length) { + ssize_t written = write(fd, bytes + offset, length - offset); + if (written > 0) { + offset += (size_t)written; + } else if (written < 0 && errno == EINTR) { + continue; + } else { + return false; + } + } + return true; +} + +static void *frontend_eof_writer(void *opaque) { + frontend_eof_writer_t *writer = opaque; + static const char first[] = + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"test/block\",\"params\":{}}\n"; + bool ok = frontend_eof_write_all(writer->fd, first, sizeof(first) - 1); + uint64_t deadline = cbm_now_ms() + FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS; + while ( + ok && + !atomic_load_explicit(&writer->application->first_request_started, memory_order_acquire) && + cbm_now_ms() < deadline) { cbm_usleep(1000); } + ok = ok && + atomic_load_explicit(&writer->application->first_request_started, memory_order_acquire); + for (int index = 0; ok && writer->overflow && index < FRONTEND_EOF_TEST_OVERFLOW_MESSAGES; + index++) { + char message[160]; + int written = + snprintf(message, sizeof(message), + "{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"test/queued\",\"params\":{}}\n", + index + 2); + ok = written > 0 && written < (int)sizeof(message) && + frontend_eof_write_all(writer->fd, message, (size_t)written); + } + ok = close(writer->fd) == 0 && ok; + writer->fd = -1; + atomic_store_explicit(&writer->succeeded, ok, memory_order_release); + atomic_store_explicit(&writer->finished, true, memory_order_release); + return NULL; } -static bool frontend_test_wait_byte(int fd, char expected, - uint64_t deadline_ms) { +static bool frontend_eof_fixture_start(frontend_eof_fixture_t *fixture, const char *parent) { + memset(fixture, 0, sizeof(*fixture)); + atomic_init(&fixture->application.requests, 0); + atomic_init(&fixture->application.session_cancels, 0); + atomic_init(&fixture->application.sessions_opened, 0); + atomic_init(&fixture->application.first_session_cancels, 0); + atomic_init(&fixture->application.second_session_cancels, 0); + atomic_init(&fixture->application.block_first_request, true); + atomic_init(&fixture->application.first_request_started, false); + fixture->application.request_observed_fd = -1; + fixture->application.session_cancel_fd = -1; + char key[CBM_DAEMON_KEY_SIZE]; + char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; + int log_written = snprintf(fixture->conflict_log, sizeof(fixture->conflict_log), + "%s/conflicts.ndjson", parent); + if (log_written <= 0 || log_written >= (int)sizeof(fixture->conflict_log) || + !cbm_daemon_rendezvous_key(key) || + !cbm_daemon_runtime_process_build_fingerprint((uint64_t)getpid(), build)) { + return false; + } + fixture->endpoint = cbm_daemon_ipc_endpoint_new(key, parent); + fixture->manager = fixture->endpoint ? cbm_version_cohort_manager_new(fixture->endpoint) : NULL; + cbm_daemon_build_identity_t identity = { + .semantic_version = "2.4.0", + .build_fingerprint = build, + .cache_fingerprint = FRONTEND_TEST_CACHE, + .protocol_abi = 3, + .store_abi = 11, + .feature_abi = 7, + }; + cbm_daemon_runtime_application_callbacks_t callbacks = { + .context = &fixture->application, + .session_open = frontend_eof_application_session_open, + .request = frontend_eof_application_request, + .request_cancel = frontend_eof_application_request_cancel, + .session_cancel = frontend_eof_application_session_cancel, + .session_close = frontend_eof_application_session_close, + }; + cbm_daemon_runtime_service_config_t config = { + .endpoint = fixture->endpoint, + .identity = identity, + .conflict_log_path = fixture->conflict_log, + .conflict_log_cap_bytes = 64U * 1024U, + .max_clients = 4, + .lease_timeout_ms = 5000, + .request_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .shutdown_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .application = callbacks, + }; + fixture->service = fixture->manager ? cbm_daemon_runtime_service_start(&config) : NULL; + cbm_daemon_runtime_connect_result_t connect_result = {0}; + fixture->client = fixture->service + ? cbm_daemon_runtime_client_connect(fixture->endpoint, &identity, + FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + &connect_result) + : NULL; + return fixture->client && connect_result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED; +} + +static bool frontend_eof_fixture_finish(frontend_eof_fixture_t *fixture) { + bool ok = true; + if (fixture->client) { + ok = cbm_daemon_runtime_client_close(fixture->client, + FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS) && + ok; + fixture->client = NULL; + } + if (fixture->service) { + if (cbm_daemon_runtime_service_state(fixture->service) != + CBM_DAEMON_RUNTIME_SERVICE_EXITED) { + ok = cbm_daemon_runtime_service_stop(fixture->service, + FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS) && + ok; + } + ok = cbm_daemon_runtime_service_free(fixture->service) && ok; + fixture->service = NULL; + } + if (fixture->manager) { + ok = cbm_version_cohort_manager_free(&fixture->manager) == CBM_PRIVATE_FILE_LOCK_OK && ok; + } + cbm_daemon_ipc_endpoint_free(fixture->endpoint); + fixture->endpoint = NULL; + return ok; +} + +static int frontend_eof_child_run(const char *parent, bool overflow) { + frontend_eof_fixture_t fixture; + if (!frontend_eof_fixture_start(&fixture, parent)) { + (void)frontend_eof_fixture_finish(&fixture); + return 70; + } + int input_pipe[2] = {-1, -1}; + if (pipe(input_pipe) != 0) { + (void)frontend_eof_fixture_finish(&fixture); + return 71; + } + FILE *input = fdopen(input_pipe[0], "rb"); + FILE *output = tmpfile(); + frontend_eof_writer_t writer = { + .fd = input_pipe[1], + .overflow = overflow, + .application = &fixture.application, + }; + atomic_init(&writer.finished, false); + atomic_init(&writer.succeeded, false); + cbm_thread_t writer_thread; + bool writer_started = + input && output && cbm_thread_create(&writer_thread, 0, frontend_eof_writer, &writer) == 0; + if (!writer_started) { + if (input) { + (void)fclose(input); + } else { + (void)close(input_pipe[0]); + } + (void)close(input_pipe[1]); + if (output) { + (void)fclose(output); + } + (void)frontend_eof_fixture_finish(&fixture); + return 72; + } + + cbm_daemon_runtime_client_t *frontend_client = fixture.client; + fixture.client = NULL; /* cbm_daemon_frontend_mcp_run consumes it. */ + int result = cbm_daemon_frontend_mcp_run(frontend_client, fixture.manager, input, output); + bool joined = cbm_thread_join(&writer_thread) == 0; + bool writer_ok = atomic_load_explicit(&writer.finished, memory_order_acquire) && + atomic_load_explicit(&writer.succeeded, memory_order_acquire); + bool request_started = + atomic_load_explicit(&fixture.application.first_request_started, memory_order_acquire); + bool session_cancelled = + atomic_load_explicit(&fixture.application.session_cancels, memory_order_acquire) > 0; + bool input_closed = fclose(input) == 0; + bool output_closed = fclose(output) == 0; + bool streams_closed = input_closed && output_closed; + bool fixture_closed = frontend_eof_fixture_finish(&fixture); + bool result_matches_contract = overflow ? result < 0 : result == 0; + return result_matches_contract && joined && writer_ok && request_started && session_cancelled && + streams_closed && fixture_closed + ? 0 + : 73; +} + +static bool frontend_eof_run_isolated(const char *tag, bool overflow) { + char parent[FRONTEND_TEST_PATH_CAP]; + int written = + snprintf(parent, sizeof(parent), "%s/cbm-frontend-eof-%s-XXXXXX", cbm_tmpdir(), tag); + if (written <= 0 || written >= (int)sizeof(parent) || !cbm_mkdtemp(parent)) { + return false; + } + pid_t child = fork(); + if (child == 0) { + (void)signal(SIGALRM, SIG_DFL); + (void)alarm(overflow ? FRONTEND_EOF_TEST_OVERFLOW_TIMEOUT_S + : FRONTEND_EOF_TEST_DRAIN_TIMEOUT_S); + _exit(frontend_eof_child_run(parent, overflow)); + } + int status = 0; + pid_t waited; + do { + waited = child > 0 ? waitpid(child, &status, 0) : -1; + } while (waited < 0 && errno == EINTR); + bool cleaned = th_rmtree(parent) == 0; + return child > 0 && waited == child && WIFEXITED(status) && WEXITSTATUS(status) == 0 && cleaned; +} + +static void frontend_test_release_lease(cbm_version_cohort_lease_t **lease) { + while (lease && *lease && cbm_version_cohort_lease_release(lease) != CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } +} + +static bool frontend_test_release_lease_until(cbm_version_cohort_lease_t **lease, + uint64_t deadline_ms) { + while (lease && *lease) { + if (cbm_version_cohort_lease_release(lease) == CBM_PRIVATE_FILE_LOCK_OK) { + continue; + } + if (cbm_now_ms() >= deadline_ms) { + return false; + } + cbm_usleep(1000); + } + return !lease || !*lease; +} + +static bool frontend_test_read_byte(int fd, char *observed, uint64_t deadline_ms) { + if (!observed) { + return false; + } + *observed = '\0'; int flags = fcntl(fd, F_GETFL, 0); if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) { return false; @@ -97,10 +473,10 @@ static bool frontend_test_wait_byte(int fd, char expected, char value = '\0'; ssize_t count = read(fd, &value, 1); if (count == 1) { - return value == expected; + *observed = value; + return true; } - if (count == 0 || - (count < 0 && errno != EAGAIN && errno != EWOULDBLOCK) || + if (count == 0 || (count < 0 && errno != EAGAIN && errno != EWOULDBLOCK) || cbm_now_ms() >= deadline_ms) { return false; } @@ -108,8 +484,492 @@ static bool frontend_test_wait_byte(int fd, char expected, } } -static cbm_version_cohort_quiesce_result_t -frontend_test_quiesce_requested(void *context) { +static bool frontend_test_wait_byte(int fd, char expected, uint64_t deadline_ms) { + char observed = '\0'; + return frontend_test_read_byte(fd, &observed, deadline_ms) && observed == expected; +} + +typedef struct { + int fd; + atomic_bool finished; + atomic_bool succeeded; +} frontend_backpressure_writer_t; + +static cbm_version_cohort_quiesce_result_t frontend_test_quiesce_requested(void *context); + +static bool frontend_backpressure_identity(cbm_daemon_build_identity_t *identity, + const char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + if (!identity || !build || strlen(build) != CBM_DAEMON_BUILD_FINGERPRINT_SIZE - 1) { + return false; + } + *identity = (cbm_daemon_build_identity_t){ + .semantic_version = "2.4.0", + .build_fingerprint = build, + .cache_fingerprint = FRONTEND_TEST_CACHE, + .protocol_abi = 3, + .store_abi = 11, + .feature_abi = 7, + }; + return true; +} + +static void *frontend_backpressure_writer(void *opaque) { + frontend_backpressure_writer_t *writer = opaque; + static const char prefix[] = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"test/large\"," + "\"params\":{\"blob\":\""; + static const char suffix[] = "\"}}\n"; + char *message = malloc(FRONTEND_BACKPRESSURE_MESSAGE_BYTES); + size_t prefix_length = sizeof(prefix) - 1; + size_t suffix_length = sizeof(suffix) - 1; + bool valid = message && prefix_length + suffix_length < FRONTEND_BACKPRESSURE_MESSAGE_BYTES; + if (valid) { + memcpy(message, prefix, prefix_length); + memset(message + prefix_length, 'x', + FRONTEND_BACKPRESSURE_MESSAGE_BYTES - prefix_length - suffix_length); + memcpy(message + FRONTEND_BACKPRESSURE_MESSAGE_BYTES - suffix_length, suffix, + suffix_length); + valid = frontend_eof_write_all(writer->fd, message, FRONTEND_BACKPRESSURE_MESSAGE_BYTES); + } + free(message); + valid = close(writer->fd) == 0 && valid; + writer->fd = -1; + atomic_store_explicit(&writer->succeeded, valid, memory_order_release); + atomic_store_explicit(&writer->finished, true, memory_order_release); + return NULL; +} + +static int frontend_backpressure_daemon_run(const char *parent, int ready_fd, int cancel_fd, + int control_fd, + const char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]) { + (void)alarm(FRONTEND_BACKPRESSURE_DAEMON_TIMEOUT_S); + char key[CBM_DAEMON_KEY_SIZE]; + char conflict_log[FRONTEND_TEST_PATH_CAP]; + cbm_daemon_build_identity_t identity = {0}; + int log_written = snprintf(conflict_log, sizeof(conflict_log), "%s/conflicts.ndjson", parent); + cbm_daemon_ipc_endpoint_t *endpoint = + log_written > 0 && log_written < (int)sizeof(conflict_log) && + cbm_daemon_rendezvous_key(key) && frontend_backpressure_identity(&identity, build) + ? cbm_daemon_ipc_endpoint_new(key, parent) + : NULL; + frontend_eof_application_context_t application; + memset(&application, 0, sizeof(application)); + atomic_init(&application.requests, 0); + atomic_init(&application.session_cancels, 0); + atomic_init(&application.sessions_opened, 0); + atomic_init(&application.first_session_cancels, 0); + atomic_init(&application.second_session_cancels, 0); + atomic_init(&application.block_first_request, false); + atomic_init(&application.first_request_started, false); + application.request_observed_fd = ready_fd; + application.session_cancel_fd = cancel_fd; + cbm_daemon_runtime_application_callbacks_t callbacks = { + .context = &application, + .session_open = frontend_eof_application_session_open, + .request = frontend_eof_application_request, + .request_cancel = frontend_eof_application_request_cancel, + .session_cancel = frontend_eof_application_session_cancel, + .session_close = frontend_eof_application_session_close, + }; + cbm_daemon_runtime_service_config_t config = { + .endpoint = endpoint, + .identity = identity, + .conflict_log_path = conflict_log, + .conflict_log_cap_bytes = 64U * 1024U, + .max_clients = 4, + .lease_timeout_ms = 30000, + .request_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .shutdown_timeout_ms = FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + .application = callbacks, + }; + cbm_daemon_runtime_service_t *service = + endpoint ? cbm_daemon_runtime_service_start(&config) : NULL; + const char ready = 'R'; + bool announced = service && write(ready_fd, &ready, 1) == 1; + uint64_t cancellation_deadline = cbm_now_ms() + FRONTEND_BACKPRESSURE_RUNTIME_TIMEOUT_MS; + while (announced && + atomic_load_explicit(&application.session_cancels, memory_order_acquire) == 0 && + cbm_now_ms() < cancellation_deadline) { + cbm_usleep(1000); + } + int target_cancellations = + atomic_load_explicit(&application.session_cancels, memory_order_acquire); + int sentinel_cancellations_before_close = + atomic_load_explicit(&application.first_session_cancels, memory_order_acquire); + int target_session_cancellations = + atomic_load_explicit(&application.second_session_cancels, memory_order_acquire); + bool target_cancelled_exactly_once = announced && target_cancellations == 1 && + sentinel_cancellations_before_close == 0 && + target_session_cancellations == 1; + const char checkpoint = target_cancelled_exactly_once ? 'T' : 'E'; + bool checkpoint_written = write(ready_fd, &checkpoint, 1) == 1; + char control = '\0'; + bool shutdown_released = + checkpoint_written && + frontend_test_read_byte(control_fd, &control, + cbm_now_ms() + FRONTEND_BACKPRESSURE_RUNTIME_TIMEOUT_MS) && + control == 'S'; + int final_cancellations = + atomic_load_explicit(&application.session_cancels, memory_order_acquire); + int sentinel_session_cancellations = + atomic_load_explicit(&application.first_session_cancels, memory_order_acquire); + int final_target_session_cancellations = + atomic_load_explicit(&application.second_session_cancels, memory_order_acquire); + bool sentinel_closed_exactly_once = final_cancellations == 2 && + sentinel_session_cancellations == 1 && + final_target_session_cancellations == 1; + bool stopped = + service && cbm_daemon_runtime_service_stop(service, FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS); + bool exited = stopped && cbm_daemon_runtime_service_wait_exited(service, 0); + bool freed = exited && cbm_daemon_runtime_service_free(service); + cbm_daemon_ipc_endpoint_free(endpoint); + const char done = target_cancelled_exactly_once && shutdown_released && + sentinel_closed_exactly_once && stopped && exited && freed + ? 'D' + : 'E'; + bool finished = write(ready_fd, &done, 1) == 1; + (void)close(ready_fd); + (void)close(cancel_fd); + (void)close(control_fd); + return announced && target_cancelled_exactly_once && checkpoint_written && shutdown_released && + sentinel_closed_exactly_once && stopped && exited && freed && finished + ? 0 + : 80; +} + +static int frontend_backpressure_frontend_run(const char *parent, int input_fd, int input_write_fd, + int output_fd, + const char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE], + bool keep_input_open) { + (void)alarm(FRONTEND_BACKPRESSURE_FRONTEND_TIMEOUT_S); + char key[CBM_DAEMON_KEY_SIZE]; + cbm_daemon_build_identity_t identity = {0}; + cbm_daemon_ipc_endpoint_t *endpoint = + cbm_daemon_rendezvous_key(key) && frontend_backpressure_identity(&identity, build) + ? cbm_daemon_ipc_endpoint_new(key, parent) + : NULL; + cbm_version_cohort_manager_t *manager = + endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL; + cbm_version_cohort_lease_t *participant = NULL; + cbm_daemon_conflict_t conflict; + cbm_version_cohort_status_t admitted = + manager ? cbm_version_cohort_acquire(manager, &identity, cbm_now_ms() + 2000U, &participant, + &conflict) + : CBM_VERSION_COHORT_IO; + cbm_daemon_runtime_connect_result_t connect_result = {0}; + cbm_daemon_runtime_client_t *client = + admitted == CBM_VERSION_COHORT_OK + ? cbm_daemon_runtime_client_connect( + endpoint, &identity, FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, &connect_result) + : NULL; + FILE *input = client ? fdopen(input_fd, "rb") : NULL; + FILE *output = input ? fdopen(output_fd, "wb") : NULL; + int input_hold_fd = keep_input_open && output ? dup(input_write_fd) : -1; + frontend_backpressure_writer_t writer = { + .fd = input_write_fd, + }; + atomic_init(&writer.finished, false); + atomic_init(&writer.succeeded, false); + cbm_thread_t writer_thread; + bool writer_started = + output && (!keep_input_open || input_hold_fd >= 0) && + cbm_thread_create(&writer_thread, 0, frontend_backpressure_writer, &writer) == 0; + if (!writer_started) { + if (client) { + (void)cbm_daemon_runtime_client_close(client, FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS); + } + if (input) { + (void)fclose(input); + } else { + (void)close(input_fd); + } + if (output) { + (void)fclose(output); + } else { + (void)close(output_fd); + } + (void)close(input_write_fd); + if (input_hold_fd >= 0) { + (void)close(input_hold_fd); + } + frontend_test_release_lease(&participant); + if (manager) { + (void)cbm_version_cohort_manager_free(&manager); + } + cbm_daemon_ipc_endpoint_free(endpoint); + return 81; + } + + int result = cbm_daemon_frontend_mcp_run(client, manager, input, output); + bool joined = cbm_thread_join(&writer_thread) == 0; + bool writer_ok = atomic_load_explicit(&writer.finished, memory_order_acquire) && + atomic_load_explicit(&writer.succeeded, memory_order_acquire); + bool input_closed = fclose(input) == 0; + bool output_closed = fclose(output) == 0; + bool input_hold_closed = input_hold_fd < 0 || close(input_hold_fd) == 0; + frontend_test_release_lease(&participant); + bool manager_closed = cbm_version_cohort_manager_free(&manager) == CBM_PRIVATE_FILE_LOCK_OK; + cbm_daemon_ipc_endpoint_free(endpoint); + return result < 0 && joined && writer_ok && input_closed && output_closed && + input_hold_closed && manager_closed + ? 47 + : 82; +} + +static bool frontend_backpressure_run_isolated(bool maintenance) { + char parent[FRONTEND_TEST_PATH_CAP]; + char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; + int path_written = snprintf(parent, sizeof(parent), + maintenance ? "%s/cbm-frontend-maintenance-backpressure-XXXXXX" + : "%s/cbm-frontend-backpressure-XXXXXX", + cbm_tmpdir()); + int ready_pipe[2] = {-1, -1}; + int cancel_pipe[2] = {-1, -1}; + int control_pipe[2] = {-1, -1}; + bool directory_ready = + path_written > 0 && path_written < (int)sizeof(parent) && cbm_mkdtemp(parent); + bool identity_ready = + directory_ready && cbm_daemon_runtime_process_build_fingerprint((uint64_t)getpid(), build); + bool prepared = identity_ready && pipe(ready_pipe) == 0 && pipe(cancel_pipe) == 0 && + pipe(control_pipe) == 0; + if (!prepared) { + for (size_t index = 0; index < 2; index++) { + if (ready_pipe[index] >= 0) { + (void)close(ready_pipe[index]); + } + if (cancel_pipe[index] >= 0) { + (void)close(cancel_pipe[index]); + } + if (control_pipe[index] >= 0) { + (void)close(control_pipe[index]); + } + } + if (directory_ready) { + (void)th_rmtree(parent); + } + return false; + } + pid_t daemon = prepared ? fork() : -1; + if (daemon == 0) { + (void)signal(SIGALRM, SIG_DFL); + (void)close(ready_pipe[0]); + (void)close(cancel_pipe[0]); + (void)close(control_pipe[1]); + _exit(frontend_backpressure_daemon_run(parent, ready_pipe[1], cancel_pipe[1], + control_pipe[0], build)); + } + if (prepared) { + (void)close(ready_pipe[1]); + (void)close(cancel_pipe[1]); + (void)close(control_pipe[0]); + } + char announced_marker = '\0'; + bool announced_read = daemon > 0 && frontend_test_read_byte(ready_pipe[0], &announced_marker, + cbm_now_ms() + 10000U); + bool announced = announced_read && announced_marker == 'R'; + + /* This raw runtime client intentionally owns no cohort lease. It is a + * sentinel for session isolation: maintenance must remove the target + * frontend without touching this independently authenticated session. Its + * completed admission precedes the target fork, so the daemon fixture can + * attribute session-cancel callbacks to sentinel index 0 and target index + * 1 rather than relying on a global callback count. */ + char sentinel_key[CBM_DAEMON_KEY_SIZE]; + cbm_daemon_build_identity_t sentinel_identity = {0}; + cbm_daemon_ipc_endpoint_t *sentinel_endpoint = + announced && cbm_daemon_rendezvous_key(sentinel_key) && + frontend_backpressure_identity(&sentinel_identity, build) + ? cbm_daemon_ipc_endpoint_new(sentinel_key, parent) + : NULL; + cbm_daemon_runtime_connect_result_t sentinel_connect = {0}; + cbm_daemon_runtime_client_t *sentinel = + sentinel_endpoint ? cbm_daemon_runtime_client_connect(sentinel_endpoint, &sentinel_identity, + FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS, + &sentinel_connect) + : NULL; + bool sentinel_ready = + sentinel && sentinel_connect.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED; + if (!sentinel_ready && daemon > 0) { + (void)kill(daemon, SIGKILL); + } + + int input_pipe[2] = {-1, -1}; + int output_pipe[2] = {-1, -1}; + bool input_ready = sentinel_ready && pipe(input_pipe) == 0; + bool frontend_pipes = input_ready && pipe(output_pipe) == 0; + if (input_ready && !frontend_pipes) { + (void)close(input_pipe[0]); + (void)close(input_pipe[1]); + input_pipe[0] = -1; + input_pipe[1] = -1; + } + pid_t frontend = frontend_pipes ? fork() : -1; + if (frontend == 0) { + (void)signal(SIGALRM, SIG_DFL); + (void)close(output_pipe[0]); + (void)close(ready_pipe[0]); + (void)close(cancel_pipe[0]); + (void)close(control_pipe[1]); + _exit(frontend_backpressure_frontend_run(parent, input_pipe[0], input_pipe[1], + output_pipe[1], build, maintenance)); + } + if (frontend_pipes) { + (void)close(input_pipe[0]); + (void)close(input_pipe[1]); + (void)close(output_pipe[1]); + } + + char request_marker = '\0'; + bool request_read = frontend > 0 && frontend_test_read_byte(ready_pipe[0], &request_marker, + cbm_now_ms() + 4000U); + bool request_observed = request_read && request_marker == 'Q'; + char output_marker = '\0'; + bool output_observed = + !maintenance || (request_observed && frontend_test_read_byte(output_pipe[0], &output_marker, + cbm_now_ms() + 4000U)); + + cbm_daemon_ipc_endpoint_t *mutation_endpoint = NULL; + cbm_version_cohort_manager_t *mutation_manager = NULL; + cbm_version_cohort_lease_t *mutation = NULL; + cbm_version_cohort_quiesce_result_t quiesce = CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + cbm_version_cohort_status_t mutation_status = CBM_VERSION_COHORT_OK; + if (maintenance) { + char mutation_key[CBM_DAEMON_KEY_SIZE]; + mutation_endpoint = output_observed && cbm_daemon_rendezvous_key(mutation_key) + ? cbm_daemon_ipc_endpoint_new(mutation_key, parent) + : NULL; + mutation_manager = + mutation_endpoint ? cbm_version_cohort_manager_new(mutation_endpoint) : NULL; + mutation_status = mutation_manager + ? cbm_version_cohort_reserve_for_mutation( + mutation_manager, cbm_now_ms() + 8000U, + frontend_test_quiesce_requested, NULL, &quiesce, &mutation) + : CBM_VERSION_COHORT_IO; + if (mutation_status != CBM_VERSION_COHORT_OK && frontend > 0) { + (void)kill(frontend, SIGKILL); + } + } + + int frontend_status = 0; + pid_t frontend_waited; + if (frontend > 0) { + do { + frontend_waited = waitpid(frontend, &frontend_status, 0); + } while (frontend_waited < 0 && errno == EINTR); + } else { + frontend_waited = -1; + } + char cancel_marker = '\0'; + bool cancel_read = request_observed && frontend_test_read_byte(cancel_pipe[0], &cancel_marker, + cbm_now_ms() + 4000U); + bool cancelled = cancel_read && cancel_marker == 'C'; + char checkpoint_marker = '\0'; + bool checkpoint_read = + frontend_test_read_byte(ready_pipe[0], &checkpoint_marker, cbm_now_ms() + 4000U); + bool target_cancelled_exactly_once = checkpoint_read && checkpoint_marker == 'T'; + bool sentinel_usable = + cancelled && target_cancelled_exactly_once && sentinel_ready && + cbm_daemon_runtime_client_heartbeat(sentinel, FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS); + bool sentinel_closed = + sentinel && cbm_daemon_runtime_client_close(sentinel, FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS); + sentinel = NULL; + cbm_daemon_ipc_endpoint_free(sentinel_endpoint); + sentinel_endpoint = NULL; + const char shutdown = 'S'; + bool shutdown_sent = checkpoint_read && write(control_pipe[1], &shutdown, 1) == 1; + if (control_pipe[1] >= 0) { + (void)close(control_pipe[1]); + control_pipe[1] = -1; + } + char done_marker = '\0'; + bool done_read = + shutdown_sent && frontend_test_read_byte(ready_pipe[0], &done_marker, cbm_now_ms() + 4000U); + bool daemon_done = done_read && done_marker == 'D'; + if (!daemon_done && daemon > 0) { + (void)kill(daemon, SIGKILL); + } + int daemon_status = 0; + pid_t daemon_waited; + do { + daemon_waited = daemon > 0 ? waitpid(daemon, &daemon_status, 0) : -1; + } while (daemon_waited < 0 && errno == EINTR); + + uint64_t cleanup_now = cbm_now_ms(); + uint64_t cleanup_deadline = cleanup_now > UINT64_MAX - FRONTEND_BACKPRESSURE_CLEANUP_TIMEOUT_MS + ? UINT64_MAX + : cleanup_now + FRONTEND_BACKPRESSURE_CLEANUP_TIMEOUT_MS; + bool mutation_released = frontend_test_release_lease_until(&mutation, cleanup_deadline); + while (mutation_released && mutation_manager && cbm_now_ms() < cleanup_deadline && + cbm_version_cohort_manager_free(&mutation_manager) != CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + bool mutation_manager_closed = mutation_manager == NULL; + cbm_daemon_ipc_endpoint_free(mutation_endpoint); + + if (output_pipe[0] >= 0) { + (void)close(output_pipe[0]); + } + if (ready_pipe[0] >= 0) { + (void)close(ready_pipe[0]); + } + if (cancel_pipe[0] >= 0) { + (void)close(cancel_pipe[0]); + } + if (control_pipe[1] >= 0) { + (void)close(control_pipe[1]); + } + bool cleaned = prepared && th_rmtree(parent) == 0; + bool frontend_exited_boundedly = frontend_waited == frontend && WIFEXITED(frontend_status) && + (maintenance ? WEXITSTATUS(frontend_status) == EXIT_SUCCESS + : (WEXITSTATUS(frontend_status) == EXIT_FAILURE || + WEXITSTATUS(frontend_status) == 47)); + bool daemon_clean = + daemon_waited == daemon && WIFEXITED(daemon_status) && WEXITSTATUS(daemon_status) == 0; + bool maintenance_completed = + !maintenance || (output_observed && mutation_status == CBM_VERSION_COHORT_OK && + quiesce == CBM_VERSION_COHORT_QUIESCE_REQUESTED); + bool passed = prepared && announced && sentinel_ready && frontend_pipes && frontend > 0 && + request_observed && maintenance_completed && frontend_exited_boundedly && + cancelled && target_cancelled_exactly_once && sentinel_usable && + sentinel_closed && shutdown_sent && daemon_done && daemon_clean && + mutation_released && mutation_manager_closed && cleaned; + if (!passed) { + bool frontend_reaped = frontend > 0 && frontend_waited == frontend; + bool daemon_reaped = daemon > 0 && daemon_waited == daemon; + int frontend_exit = + frontend_reaped && WIFEXITED(frontend_status) ? WEXITSTATUS(frontend_status) : -1; + int frontend_signal = + frontend_reaped && WIFSIGNALED(frontend_status) ? WTERMSIG(frontend_status) : 0; + int daemon_exit = + daemon_reaped && WIFEXITED(daemon_status) ? WEXITSTATUS(daemon_status) : -1; + int daemon_signal = + daemon_reaped && WIFSIGNALED(daemon_status) ? WTERMSIG(daemon_status) : 0; + fprintf(stderr, + "frontend backpressure fixture failed: maintenance=%d prepared=%d daemon_pid=%ld " + "announced=%d announced_marker=0x%02x sentinel_ready=%d frontend_pipes=%d " + "frontend_pid=%ld frontend_reaped=%d frontend_status=0x%x " + "frontend_exit=%d frontend_signal=%d request_read=%d " + "request_marker=0x%02x output_observed=%d output_marker=0x%02x " + "mutation_status=%d quiesce=%d cancel_read=%d cancel_marker=0x%02x " + "checkpoint_read=%d checkpoint_marker=0x%02x sentinel_usable=%d " + "sentinel_closed=%d shutdown_sent=%d " + "done_read=%d done_marker=0x%02x daemon_reaped=%d " + "daemon_status=0x%x daemon_exit=%d daemon_signal=%d " + "mutation_released=%d mutation_manager_closed=%d cleaned=%d\n", + maintenance, prepared, (long)daemon, announced, + (unsigned int)(unsigned char)announced_marker, sentinel_ready, frontend_pipes, + (long)frontend, frontend_reaped, frontend_status, frontend_exit, frontend_signal, + request_read, (unsigned int)(unsigned char)request_marker, output_observed, + (unsigned int)(unsigned char)output_marker, mutation_status, quiesce, cancel_read, + (unsigned int)(unsigned char)cancel_marker, checkpoint_read, + (unsigned int)(unsigned char)checkpoint_marker, sentinel_usable, sentinel_closed, + shutdown_sent, done_read, (unsigned int)(unsigned char)done_marker, daemon_reaped, + daemon_status, daemon_exit, daemon_signal, mutation_released, + mutation_manager_closed, cleaned); + } + return passed; +} + +static cbm_version_cohort_quiesce_result_t frontend_test_quiesce_requested(void *context) { (void)context; return CBM_VERSION_COHORT_QUIESCE_REQUESTED; } @@ -161,11 +1021,11 @@ TEST(daemon_frontend_correlates_cancellation_to_exact_request) { "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/cancelled\"," "\"params\":{}}", 7, NULL)); - ASSERT_FALSE(cbm_daemon_frontend_cancellation_matches_request( - "{\"jsonrpc\":\"2.0\",\"id\":9," - "\"method\":\"notifications/cancelled\"," - "\"params\":{\"requestId\":7}}", - 7, NULL)); + ASSERT_FALSE( + cbm_daemon_frontend_cancellation_matches_request("{\"jsonrpc\":\"2.0\",\"id\":9," + "\"method\":\"notifications/cancelled\"," + "\"params\":{\"requestId\":7}}", + 7, NULL)); PASS(); } @@ -221,13 +1081,10 @@ TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked) { cbm_daemon_conflict_t conflict; cbm_daemon_build_identity_t identity = frontend_test_identity(); cbm_version_cohort_status_t admitted = - manager ? cbm_version_cohort_acquire( - manager, &identity, cbm_now_ms() + 2000U, - &participant, &conflict) + manager ? cbm_version_cohort_acquire(manager, &identity, cbm_now_ms() + 2000U, + &participant, &conflict) : CBM_VERSION_COHORT_IO; - FILE *input = admitted == CBM_VERSION_COHORT_OK - ? fdopen(input_pipe[0], "rb") - : NULL; + FILE *input = admitted == CBM_VERSION_COHORT_OK ? fdopen(input_pipe[0], "rb") : NULL; FILE *output = input ? tmpfile() : NULL; const char ready = 'R'; bool announced = output && write(ready_pipe[1], &ready, 1) == 1; @@ -235,9 +1092,8 @@ TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked) { if (!announced) { _exit(70); } - int result = cbm_daemon_frontend_mcp_run( - (cbm_daemon_runtime_client_t *)(uintptr_t)1, manager, input, - output); + int result = cbm_daemon_frontend_mcp_run((cbm_daemon_runtime_client_t *)(uintptr_t)1, + manager, input, output); (void)result; _exit(71); } @@ -246,16 +1102,13 @@ TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked) { (void)close(input_pipe[0]); (void)close(ready_pipe[1]); } - bool announced = child > 0 && frontend_test_wait_byte( - ready_pipe[0], 'R', cbm_now_ms() + 2000U); + bool announced = child > 0 && frontend_test_wait_byte(ready_pipe[0], 'R', cbm_now_ms() + 2000U); cbm_version_cohort_lease_t *mutation = NULL; - cbm_version_cohort_quiesce_result_t quiesce = - CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + cbm_version_cohort_quiesce_result_t quiesce = CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; cbm_version_cohort_status_t status = - announced ? cbm_version_cohort_reserve_for_mutation( - fixture.manager, cbm_now_ms() + 3000U, - frontend_test_quiesce_requested, NULL, &quiesce, - &mutation) + announced ? cbm_version_cohort_reserve_for_mutation(fixture.manager, cbm_now_ms() + 3000U, + frontend_test_quiesce_requested, NULL, + &quiesce, &mutation) : CBM_VERSION_COHORT_IO; if (status != CBM_VERSION_COHORT_OK && child > 0) { (void)kill(child, SIGKILL); @@ -303,15 +1156,13 @@ TEST(daemon_local_participant_monitor_cancels_then_bounds_active_operation) { cbm_daemon_conflict_t conflict; cbm_daemon_build_identity_t identity = frontend_test_identity(); cbm_version_cohort_status_t admitted = - manager ? cbm_version_cohort_acquire( - manager, &identity, cbm_now_ms() + 2000U, - &participant, &conflict) + manager ? cbm_version_cohort_acquire(manager, &identity, cbm_now_ms() + 2000U, + &participant, &conflict) : CBM_VERSION_COHORT_IO; cbm_daemon_maintenance_monitor_t *monitor = admitted == CBM_VERSION_COHORT_OK - ? cbm_daemon_maintenance_monitor_start( - manager, frontend_test_cancel_active, &cancel_pipe[1], - 37, "test-local-operation") + ? cbm_daemon_maintenance_monitor_start(manager, frontend_test_cancel_active, + &cancel_pipe[1], 37, "test-local-operation") : NULL; const char ready = 'R'; bool announced = monitor && write(ready_pipe[1], &ready, 1) == 1; @@ -328,20 +1179,16 @@ TEST(daemon_local_participant_monitor_cancels_then_bounds_active_operation) { (void)close(ready_pipe[1]); (void)close(cancel_pipe[1]); } - bool announced = child > 0 && frontend_test_wait_byte( - ready_pipe[0], 'R', cbm_now_ms() + 2000U); + bool announced = child > 0 && frontend_test_wait_byte(ready_pipe[0], 'R', cbm_now_ms() + 2000U); cbm_version_cohort_lease_t *mutation = NULL; - cbm_version_cohort_quiesce_result_t quiesce = - CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + cbm_version_cohort_quiesce_result_t quiesce = CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; cbm_version_cohort_status_t status = - announced ? cbm_version_cohort_reserve_for_mutation( - fixture.manager, cbm_now_ms() + 5000U, - frontend_test_quiesce_requested, NULL, &quiesce, - &mutation) + announced ? cbm_version_cohort_reserve_for_mutation(fixture.manager, cbm_now_ms() + 5000U, + frontend_test_quiesce_requested, NULL, + &quiesce, &mutation) : CBM_VERSION_COHORT_IO; bool cancelled = status == CBM_VERSION_COHORT_OK && - frontend_test_wait_byte(cancel_pipe[0], 'C', - cbm_now_ms() + 1000U); + frontend_test_wait_byte(cancel_pipe[0], 'C', cbm_now_ms() + 1000U); if (status != CBM_VERSION_COHORT_OK && child > 0) { (void)kill(child, SIGKILL); } @@ -386,17 +1233,15 @@ TEST(daemon_local_participant_monitor_allows_supervisor_containment_window) { cbm_daemon_conflict_t conflict; cbm_daemon_build_identity_t identity = frontend_test_identity(); cbm_version_cohort_status_t admitted = - manager ? cbm_version_cohort_acquire( - manager, &identity, cbm_now_ms() + 2000U, - &participant, &conflict) + manager ? cbm_version_cohort_acquire(manager, &identity, cbm_now_ms() + 2000U, + &participant, &conflict) : CBM_VERSION_COHORT_IO; atomic_bool cancelled; atomic_init(&cancelled, false); cbm_daemon_maintenance_monitor_t *monitor = admitted == CBM_VERSION_COHORT_OK - ? cbm_daemon_maintenance_monitor_start( - manager, frontend_test_cancel_and_mark, &cancelled, - 38, "test-supervisor-window") + ? cbm_daemon_maintenance_monitor_start(manager, frontend_test_cancel_and_mark, + &cancelled, 38, "test-supervisor-window") : NULL; const char ready = 'R'; bool announced = monitor && write(ready_pipe[1], &ready, 1) == 1; @@ -412,9 +1257,7 @@ TEST(daemon_local_participant_monitor_allows_supervisor_containment_window) { cbm_usleep(2100U * 1000U); bool stopped = cbm_daemon_maintenance_monitor_stop(&monitor); frontend_test_release_lease(&participant); - while (manager && - cbm_version_cohort_manager_free(&manager) != - CBM_PRIVATE_FILE_LOCK_OK) { + while (manager && cbm_version_cohort_manager_free(&manager) != CBM_PRIVATE_FILE_LOCK_OK) { cbm_usleep(1000); } cbm_daemon_ipc_endpoint_free(endpoint); @@ -424,16 +1267,13 @@ TEST(daemon_local_participant_monitor_allows_supervisor_containment_window) { if (pipe_ready) { (void)close(ready_pipe[1]); } - bool announced = child > 0 && frontend_test_wait_byte( - ready_pipe[0], 'R', cbm_now_ms() + 2000U); + bool announced = child > 0 && frontend_test_wait_byte(ready_pipe[0], 'R', cbm_now_ms() + 2000U); cbm_version_cohort_lease_t *mutation = NULL; - cbm_version_cohort_quiesce_result_t quiesce = - CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + cbm_version_cohort_quiesce_result_t quiesce = CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; cbm_version_cohort_status_t status = - announced ? cbm_version_cohort_reserve_for_mutation( - fixture.manager, cbm_now_ms() + 5000U, - frontend_test_quiesce_requested, NULL, &quiesce, - &mutation) + announced ? cbm_version_cohort_reserve_for_mutation(fixture.manager, cbm_now_ms() + 5000U, + frontend_test_quiesce_requested, NULL, + &quiesce, &mutation) : CBM_VERSION_COHORT_IO; if (status != CBM_VERSION_COHORT_OK && child > 0) { (void)kill(child, SIGKILL); @@ -456,14 +1296,50 @@ TEST(daemon_local_participant_monitor_allows_supervisor_containment_window) { ASSERT_EQ(WEXITSTATUS(child_status), 0); PASS(); } + +/* RED: once a long request is active, a buffered writer can fill the bounded + * frontend queue and close its pipe. Waiting indefinitely for queue capacity + * prevents the sole reader from observing that already-pending EOF, so neither + * the request nor its daemon session is cancelled. Overload must instead fail + * the frontend promptly and close/cancel the authenticated session. */ +TEST(daemon_frontend_over_capacity_input_cannot_hide_eof_behind_active_request) { + ASSERT_TRUE(frontend_eof_run_isolated("overflow", true)); + PASS(); +} + +/* Clean EOF is the normal MCP-session ownership boundary. Accepted work gets a + * bounded drain opportunity; work still active at the deadline is cancelled + * with that exact session, while the frontend itself reports a clean close. */ +TEST(daemon_frontend_eof_drain_timeout_cancels_and_returns_success) { + ASSERT_TRUE(frontend_eof_run_isolated("timeout", false)); + PASS(); +} + +/* A daemon response can finish its IPC exchange and then block forever while + * writing to an agent that stopped reading stdout. EOF must still bound the + * thin frontend process. Keep the daemon in a separate child so the frontend's + * terminal watchdog closes a real kernel connection; the daemon must observe + * that close, cancel the exact session, and shut down normally. */ +TEST(daemon_frontend_stdout_backpressure_eof_fail_stops_and_cancels_session) { + ASSERT_TRUE(frontend_backpressure_run_isolated(false)); + PASS(); +} + +/* Maintenance must not depend on either MCP stdio thread making progress. + * Keep stdin open, fill stdout with a completed daemon response, then publish + * activation intent. The independent monitor must release the cohort lease and + * kernel session within its bound; the daemon must cancel that exact session. */ +TEST(daemon_frontend_stdout_backpressure_maintenance_stops_and_cancels_session) { + ASSERT_TRUE(frontend_backpressure_run_isolated(true)); + PASS(); +} #endif TEST(daemon_local_participant_monitor_joins_before_manager_teardown) { frontend_maintenance_fixture_t fixture; ASSERT_TRUE(frontend_maintenance_fixture_start(&fixture, "monitor-join")); - cbm_daemon_maintenance_monitor_t *monitor = - cbm_daemon_maintenance_monitor_start( - fixture.manager, NULL, NULL, EXIT_FAILURE, "test-idle-command"); + cbm_daemon_maintenance_monitor_t *monitor = cbm_daemon_maintenance_monitor_start( + fixture.manager, NULL, NULL, EXIT_FAILURE, "test-idle-command"); bool started = monitor != NULL; bool stopped = started && cbm_daemon_maintenance_monitor_stop(&monitor); bool consumed = monitor == NULL; @@ -484,6 +1360,10 @@ SUITE(daemon_frontend) { RUN_TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked); RUN_TEST(daemon_local_participant_monitor_cancels_then_bounds_active_operation); RUN_TEST(daemon_local_participant_monitor_allows_supervisor_containment_window); + RUN_TEST(daemon_frontend_over_capacity_input_cannot_hide_eof_behind_active_request); + RUN_TEST(daemon_frontend_eof_drain_timeout_cancels_and_returns_success); + RUN_TEST(daemon_frontend_stdout_backpressure_eof_fail_stops_and_cancels_session); + RUN_TEST(daemon_frontend_stdout_backpressure_maintenance_stops_and_cancels_session); #endif RUN_TEST(daemon_local_participant_monitor_joins_before_manager_teardown); } diff --git a/tests/test_daemon_ipc.c b/tests/test_daemon_ipc.c index ace3a348f..2bc2b074d 100644 --- a/tests/test_daemon_ipc.c +++ b/tests/test_daemon_ipc.c @@ -28,6 +28,7 @@ #define WIN32_LEAN_AND_MEAN #endif #include "foundation/win_utf8.h" +#include #include #include #ifndef PIPE_REJECT_REMOTE_CLIENTS @@ -189,7 +190,7 @@ static void ipc_test_remove_tree(const char *runtime_dir, const char *parent) { #ifdef __APPLE__ /* Return 1 when installed, 0 only when the backing filesystem does not support * Darwin extended ACLs, and -1 for every other fixture failure. */ -static int ipc_test_macos_set_mutating_acl(const char *path, bool inheritable) { +static int ipc_test_macos_set_directory_acl(const char *path, bool inheritable, acl_tag_t tag) { if (!path || path[0] == '\0') { errno = EINVAL; return -1; @@ -200,30 +201,28 @@ static int ipc_test_macos_set_mutating_acl(const char *path, bool inheritable) { acl_permset_t permissions = NULL; acl_flagset_t flags = NULL; bool built = acl && acl_create_entry(&acl, &entry) == 0 && entry && - acl_set_tag_type(entry, ACL_EXTENDED_ALLOW) == 0; + (tag == ACL_EXTENDED_ALLOW || tag == ACL_EXTENDED_DENY) && + acl_set_tag_type(entry, tag) == 0; /* wheel (0) and daemon (1) are stable real macOS system groups. Use the * one that is not the process's effective group so the fixture grants a * distinct principal directory-mutation rights. */ gid_t foreign_group = getegid() == (gid_t)0 ? (gid_t)1 : (gid_t)0; uuid_t foreign_group_uuid = {0}; - int membership_result = - built ? mbr_gid_to_uuid(foreign_group, foreign_group_uuid) : 0; + int membership_result = built ? mbr_gid_to_uuid(foreign_group, foreign_group_uuid) : 0; if (built && membership_result != 0) { errno = membership_result; built = false; } built = built && acl_set_qualifier(entry, foreign_group_uuid) == 0 && acl_get_permset(entry, &permissions) == 0 && permissions && - acl_clear_perms(permissions) == 0 && - acl_add_perm(permissions, ACL_ADD_FILE) == 0 && + acl_clear_perms(permissions) == 0 && acl_add_perm(permissions, ACL_ADD_FILE) == 0 && acl_add_perm(permissions, ACL_ADD_SUBDIRECTORY) == 0 && acl_add_perm(permissions, ACL_DELETE_CHILD) == 0 && acl_set_permset(entry, permissions) == 0; if (built && inheritable) { - built = acl_get_flagset_np(entry, &flags) == 0 && flags && - acl_clear_flags_np(flags) == 0 && + built = acl_get_flagset_np(entry, &flags) == 0 && flags && acl_clear_flags_np(flags) == 0 && acl_add_flag_np(flags, ACL_ENTRY_FILE_INHERIT) == 0 && acl_add_flag_np(flags, ACL_ENTRY_DIRECTORY_INHERIT) == 0 && acl_set_flagset_np(entry, flags) == 0; @@ -244,6 +243,14 @@ static int ipc_test_macos_set_mutating_acl(const char *path, bool inheritable) { return saved_error == ENOTSUP || saved_error == EOPNOTSUPP ? 0 : -1; } +static int ipc_test_macos_set_mutating_acl(const char *path, bool inheritable) { + return ipc_test_macos_set_directory_acl(path, inheritable, ACL_EXTENDED_ALLOW); +} + +static int ipc_test_macos_set_deny_acl(const char *path, bool inheritable) { + return ipc_test_macos_set_directory_acl(path, inheritable, ACL_EXTENDED_DENY); +} + /* An ACL-less existing path is reported by Darwin either as an empty ACL or, * on some filesystems, ENOENT. Validate the path first so ENOENT cannot hide * a missing runtime directory. */ @@ -272,9 +279,7 @@ static int ipc_test_macos_extended_acl_entry_count(const char *path) { } int entry_error = errno; int free_result = acl_free(acl); - return entry_result == -1 && entry_error == EINVAL && free_result == 0 - ? count - : -1; + return entry_result == -1 && entry_error == EINVAL && free_result == 0 ? count : -1; } #endif @@ -284,6 +289,42 @@ typedef struct { bool present; } ipc_test_win_env_t; +static bool ipc_test_win_grant_everyone_mutation(const char *path) { + wchar_t *wide_path = cbm_utf8_to_wide(path); + BYTE world_buffer[SECURITY_MAX_SID_SIZE]; + DWORD world_size = sizeof(world_buffer); + PACL existing = NULL; + PACL replacement = NULL; + PSECURITY_DESCRIPTOR descriptor = NULL; + bool ok = wide_path && CreateWellKnownSid(WinWorldSid, NULL, world_buffer, &world_size) != 0 && + GetNamedSecurityInfoW(wide_path, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, NULL, + NULL, &existing, NULL, &descriptor) == ERROR_SUCCESS; + EXPLICIT_ACCESSW access; + memset(&access, 0, sizeof(access)); + access.grfAccessPermissions = FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | + DELETE | WRITE_DAC | WRITE_OWNER; + access.grfAccessMode = GRANT_ACCESS; + access.grfInheritance = NO_INHERITANCE; + access.Trustee.TrusteeForm = TRUSTEE_IS_SID; + access.Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP; + access.Trustee.ptstrName = (LPWSTR)world_buffer; + if (ok) { + ok = SetEntriesInAclW(1U, &access, existing, &replacement) == ERROR_SUCCESS && + replacement && + SetNamedSecurityInfoW(wide_path, SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + NULL, NULL, replacement, NULL) == ERROR_SUCCESS; + } + if (replacement) { + (void)LocalFree(replacement); + } + if (descriptor) { + (void)LocalFree(descriptor); + } + free(wide_path); + return ok; +} + static bool ipc_test_win_env_capture(const wchar_t *name, ipc_test_win_env_t *saved) { if (!name || !saved) { return false; @@ -323,12 +364,10 @@ static bool ipc_test_win_temp_set(const char *path) { return changed; } -static int ipc_test_win_lock_child(const char *kind, const char *key, - const char *parent) { +static int ipc_test_win_lock_child(const char *kind, const char *key, const char *parent) { char self[MAX_PATH]; DWORD self_length = GetModuleFileNameA(NULL, self, sizeof(self)); - if (!kind || !key || !parent || self_length == 0 || - self_length >= sizeof(self)) { + if (!kind || !key || !parent || self_length == 0 || self_length >= sizeof(self)) { return -1; } const char *const argv[] = { @@ -351,10 +390,8 @@ typedef struct { static void *ipc_test_win_startup_call(void *opaque) { ipc_test_win_startup_call_t *call = opaque; cbm_daemon_ipc_startup_lock_t *startup = NULL; - call->result = cbm_daemon_ipc_startup_lock_try_acquire( - call->endpoint, &startup); - if (call->result == 1 && - !cbm_daemon_ipc_startup_lock_prepare_handoff(startup)) { + call->result = cbm_daemon_ipc_startup_lock_try_acquire(call->endpoint, &startup); + if (call->result == 1 && !cbm_daemon_ipc_startup_lock_prepare_handoff(startup)) { call->result = 0; } cbm_daemon_ipc_startup_lock_release(&startup); @@ -363,18 +400,15 @@ static void *ipc_test_win_startup_call(void *opaque) { static void ipc_test_win_lock_release(cbm_private_file_lock_t **lock_io) { while (lock_io && *lock_io && - cbm_private_file_lock_release(lock_io) != - CBM_PRIVATE_FILE_LOCK_OK) { + cbm_private_file_lock_release(lock_io) != CBM_PRIVATE_FILE_LOCK_OK) { cbm_usleep(1000); } } -static bool ipc_test_win_lock_busy(cbm_private_lock_directory_t *directory, - const char *base_name) { +static bool ipc_test_win_lock_busy(cbm_private_lock_directory_t *directory, const char *base_name) { cbm_private_file_lock_t *probe = NULL; cbm_private_file_lock_status_t status = - cbm_private_file_lock_try_acquire( - directory, base_name, CBM_PRIVATE_FILE_LOCK_SH, &probe); + cbm_private_file_lock_try_acquire(directory, base_name, CBM_PRIVATE_FILE_LOCK_SH, &probe); ipc_test_win_lock_release(&probe); return status == CBM_PRIVATE_FILE_LOCK_BUSY; } @@ -487,16 +521,14 @@ TEST(daemon_ipc_windows_generation_address_binds_account_key_and_nonce) { static const char key[] = "0123456789abcdef"; static const char other_key[] = "fedcba9876543210"; static const uint8_t nonce_a[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE] = { - 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, - 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, - 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, - 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, + 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, + 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, }; static const uint8_t nonce_b[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE] = { - 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, - 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, - 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, - 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, + 0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, + 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, + 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f, }; char address_a[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; char address_same[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; @@ -504,16 +536,16 @@ TEST(daemon_ipc_windows_generation_address_binds_account_key_and_nonce) { char address_other_key[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; char address_other_nonce[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; - bool first = cbm_daemon_ipc_windows_generation_address( - sid_a, sizeof(sid_a), key, nonce_a, address_a); - bool repeated = cbm_daemon_ipc_windows_generation_address( - sid_a, sizeof(sid_a), key, nonce_a, address_same); - bool other_user = cbm_daemon_ipc_windows_generation_address( - sid_b, sizeof(sid_b), key, nonce_a, address_b); - bool changed_key = cbm_daemon_ipc_windows_generation_address( - sid_a, sizeof(sid_a), other_key, nonce_a, address_other_key); - bool changed_nonce = cbm_daemon_ipc_windows_generation_address( - sid_a, sizeof(sid_a), key, nonce_b, address_other_nonce); + bool first = + cbm_daemon_ipc_windows_generation_address(sid_a, sizeof(sid_a), key, nonce_a, address_a); + bool repeated = + cbm_daemon_ipc_windows_generation_address(sid_a, sizeof(sid_a), key, nonce_a, address_same); + bool other_user = + cbm_daemon_ipc_windows_generation_address(sid_b, sizeof(sid_b), key, nonce_a, address_b); + bool changed_key = cbm_daemon_ipc_windows_generation_address(sid_a, sizeof(sid_a), other_key, + nonce_a, address_other_key); + bool changed_nonce = cbm_daemon_ipc_windows_generation_address(sid_a, sizeof(sid_a), key, + nonce_b, address_other_nonce); ASSERT_TRUE(first); ASSERT_TRUE(repeated); @@ -524,10 +556,9 @@ TEST(daemon_ipc_windows_generation_address_binds_account_key_and_nonce) { ASSERT_TRUE(strcmp(address_a, address_b) != 0); ASSERT_TRUE(strcmp(address_a, address_other_key) != 0); ASSERT_TRUE(strcmp(address_a, address_other_nonce) != 0); - ASSERT_STR_EQ(address_a, - "\\\\.\\pipe\\cbm-daemon-" - "e861648d9f8bc786dce31bbb16eda2ab" - "ffa330a770752832ab5f2e4feaa506f1"); + ASSERT_STR_EQ(address_a, "\\\\.\\pipe\\cbm-daemon-" + "e861648d9f8bc786dce31bbb16eda2ab" + "ffa330a770752832ab5f2e4feaa506f1"); ASSERT_TRUE(strstr(address_a, "S-1-") == NULL); ASSERT_TRUE(strstr(address_a, key) == NULL); PASS(); @@ -536,40 +567,33 @@ TEST(daemon_ipc_windows_generation_address_binds_account_key_and_nonce) { TEST(daemon_ipc_windows_legacy_names_are_frozen_for_migration) { char pipe_name[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; char startup_name[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; - bool derived = cbm_daemon_ipc_windows_legacy_names( - "C:\\Users\\Alice\\AppData\\Local", "0123456789abcdef", - pipe_name, startup_name); + bool derived = cbm_daemon_ipc_windows_legacy_names("C:\\Users\\Alice\\AppData\\Local", + "0123456789abcdef", pipe_name, startup_name); ASSERT_TRUE(derived); - ASSERT_STR_EQ(pipe_name, - "\\\\.\\pipe\\cbm-daemon-72380d6ef7f19c0c-" - "0123456789abcdef"); - ASSERT_STR_EQ(startup_name, - "Local\\cbm-daemon-72380d6ef7f19c0c-" - "0123456789abcdef-startup"); + ASSERT_STR_EQ(pipe_name, "\\\\.\\pipe\\cbm-daemon-72380d6ef7f19c0c-" + "0123456789abcdef"); + ASSERT_STR_EQ(startup_name, "Local\\cbm-daemon-72380d6ef7f19c0c-" + "0123456789abcdef-startup"); PASS(); } TEST(daemon_ipc_windows_rendezvous_record_is_exact_and_canonical) { static const uint8_t nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE] = { - 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, - 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, - 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, - 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, + 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, + 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, 0x90, 0x91, 0x92, 0x93, 0x94, 0x95, + 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f, }; - static const char address[] = - "\\\\.\\pipe\\cbm-daemon-" - "0123456789abcdef0123456789abcdef" - "0123456789abcdef0123456789abcdef"; + static const char address[] = "\\\\.\\pipe\\cbm-daemon-" + "0123456789abcdef0123456789abcdef" + "0123456789abcdef0123456789abcdef"; uint8_t record[CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE]; uint8_t decoded_nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE] = {0}; char decoded_address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; - bool encoded = cbm_daemon_ipc_windows_rendezvous_record_encode( - nonce, address, record); + bool encoded = cbm_daemon_ipc_windows_rendezvous_record_encode(nonce, address, record); bool decoded = encoded && cbm_daemon_ipc_windows_rendezvous_record_decode( - record, sizeof(record), decoded_nonce, - decoded_address); + record, sizeof(record), decoded_nonce, decoded_address); ASSERT_TRUE(encoded); ASSERT_TRUE(decoded); ASSERT_TRUE(memcmp(decoded_nonce, nonce, sizeof(nonce)) == 0); @@ -578,22 +602,21 @@ TEST(daemon_ipc_windows_rendezvous_record_is_exact_and_canonical) { uint8_t corrupt[sizeof(record)]; memcpy(corrupt, record, sizeof(corrupt)); corrupt[0] ^= 0xffU; - ASSERT_FALSE(cbm_daemon_ipc_windows_rendezvous_record_decode( - corrupt, sizeof(corrupt), decoded_nonce, decoded_address)); - ASSERT_FALSE(cbm_daemon_ipc_windows_rendezvous_record_decode( - record, sizeof(record) - 1U, decoded_nonce, decoded_address)); + ASSERT_FALSE(cbm_daemon_ipc_windows_rendezvous_record_decode(corrupt, sizeof(corrupt), + decoded_nonce, decoded_address)); + ASSERT_FALSE(cbm_daemon_ipc_windows_rendezvous_record_decode(record, sizeof(record) - 1U, + decoded_nonce, decoded_address)); memcpy(corrupt, record, sizeof(corrupt)); corrupt[sizeof(corrupt) - 1U] = 1U; - ASSERT_FALSE(cbm_daemon_ipc_windows_rendezvous_record_decode( - corrupt, sizeof(corrupt), decoded_nonce, decoded_address)); + ASSERT_FALSE(cbm_daemon_ipc_windows_rendezvous_record_decode(corrupt, sizeof(corrupt), + decoded_nonce, decoded_address)); memcpy(corrupt, record, sizeof(corrupt)); - const size_t address_offset = - 8U + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE; + const size_t address_offset = 8U + CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE; corrupt[address_offset + strlen("\\\\.\\pipe\\cbm-daemon-")] = 'A'; - ASSERT_FALSE(cbm_daemon_ipc_windows_rendezvous_record_decode( - corrupt, sizeof(corrupt), decoded_nonce, decoded_address)); + ASSERT_FALSE(cbm_daemon_ipc_windows_rendezvous_record_decode(corrupt, sizeof(corrupt), + decoded_nonce, decoded_address)); PASS(); } @@ -624,8 +647,7 @@ TEST(daemon_ipc_windows_default_endpoint_ignores_temp_environment) { endpoint_a = cbm_daemon_ipc_endpoint_new(key, NULL); } if (endpoint_a) { - int startup_status = cbm_daemon_ipc_startup_lock_try_acquire( - endpoint_a, &startup); + int startup_status = cbm_daemon_ipc_startup_lock_try_acquire(endpoint_a, &startup); cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; if (startup_status != 1) { @@ -646,8 +668,7 @@ TEST(daemon_ipc_windows_default_endpoint_ignores_temp_environment) { ipc_test_copy_path(address_b, cbm_daemon_ipc_endpoint_address(endpoint_b)); } bool stable = endpoint_a && endpoint_b && strcmp(address_a, address_b) == 0 && - address_a[0] != '\0' && - strcmp(runtime_a, runtime_b) == 0 && + address_a[0] != '\0' && strcmp(runtime_a, runtime_b) == 0 && !ipc_test_path_has_parent(runtime_a, parent_a) && !ipc_test_path_has_parent(runtime_a, parent_b); @@ -673,6 +694,49 @@ TEST(daemon_ipc_windows_default_endpoint_ignores_temp_environment) { PASS(); } +TEST(daemon_ipc_windows_private_directory_rejects_untrusted_ancestor_acl) { + char parent[TEST_PATH_CAP] = {0}; + char unsafe[TEST_PATH_CAP] = {0}; + char cache[TEST_PATH_CAP] = {0}; + bool paths_ok = false; + bool unsafe_created = false; + bool acl_injected = false; + bool rejected = false; + bool cache_absent = false; + + if (ipc_test_parent_new(parent, "win-unsafe-ancestor")) { + int unsafe_written = snprintf(unsafe, sizeof(unsafe), "%s/unsafe", parent); + int cache_written = snprintf(cache, sizeof(cache), "%s/cache", unsafe); + paths_ok = unsafe_written > 0 && unsafe_written < (int)sizeof(unsafe) && + cache_written > 0 && cache_written < (int)sizeof(cache); + } + if (paths_ok) { + unsafe_created = CreateDirectoryA(unsafe, NULL) != 0; + } + if (unsafe_created) { + acl_injected = ipc_test_win_grant_everyone_mutation(unsafe); + } + if (acl_injected) { + rejected = !cbm_daemon_ipc_private_directory_secure(cache); + SetLastError(ERROR_SUCCESS); + DWORD attributes = GetFileAttributesA(cache); + DWORD error = GetLastError(); + cache_absent = attributes == INVALID_FILE_ATTRIBUTES && + (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND); + } + + (void)RemoveDirectoryA(cache); + (void)RemoveDirectoryA(unsafe); + ipc_test_remove_flat_dir(parent); + + ASSERT_TRUE(paths_ok); + ASSERT_TRUE(unsafe_created); + ASSERT_TRUE(acl_injected); + ASSERT_TRUE(rejected); + ASSERT_TRUE(cache_absent); + PASS(); +} + TEST(daemon_ipc_windows_legacy_bridge_covers_handoff_and_lifetime) { static const char key[] = "91a2b3c4d5e6f708"; char parent[TEST_PATH_CAP] = {0}; @@ -688,86 +752,59 @@ TEST(daemon_ipc_windows_legacy_bridge_covers_handoff_and_lifetime) { bool parent_ok = ipc_test_parent_new(parent, "win-legacy-bridge") && ipc_test_full_path(canonical_parent, parent); - bool names_ok = parent_ok && cbm_daemon_ipc_windows_legacy_names( - canonical_parent, key, legacy_pipe, - legacy_startup); + bool names_ok = parent_ok && cbm_daemon_ipc_windows_legacy_names(canonical_parent, key, + legacy_pipe, legacy_startup); if (names_ok) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } - int absent_before = - endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + int absent_before = endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; int startup_status = - endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) - : -1; - int visible_during_startup = - startup ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; - bool handoff = startup && - cbm_daemon_ipc_startup_lock_prepare_handoff(startup); - int visible_during_handoff = - handoff ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; + int visible_during_startup = startup ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + bool handoff = startup && cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + int visible_during_handoff = handoff ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; int participant_status = - handoff ? cbm_daemon_ipc_participant_guard_try_join( - endpoint, &participant) - : -1; - int lifetime_status = - participant_status == 1 - ? cbm_daemon_ipc_lifetime_reservation_try_acquire( - endpoint, &lifetime) - : -1; - int visible_during_lifetime = - lifetime ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + handoff ? cbm_daemon_ipc_participant_guard_try_join(endpoint, &participant) : -1; + int lifetime_status = participant_status == 1 + ? cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &lifetime) + : -1; + int visible_during_lifetime = lifetime ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; cbm_daemon_ipc_lifetime_reservation_release(lifetime); lifetime = NULL; - bool participant_released = - cbm_daemon_ipc_participant_guard_release(&participant); - int absent_after_lifetime = - endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; - - typedef BOOL(WINAPI *ipc_test_initialize_sd_fn)(PSECURITY_DESCRIPTOR, - DWORD); - typedef BOOL(WINAPI *ipc_test_set_dacl_fn)(PSECURITY_DESCRIPTOR, BOOL, - PACL, BOOL); + bool participant_released = cbm_daemon_ipc_participant_guard_release(&participant); + int absent_after_lifetime = endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + + typedef BOOL(WINAPI * ipc_test_initialize_sd_fn)(PSECURITY_DESCRIPTOR, DWORD); + typedef BOOL(WINAPI * ipc_test_set_dacl_fn)(PSECURITY_DESCRIPTOR, BOOL, PACL, BOOL); HMODULE advapi = LoadLibraryW(L"advapi32.dll"); ipc_test_initialize_sd_fn initialize_sd = - advapi ? (ipc_test_initialize_sd_fn)GetProcAddress( - advapi, "InitializeSecurityDescriptor") + advapi ? (ipc_test_initialize_sd_fn)GetProcAddress(advapi, "InitializeSecurityDescriptor") : NULL; ipc_test_set_dacl_fn set_dacl = - advapi ? (ipc_test_set_dacl_fn)GetProcAddress( - advapi, "SetSecurityDescriptorDacl") - : NULL; + advapi ? (ipc_test_set_dacl_fn)GetProcAddress(advapi, "SetSecurityDescriptorDacl") : NULL; SECURITY_DESCRIPTOR unsafe_descriptor; - bool unsafe_descriptor_ok = - initialize_sd && set_dacl && - initialize_sd(&unsafe_descriptor, SECURITY_DESCRIPTOR_REVISION) && - set_dacl(&unsafe_descriptor, TRUE, NULL, FALSE); + bool unsafe_descriptor_ok = initialize_sd && set_dacl && + initialize_sd(&unsafe_descriptor, SECURITY_DESCRIPTOR_REVISION) && + set_dacl(&unsafe_descriptor, TRUE, NULL, FALSE); SECURITY_ATTRIBUTES unsafe_attributes = { .nLength = sizeof(unsafe_attributes), - .lpSecurityDescriptor = unsafe_descriptor_ok ? &unsafe_descriptor - : NULL, + .lpSecurityDescriptor = unsafe_descriptor_ok ? &unsafe_descriptor : NULL, .bInheritHandle = FALSE, }; - wchar_t *legacy_startup_wide = - names_ok ? cbm_utf8_to_wide(legacy_startup) : NULL; - HANDLE unsafe_mutex = - unsafe_descriptor_ok && legacy_startup_wide - ? CreateMutexW(&unsafe_attributes, FALSE, legacy_startup_wide) - : NULL; + wchar_t *legacy_startup_wide = names_ok ? cbm_utf8_to_wide(legacy_startup) : NULL; + HANDLE unsafe_mutex = unsafe_descriptor_ok && legacy_startup_wide + ? CreateMutexW(&unsafe_attributes, FALSE, legacy_startup_wide) + : NULL; free(legacy_startup_wide); - int unsafe_probe = unsafe_mutex - ? cbm_daemon_ipc_legacy_generation_probe(endpoint) - : 0; + int unsafe_probe = unsafe_mutex ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : 0; cbm_daemon_ipc_startup_lock_t *unsafe_startup = NULL; int unsafe_startup_status = - unsafe_mutex ? cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &unsafe_startup) - : 0; + unsafe_mutex ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &unsafe_startup) : 0; cbm_daemon_ipc_startup_lock_release(&unsafe_startup); if (unsafe_mutex) { (void)CloseHandle(unsafe_mutex); @@ -775,27 +812,22 @@ TEST(daemon_ipc_windows_legacy_bridge_covers_handoff_and_lifetime) { if (advapi) { (void)FreeLibrary(advapi); } - int absent_after_unsafe = - endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + int absent_after_unsafe = endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; wchar_t *old_name = names_ok ? cbm_utf8_to_wide(legacy_pipe) : NULL; if (old_name) { - old_pipe = CreateNamedPipeW( - old_name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, - PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | - PIPE_REJECT_REMOTE_CLIENTS, - 1, 4096, 4096, 0, NULL); + old_pipe = CreateNamedPipeW(old_name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | + PIPE_REJECT_REMOTE_CLIENTS, + 1, 4096, 4096, 0, NULL); } free(old_name); int visible_legacy_pipe = - old_pipe != INVALID_HANDLE_VALUE - ? cbm_daemon_ipc_legacy_generation_probe(endpoint) - : -1; + old_pipe != INVALID_HANDLE_VALUE ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; if (old_pipe != INVALID_HANDLE_VALUE) { (void)CloseHandle(old_pipe); } - int absent_after_pipe = - endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + int absent_after_pipe = endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; cbm_daemon_ipc_lifetime_reservation_release(lifetime); (void)cbm_daemon_ipc_participant_guard_release(&participant); @@ -842,62 +874,46 @@ TEST(daemon_ipc_windows_local_transition_atomically_reserves_legacy_pipe) { bool parent_ok = ipc_test_parent_new(parent, "win-local-transition") && ipc_test_full_path(canonical_parent, parent); - bool names_ok = - parent_ok && cbm_daemon_ipc_windows_legacy_names( - canonical_parent, key, legacy_pipe, legacy_startup); + bool names_ok = parent_ok && cbm_daemon_ipc_windows_legacy_names(canonical_parent, key, + legacy_pipe, legacy_startup); if (names_ok) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } int transition_wins_result = - endpoint ? cbm_daemon_ipc_local_transition_try_acquire( - endpoint, &transition_wins) - : -1; - int sentinel_result = - transition_wins_result == 1 - ? cbm_daemon_ipc_local_transition_seal_legacy(transition_wins) - : -1; + endpoint ? cbm_daemon_ipc_local_transition_try_acquire(endpoint, &transition_wins) : -1; + int sentinel_result = transition_wins_result == 1 + ? cbm_daemon_ipc_local_transition_seal_legacy(transition_wins) + : -1; int sentinel_visible = - sentinel_result == 1 - ? cbm_daemon_ipc_legacy_generation_probe(endpoint) - : -1; + sentinel_result == 1 ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; wchar_t *legacy_name = names_ok ? cbm_utf8_to_wide(legacy_pipe) : NULL; if (legacy_name && sentinel_result == 1) { old_after_sentinel = CreateNamedPipeW( legacy_name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, - PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | - PIPE_REJECT_REMOTE_CLIENTS, - 1, 4096, 4096, 0, NULL); + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, 1, 4096, + 4096, 0, NULL); } bool work_begun = - sentinel_result == 1 && - cbm_daemon_ipc_local_transition_begin_work(transition_wins); - bool transition_wins_released = - cbm_daemon_ipc_local_transition_release(&transition_wins); - int absent_after_sentinel = - endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + sentinel_result == 1 && cbm_daemon_ipc_local_transition_begin_work(transition_wins); + bool transition_wins_released = cbm_daemon_ipc_local_transition_release(&transition_wins); + int absent_after_sentinel = endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; if (legacy_name) { old_first = CreateNamedPipeW( legacy_name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, - PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | - PIPE_REJECT_REMOTE_CLIENTS, - 1, 4096, 4096, 0, NULL); + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, 1, 4096, + 4096, 0, NULL); } int legacy_wins_result = old_first != INVALID_HANDLE_VALUE - ? cbm_daemon_ipc_local_transition_try_acquire(endpoint, - &legacy_wins) + ? cbm_daemon_ipc_local_transition_try_acquire(endpoint, &legacy_wins) : -1; int rejected_after_legacy_win = - legacy_wins_result == 1 - ? cbm_daemon_ipc_local_transition_seal_legacy(legacy_wins) - : -1; - bool legacy_wins_released = - cbm_daemon_ipc_local_transition_release(&legacy_wins); + legacy_wins_result == 1 ? cbm_daemon_ipc_local_transition_seal_legacy(legacy_wins) : -1; + bool legacy_wins_released = cbm_daemon_ipc_local_transition_release(&legacy_wins); if (old_after_sentinel != INVALID_HANDLE_VALUE) { (void)CloseHandle(old_after_sentinel); } @@ -905,8 +921,7 @@ TEST(daemon_ipc_windows_local_transition_atomically_reserves_legacy_pipe) { (void)CloseHandle(old_first); } free(legacy_name); - int absent_after_old = - endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + int absent_after_old = endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; (void)cbm_daemon_ipc_local_transition_release(&legacy_wins); (void)cbm_daemon_ipc_local_transition_release(&transition_wins); @@ -952,23 +967,18 @@ TEST(daemon_ipc_windows_startup_retries_transient_rendezvous_reader) { } if (endpoint) { call.endpoint = endpoint; - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } cbm_private_file_lock_status_t directory_status = - endpoint ? cbm_daemon_ipc_private_lock_directory_new(endpoint, - &directory) + endpoint ? cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory) : CBM_PRIVATE_FILE_LOCK_IO; cbm_private_file_lock_status_t record_status = directory_status == CBM_PRIVATE_FILE_LOCK_OK - ? cbm_private_file_lock_try_acquire( - directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, - CBM_PRIVATE_FILE_LOCK_SH, &record_reader) + ? cbm_private_file_lock_try_acquire(directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_SH, &record_reader) : CBM_PRIVATE_FILE_LOCK_IO; if (record_status == CBM_PRIVATE_FILE_LOCK_OK) { - thread_started = cbm_thread_create(&thread, 0, - ipc_test_win_startup_call, - &call) == 0; + thread_started = cbm_thread_create(&thread, 0, ipc_test_win_startup_call, &call) == 0; } for (size_t attempt = 0; thread_started && attempt < 200U; attempt++) { if (ipc_test_win_lock_busy(directory, "cbm-startup-v2.lock")) { @@ -982,8 +992,7 @@ TEST(daemon_ipc_windows_startup_retries_transient_rendezvous_reader) { join_status = cbm_thread_join(&thread); } if (endpoint) { - ipc_test_copy_path(address, - cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(address, cbm_daemon_ipc_endpoint_address(endpoint)); } ipc_test_win_lock_release(&record_reader); @@ -1025,38 +1034,29 @@ TEST(daemon_ipc_windows_rendezvous_bridges_concurrent_lifetime_owner) { } if (endpoint) { call.endpoint = endpoint; - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } int initial_status = - endpoint ? cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &initial_startup) - : -1; - if (initial_status == 1 && - !cbm_daemon_ipc_startup_lock_prepare_handoff(initial_startup)) { + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &initial_startup) : -1; + if (initial_status == 1 && !cbm_daemon_ipc_startup_lock_prepare_handoff(initial_startup)) { initial_status = -1; } if (initial_status == 1) { - ipc_test_copy_path(before, - cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(before, cbm_daemon_ipc_endpoint_address(endpoint)); } cbm_daemon_ipc_startup_lock_release(&initial_startup); initial_startup = NULL; cbm_private_file_lock_status_t directory_status = - endpoint ? cbm_daemon_ipc_private_lock_directory_new(endpoint, - &directory) + endpoint ? cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory) : CBM_PRIVATE_FILE_LOCK_IO; cbm_private_file_lock_status_t record_status = directory_status == CBM_PRIVATE_FILE_LOCK_OK - ? cbm_private_file_lock_try_acquire( - directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, - CBM_PRIVATE_FILE_LOCK_SH, &record_reader) + ? cbm_private_file_lock_try_acquire(directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_SH, &record_reader) : CBM_PRIVATE_FILE_LOCK_IO; if (record_status == CBM_PRIVATE_FILE_LOCK_OK) { - thread_started = cbm_thread_create(&thread, 0, - ipc_test_win_startup_call, - &call) == 0; + thread_started = cbm_thread_create(&thread, 0, ipc_test_win_startup_call, &call) == 0; } for (size_t attempt = 0; thread_started && attempt < 200U; attempt++) { if (ipc_test_win_lock_busy(directory, "cbm-startup-v2.lock")) { @@ -1067,17 +1067,15 @@ TEST(daemon_ipc_windows_rendezvous_bridges_concurrent_lifetime_owner) { } cbm_private_file_lock_status_t lifetime_status = startup_observed - ? cbm_private_file_lock_try_acquire( - directory, "cbm-lifetime.lock", CBM_PRIVATE_FILE_LOCK_EX, - &lifetime_owner) + ? cbm_private_file_lock_try_acquire(directory, "cbm-lifetime.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lifetime_owner) : CBM_PRIVATE_FILE_LOCK_IO; ipc_test_win_lock_release(&record_reader); if (thread_started) { join_status = cbm_thread_join(&thread); } if (endpoint) { - ipc_test_copy_path(after, - cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(after, cbm_daemon_ipc_endpoint_address(endpoint)); } ipc_test_win_lock_release(&lifetime_owner); @@ -1125,76 +1123,55 @@ TEST(daemon_ipc_windows_generation_rotates_and_escapes_occupied_old_pipe) { peer = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } int first_status = - endpoint ? cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &first_startup) - : -1; - if (first_status == 1 && - !cbm_daemon_ipc_startup_lock_prepare_handoff(first_startup)) { + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &first_startup) : -1; + if (first_status == 1 && !cbm_daemon_ipc_startup_lock_prepare_handoff(first_startup)) { first_status = -1; } if (first_status == 1) { - ipc_test_copy_path(first_address, - cbm_daemon_ipc_endpoint_address(endpoint)); - ipc_test_copy_path(first_peer_address, - cbm_daemon_ipc_endpoint_address(peer)); + ipc_test_copy_path(first_address, cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(first_peer_address, cbm_daemon_ipc_endpoint_address(peer)); } cbm_daemon_ipc_startup_lock_release(&first_startup); first_startup = NULL; wchar_t *old_name = first_address[0] ? cbm_utf8_to_wide(first_address) : NULL; if (old_name) { - old_pipe = CreateNamedPipeW( - old_name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, - PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | - PIPE_REJECT_REMOTE_CLIENTS, - 1, 4096, 4096, 0, NULL); + old_pipe = CreateNamedPipeW(old_name, PIPE_ACCESS_DUPLEX | FILE_FLAG_FIRST_PIPE_INSTANCE, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | + PIPE_REJECT_REMOTE_CLIENTS, + 1, 4096, 4096, 0, NULL); } free(old_name); int second_status = - endpoint ? cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &second_startup) - : -1; + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &second_startup) : -1; bool handoff_prepared = - second_status == 1 && - cbm_daemon_ipc_startup_lock_prepare_handoff(second_startup); + second_status == 1 && cbm_daemon_ipc_startup_lock_prepare_handoff(second_startup); int participant_status = - handoff_prepared - ? cbm_daemon_ipc_participant_guard_try_join(endpoint, - &participant) - : -1; - int lifetime_status = - participant_status == 1 - ? cbm_daemon_ipc_lifetime_reservation_try_acquire( - endpoint, &lifetime) - : -1; + handoff_prepared ? cbm_daemon_ipc_participant_guard_try_join(endpoint, &participant) : -1; + int lifetime_status = participant_status == 1 + ? cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &lifetime) + : -1; if (handoff_prepared) { - ipc_test_copy_path(second_address, - cbm_daemon_ipc_endpoint_address(endpoint)); - ipc_test_copy_path(second_peer_address, - cbm_daemon_ipc_endpoint_address(peer)); - listener = lifetime_status == 1 - ? cbm_daemon_ipc_listen_reserved(endpoint, &lifetime) - : NULL; + ipc_test_copy_path(second_address, cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(second_peer_address, cbm_daemon_ipc_endpoint_address(peer)); + listener = + lifetime_status == 1 ? cbm_daemon_ipc_listen_reserved(endpoint, &lifetime) : NULL; } cbm_daemon_ipc_startup_lock_release(&second_startup); second_startup = NULL; - bool first_shared = first_address[0] && - strcmp(first_address, first_peer_address) == 0; - bool rotated = second_address[0] && - strcmp(first_address, second_address) != 0; + bool first_shared = first_address[0] && strcmp(first_address, first_peer_address) == 0; + bool rotated = second_address[0] && strcmp(first_address, second_address) != 0; bool second_shared = strcmp(second_address, second_peer_address) == 0; bool old_occupied = old_pipe != INVALID_HANDLE_VALUE; bool new_listened = listener != NULL; cbm_daemon_ipc_listener_close(listener); cbm_daemon_ipc_lifetime_reservation_release(lifetime); - bool participant_released = - cbm_daemon_ipc_participant_guard_release(&participant); + bool participant_released = cbm_daemon_ipc_participant_guard_release(&participant); if (old_pipe != INVALID_HANDLE_VALUE) { (void)CloseHandle(old_pipe); } @@ -1232,10 +1209,8 @@ TEST(daemon_ipc_windows_corrupt_rendezvous_fails_closed_until_startup_repairs) { cbm_daemon_ipc_startup_lock_t *startup = NULL; cbm_private_lock_directory_t *directory = NULL; cbm_private_file_lock_t *record_lock = NULL; - cbm_private_file_lock_status_t directory_status = - CBM_PRIVATE_FILE_LOCK_IO; - cbm_private_file_lock_status_t record_status = - CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t directory_status = CBM_PRIVATE_FILE_LOCK_IO; + cbm_private_file_lock_status_t record_status = CBM_PRIVATE_FILE_LOCK_IO; cbm_private_file_lock_status_t write_status = CBM_PRIVATE_FILE_LOCK_IO; cbm_private_file_lock_status_t read_status = CBM_PRIVATE_FILE_LOCK_IO; uint8_t readback[CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RECORD_SIZE] = {0}; @@ -1246,35 +1221,29 @@ TEST(daemon_ipc_windows_corrupt_rendezvous_fails_closed_until_startup_repairs) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } int initial_status = - endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) - : -1; - if (initial_status == 1 && - !cbm_daemon_ipc_startup_lock_prepare_handoff(startup)) { + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; + if (initial_status == 1 && !cbm_daemon_ipc_startup_lock_prepare_handoff(startup)) { initial_status = -1; } if (initial_status == 1) { - ipc_test_copy_path(original_address, - cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(original_address, cbm_daemon_ipc_endpoint_address(endpoint)); } cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; if (endpoint) { - directory_status = cbm_daemon_ipc_private_lock_directory_new( - endpoint, &directory); + directory_status = cbm_daemon_ipc_private_lock_directory_new(endpoint, &directory); } if (directory_status == CBM_PRIVATE_FILE_LOCK_OK) { - record_status = cbm_private_file_lock_try_acquire( - directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, - CBM_PRIVATE_FILE_LOCK_EX, &record_lock); + record_status = + cbm_private_file_lock_try_acquire(directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_EX, &record_lock); } if (record_status == CBM_PRIVATE_FILE_LOCK_OK) { - write_status = cbm_private_file_lock_payload_write( - record_lock, partial, sizeof(partial)); + write_status = cbm_private_file_lock_payload_write(record_lock, partial, sizeof(partial)); } if (record_lock) { (void)cbm_private_file_lock_release(&record_lock); @@ -1288,45 +1257,37 @@ TEST(daemon_ipc_windows_corrupt_rendezvous_fails_closed_until_startup_repairs) { cbm_daemon_ipc_connection_t *corrupt_connection = reader ? cbm_daemon_ipc_connect(reader, 1) : NULL; bool failed_closed = - reader && cbm_daemon_ipc_endpoint_address(reader) == NULL && - corrupt_connection == NULL; + reader && cbm_daemon_ipc_endpoint_address(reader) == NULL && corrupt_connection == NULL; cbm_daemon_ipc_connection_close(corrupt_connection); - if (reader && cbm_daemon_ipc_private_lock_directory_new( - reader, &directory) == CBM_PRIVATE_FILE_LOCK_OK && - cbm_private_file_lock_try_acquire( - directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, - CBM_PRIVATE_FILE_LOCK_SH, &record_lock) == - CBM_PRIVATE_FILE_LOCK_OK) { - read_status = cbm_private_file_lock_payload_read( - record_lock, readback, sizeof(readback), &readback_length); + if (reader && + cbm_daemon_ipc_private_lock_directory_new(reader, &directory) == CBM_PRIVATE_FILE_LOCK_OK && + cbm_private_file_lock_try_acquire(directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_SH, + &record_lock) == CBM_PRIVATE_FILE_LOCK_OK) { + read_status = cbm_private_file_lock_payload_read(record_lock, readback, sizeof(readback), + &readback_length); } if (record_lock) { (void)cbm_private_file_lock_release(&record_lock); } cbm_private_lock_directory_close(directory); directory = NULL; - bool reader_did_not_repair = - read_status == CBM_PRIVATE_FILE_LOCK_OK && - readback_length == sizeof(partial) && - memcmp(readback, partial, sizeof(partial)) == 0; - - int repair_status = - reader ? cbm_daemon_ipc_startup_lock_try_acquire(reader, &startup) - : -1; - if (repair_status == 1 && - !cbm_daemon_ipc_startup_lock_prepare_handoff(startup)) { + bool reader_did_not_repair = read_status == CBM_PRIVATE_FILE_LOCK_OK && + readback_length == sizeof(partial) && + memcmp(readback, partial, sizeof(partial)) == 0; + + int repair_status = reader ? cbm_daemon_ipc_startup_lock_try_acquire(reader, &startup) : -1; + if (repair_status == 1 && !cbm_daemon_ipc_startup_lock_prepare_handoff(startup)) { repair_status = -1; } if (repair_status == 1) { - ipc_test_copy_path(repaired_address, - cbm_daemon_ipc_endpoint_address(reader)); + ipc_test_copy_path(repaired_address, cbm_daemon_ipc_endpoint_address(reader)); } cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; - bool repaired = repaired_address[0] && - strcmp(original_address, repaired_address) != 0; + bool repaired = repaired_address[0] && strcmp(original_address, repaired_address) != 0; uint8_t mismatched_nonce[CBM_DAEMON_IPC_WINDOWS_NONCE_SIZE] = {0}; char mismatched_address[CBM_DAEMON_IPC_WINDOWS_NAME_CAP] = {0}; @@ -1334,50 +1295,41 @@ TEST(daemon_ipc_windows_corrupt_rendezvous_fails_closed_until_startup_repairs) { cbm_private_file_lock_status_t mismatch_read = CBM_PRIVATE_FILE_LOCK_IO; cbm_private_file_lock_status_t mismatch_write = CBM_PRIVATE_FILE_LOCK_IO; bool mismatch_encoded = false; - if (reader && cbm_daemon_ipc_private_lock_directory_new( - reader, &directory) == CBM_PRIVATE_FILE_LOCK_OK && - cbm_private_file_lock_try_acquire( - directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, - CBM_PRIVATE_FILE_LOCK_EX, &record_lock) == - CBM_PRIVATE_FILE_LOCK_OK) { - mismatch_read = cbm_private_file_lock_payload_read( - record_lock, readback, sizeof(readback), &valid_record_length); + if (reader && + cbm_daemon_ipc_private_lock_directory_new(reader, &directory) == CBM_PRIVATE_FILE_LOCK_OK && + cbm_private_file_lock_try_acquire(directory, CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_FILE, + CBM_PRIVATE_FILE_LOCK_EX, + &record_lock) == CBM_PRIVATE_FILE_LOCK_OK) { + mismatch_read = cbm_private_file_lock_payload_read(record_lock, readback, sizeof(readback), + &valid_record_length); bool decoded = mismatch_read == CBM_PRIVATE_FILE_LOCK_OK && cbm_daemon_ipc_windows_rendezvous_record_decode( - readback, valid_record_length, mismatched_nonce, - mismatched_address); + readback, valid_record_length, mismatched_nonce, mismatched_address); if (decoded) { mismatched_nonce[0] ^= 0x80U; - mismatch_encoded = - cbm_daemon_ipc_windows_rendezvous_record_encode( - mismatched_nonce, mismatched_address, readback); + mismatch_encoded = cbm_daemon_ipc_windows_rendezvous_record_encode( + mismatched_nonce, mismatched_address, readback); } if (mismatch_encoded) { - mismatch_write = cbm_private_file_lock_payload_write( - record_lock, readback, sizeof(readback)); + mismatch_write = + cbm_private_file_lock_payload_write(record_lock, readback, sizeof(readback)); } } ipc_test_win_lock_release(&record_lock); cbm_private_lock_directory_close(directory); directory = NULL; - bool mismatch_failed_closed = - mismatch_write == CBM_PRIVATE_FILE_LOCK_OK && - cbm_daemon_ipc_endpoint_address(reader) == NULL; - int rebound_status = - reader ? cbm_daemon_ipc_startup_lock_try_acquire(reader, &startup) - : -1; - if (rebound_status == 1 && - !cbm_daemon_ipc_startup_lock_prepare_handoff(startup)) { + bool mismatch_failed_closed = mismatch_write == CBM_PRIVATE_FILE_LOCK_OK && + cbm_daemon_ipc_endpoint_address(reader) == NULL; + int rebound_status = reader ? cbm_daemon_ipc_startup_lock_try_acquire(reader, &startup) : -1; + if (rebound_status == 1 && !cbm_daemon_ipc_startup_lock_prepare_handoff(startup)) { rebound_status = -1; } if (rebound_status == 1) { - ipc_test_copy_path(rebound_address, - cbm_daemon_ipc_endpoint_address(reader)); + ipc_test_copy_path(rebound_address, cbm_daemon_ipc_endpoint_address(reader)); } cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; - bool rebound = rebound_address[0] && - strcmp(rebound_address, repaired_address) != 0; + bool rebound = rebound_address[0] && strcmp(rebound_address, repaired_address) != 0; cbm_daemon_ipc_startup_lock_release(&startup); if (record_lock) { @@ -1422,50 +1374,35 @@ TEST(daemon_ipc_windows_startup_and_lifetime_locks_are_cross_process) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } int startup_status = - endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) - : -1; + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; int child_startup_while_held = - startup_status == 1 - ? ipc_test_win_lock_child("startup", key, parent) - : -1; + startup_status == 1 ? ipc_test_win_lock_child("startup", key, parent) : -1; cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; int child_startup_after_release = endpoint ? ipc_test_win_lock_child("startup", key, parent) : -1; int lifetime_startup_status = - endpoint ? cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &lifetime_startup) - : -1; + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &lifetime_startup) : -1; bool lifetime_prepared = lifetime_startup_status == 1 && - cbm_daemon_ipc_startup_lock_prepare_handoff( - lifetime_startup); + cbm_daemon_ipc_startup_lock_prepare_handoff(lifetime_startup); int participant_status = - lifetime_prepared - ? cbm_daemon_ipc_participant_guard_try_join(endpoint, - &participant) - : -1; - int lifetime_status = - participant_status == 1 - ? cbm_daemon_ipc_lifetime_reservation_try_acquire( - endpoint, &lifetime) - : -1; + lifetime_prepared ? cbm_daemon_ipc_participant_guard_try_join(endpoint, &participant) : -1; + int lifetime_status = participant_status == 1 + ? cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &lifetime) + : -1; cbm_daemon_ipc_startup_lock_release(&lifetime_startup); lifetime_startup = NULL; int child_lifetime_while_held = - lifetime_status == 1 - ? ipc_test_win_lock_child("lifetime", key, parent) - : -1; + lifetime_status == 1 ? ipc_test_win_lock_child("lifetime", key, parent) : -1; cbm_daemon_ipc_lifetime_reservation_release(lifetime); lifetime = NULL; int child_lifetime_after_release = endpoint ? ipc_test_win_lock_child("lifetime", key, parent) : -1; - bool participant_released = - cbm_daemon_ipc_participant_guard_release(&participant); + bool participant_released = cbm_daemon_ipc_participant_guard_release(&participant); cbm_daemon_ipc_lifetime_reservation_release(lifetime); cbm_daemon_ipc_startup_lock_release(&startup); @@ -1521,11 +1458,9 @@ TEST(daemon_ipc_endpoint_is_namespaced_by_instance_key) { } #ifdef _WIN32 - int a_startup_status = - a ? cbm_daemon_ipc_startup_lock_try_acquire(a, &a_startup) : -1; + int a_startup_status = a ? cbm_daemon_ipc_startup_lock_try_acquire(a, &a_startup) : -1; int other_startup_status = - other ? cbm_daemon_ipc_startup_lock_try_acquire(other, &other_startup) - : -1; + other ? cbm_daemon_ipc_startup_lock_try_acquire(other, &other_startup) : -1; cbm_daemon_ipc_startup_lock_release(&a_startup); cbm_daemon_ipc_startup_lock_release(&other_startup); a_startup = NULL; @@ -1547,12 +1482,11 @@ TEST(daemon_ipc_endpoint_is_namespaced_by_instance_key) { other_key_other_address = a_address && other_address && strcmp(a_address, other_address) != 0; #ifdef _WIN32 - address_contains_key = - a_address && - strncmp(a_address, "\\\\.\\pipe\\cbm-daemon-", - strlen("\\\\.\\pipe\\cbm-daemon-")) == 0 && - strstr(a_address, key_a) == NULL && other_address && - strstr(other_address, key_b) == NULL; + address_contains_key = a_address && + strncmp(a_address, "\\\\.\\pipe\\cbm-daemon-", + strlen("\\\\.\\pipe\\cbm-daemon-")) == 0 && + strstr(a_address, key_a) == NULL && other_address && + strstr(other_address, key_b) == NULL; #else address_contains_key = a_address && strstr(a_address, key_a) != NULL && other_address && strstr(other_address, key_b) != NULL; @@ -1824,33 +1758,25 @@ TEST(daemon_ipc_lifetime_reservation_transfers_without_unlock_window) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); - int startup_result = cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &startup); - bool prepared = startup_result == 1 && - cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + int startup_result = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup); + bool prepared = startup_result == 1 && cbm_daemon_ipc_startup_lock_prepare_handoff(startup); int participant_result = - prepared ? cbm_daemon_ipc_participant_guard_try_join( - endpoint, &participant) - : -1; + prepared ? cbm_daemon_ipc_participant_guard_try_join(endpoint, &participant) : -1; acquired = participant_result == 1 - ? cbm_daemon_ipc_lifetime_reservation_try_acquire( - endpoint, &reservation) + ? cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &reservation) : -1; cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; } if (reservation) { - held_before_transfer = - cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + held_before_transfer = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); contender = cbm_daemon_ipc_listen(endpoint); listener = cbm_daemon_ipc_listen_reserved(endpoint, &reservation); transfer_consumed = listener != NULL && reservation == NULL; } if (listener) { - held_after_transfer = - cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + held_after_transfer = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); client = cbm_daemon_ipc_connect(endpoint, 500); connected = client != NULL; } @@ -1861,11 +1787,9 @@ TEST(daemon_ipc_lifetime_reservation_transfers_without_unlock_window) { listener = NULL; cbm_daemon_ipc_lifetime_reservation_release(reservation); reservation = NULL; - bool participant_released = - cbm_daemon_ipc_participant_guard_release(&participant); + bool participant_released = cbm_daemon_ipc_participant_guard_release(&participant); if (endpoint) { - free_after_close = - cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + free_after_close = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); } cbm_daemon_ipc_endpoint_free(endpoint); ipc_test_remove_tree(runtime_dir, parent); @@ -2024,8 +1948,7 @@ TEST(daemon_ipc_bounded_receive_rejects_oversize_before_payload) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); listener = cbm_daemon_ipc_listen(endpoint); } if (listener) { @@ -2035,20 +1958,17 @@ TEST(daemon_ipc_bounded_receive_rejects_oversize_before_payload) { accepted = cbm_daemon_ipc_accept(listener, 500, &server); } if (accepted == 1 && server) { - sent = cbm_daemon_ipc_send_frame( - client, CBM_DAEMON_FRAME_REQUEST, 0x2021, oversized_payload, - (uint32_t)sizeof(oversized_payload)); + sent = cbm_daemon_ipc_send_frame(client, CBM_DAEMON_FRAME_REQUEST, 0x2021, + oversized_payload, (uint32_t)sizeof(oversized_payload)); } if (sent) { bounded_result = cbm_daemon_ipc_receive_frame_bounded( - server, 500, (uint32_t)sizeof(oversized_payload) - 1, &frame, - &received_payload); + server, 500, (uint32_t)sizeof(oversized_payload) - 1, &frame, &received_payload); bounded_payload_absent = received_payload == NULL; bounded_frame_empty = frame.length == 0; free(received_payload); received_payload = NULL; - reuse_result = cbm_daemon_ipc_receive_frame( - server, 1, &frame, &received_payload); + reuse_result = cbm_daemon_ipc_receive_frame(server, 1, &frame, &received_payload); } free(received_payload); @@ -2287,53 +2207,35 @@ TEST(daemon_ipc_activation_probe_ignores_matching_startup_claim_only) { bool parent_ok = ipc_test_parent_new(parent, "activation-probe"); if (parent_ok) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); - wrong_endpoint = cbm_daemon_ipc_endpoint_new( - "5c5c6d6d7e7e8f90", parent); + wrong_endpoint = cbm_daemon_ipc_endpoint_new("5c5c6d6d7e7e8f90", parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } - int without_lock = endpoint - ? cbm_daemon_ipc_generation_probe_under_startup_lock( - endpoint, NULL) - : 0; + int without_lock = + endpoint ? cbm_daemon_ipc_generation_probe_under_startup_lock(endpoint, NULL) : 0; int startup_result = - endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, - &startup) - : -1; - int generic_self = - startup ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; + int generic_self = startup ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; int matching = - startup ? cbm_daemon_ipc_generation_probe_under_startup_lock( - endpoint, startup) - : -2; + startup ? cbm_daemon_ipc_generation_probe_under_startup_lock(endpoint, startup) : -2; int mismatched = startup && wrong_endpoint - ? cbm_daemon_ipc_generation_probe_under_startup_lock( - wrong_endpoint, startup) + ? cbm_daemon_ipc_generation_probe_under_startup_lock(wrong_endpoint, startup) : -2; - bool prepared = startup && - cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + bool prepared = startup && cbm_daemon_ipc_startup_lock_prepare_handoff(startup); int after_prepare = - prepared ? cbm_daemon_ipc_generation_probe_under_startup_lock( - endpoint, startup) - : -2; + prepared ? cbm_daemon_ipc_generation_probe_under_startup_lock(endpoint, startup) : -2; cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; - int after_release = - endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + int after_release = endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; listener = endpoint ? cbm_daemon_ipc_listen(endpoint) : NULL; bool listener_started = listener != NULL; int active_startup = - listener ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, - &startup) - : -1; + listener ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; int active_generation = - active_startup == 1 - ? cbm_daemon_ipc_generation_probe_under_startup_lock(endpoint, - startup) - : -1; + active_startup == 1 ? cbm_daemon_ipc_generation_probe_under_startup_lock(endpoint, startup) + : -1; cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; cbm_daemon_ipc_listener_close(listener); @@ -2374,68 +2276,46 @@ TEST(daemon_ipc_local_transition_coexists_with_active_daemon_lifetime) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } int startup_result = - endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) - : -1; - bool handoff = startup_result == 1 && - cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; + bool handoff = startup_result == 1 && cbm_daemon_ipc_startup_lock_prepare_handoff(startup); int participant_result = - handoff ? cbm_daemon_ipc_participant_guard_try_join( - endpoint, &daemon_participant) - : -1; - int lifetime_result = - participant_result == 1 - ? cbm_daemon_ipc_lifetime_reservation_try_acquire( - endpoint, &lifetime) - : -1; + handoff ? cbm_daemon_ipc_participant_guard_try_join(endpoint, &daemon_participant) : -1; + int lifetime_result = participant_result == 1 + ? cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &lifetime) + : -1; cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; - int transition_result = - lifetime_result == 1 - ? cbm_daemon_ipc_local_transition_try_acquire(endpoint, - &transition) - : -1; + int transition_result = lifetime_result == 1 + ? cbm_daemon_ipc_local_transition_try_acquire(endpoint, &transition) + : -1; int unsealed_lifetime = transition_result == 1 - ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, - transition) + ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, transition) : 0; int seal_result = - transition_result == 1 - ? cbm_daemon_ipc_local_transition_seal_legacy(transition) - : -1; - int sealed_lifetime = - seal_result == 1 - ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, - transition) - : -1; + transition_result == 1 ? cbm_daemon_ipc_local_transition_seal_legacy(transition) : -1; + int sealed_lifetime = seal_result == 1 + ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, transition) + : -1; bool work_begun = - sealed_lifetime == 1 && - cbm_daemon_ipc_local_transition_begin_work(transition); + sealed_lifetime == 1 && cbm_daemon_ipc_local_transition_begin_work(transition); int startup_during_result = - work_begun - ? cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &startup_during_local) - : -1; + work_begun ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup_during_local) : -1; cbm_daemon_ipc_startup_lock_release(&startup_during_local); startup_during_local = NULL; - bool transition_released = - cbm_daemon_ipc_local_transition_release(&transition); + bool transition_released = cbm_daemon_ipc_local_transition_release(&transition); int lifetime_after_transition = endpoint ? cbm_daemon_ipc_lifetime_reservation_probe(endpoint) : -1; cbm_daemon_ipc_lifetime_reservation_release(lifetime); lifetime = NULL; - bool participant_released = - cbm_daemon_ipc_participant_guard_release(&daemon_participant); + bool participant_released = cbm_daemon_ipc_participant_guard_release(&daemon_participant); int startup_after_result = - endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, - &startup_after) - : -1; + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup_after) : -1; cbm_daemon_ipc_startup_lock_release(&startup_after); cbm_daemon_ipc_endpoint_free(endpoint); ipc_test_remove_tree(runtime_dir, parent); @@ -2474,58 +2354,33 @@ TEST(daemon_ipc_local_participants_overlap_and_allow_modern_startup) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } int first_acquired = - endpoint ? cbm_daemon_ipc_local_transition_try_acquire( - endpoint, &first) - : -1; + endpoint ? cbm_daemon_ipc_local_transition_try_acquire(endpoint, &first) : -1; int first_sealed = - first_acquired == 1 - ? cbm_daemon_ipc_local_transition_seal_legacy(first) - : -1; + first_acquired == 1 ? cbm_daemon_ipc_local_transition_seal_legacy(first) : -1; int first_presence = - first_sealed == 1 - ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, first) - : -1; - bool first_begun = - first_presence == 0 && - cbm_daemon_ipc_local_transition_begin_work(first); + first_sealed == 1 ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, first) : -1; + bool first_begun = first_presence == 0 && cbm_daemon_ipc_local_transition_begin_work(first); int second_acquired = - first_begun ? cbm_daemon_ipc_local_transition_try_acquire( - endpoint, &second) - : -1; + first_begun ? cbm_daemon_ipc_local_transition_try_acquire(endpoint, &second) : -1; int second_sealed = - second_acquired == 1 - ? cbm_daemon_ipc_local_transition_seal_legacy(second) - : -1; + second_acquired == 1 ? cbm_daemon_ipc_local_transition_seal_legacy(second) : -1; int second_presence = - second_sealed == 1 - ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, - second) - : -1; - bool second_begun = - second_presence == 0 && - cbm_daemon_ipc_local_transition_begin_work(second); + second_sealed == 1 ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, second) : -1; + bool second_begun = second_presence == 0 && cbm_daemon_ipc_local_transition_begin_work(second); int startup_during = - second_begun ? cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &startup) - : -1; + second_begun ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; - int legacy_while_both = - second_begun ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + int legacy_while_both = second_begun ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; bool first_released = cbm_daemon_ipc_local_transition_release(&first); int legacy_while_second = - first_released ? cbm_daemon_ipc_legacy_generation_probe(endpoint) - : -1; - bool second_released = - cbm_daemon_ipc_local_transition_release(&second); - int legacy_after = - second_released ? cbm_daemon_ipc_legacy_generation_probe(endpoint) - : -1; + first_released ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + bool second_released = cbm_daemon_ipc_local_transition_release(&second); + int legacy_after = second_released ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; (void)cbm_daemon_ipc_local_transition_release(&second); (void)cbm_daemon_ipc_local_transition_release(&first); @@ -2566,39 +2421,23 @@ TEST(daemon_ipc_windows_local_transition_release_retries_retained_mutex) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } - int acquired = endpoint - ? cbm_daemon_ipc_local_transition_try_acquire( - endpoint, &transition) - : -1; - int sealed = acquired == 1 - ? cbm_daemon_ipc_local_transition_seal_legacy( - transition) - : -1; - int lifetime = sealed == 1 - ? cbm_daemon_ipc_local_transition_lifetime_probe( - endpoint, transition) - : -1; - bool begun = lifetime == 0 && - cbm_daemon_ipc_local_transition_begin_work(transition); + int acquired = + endpoint ? cbm_daemon_ipc_local_transition_try_acquire(endpoint, &transition) : -1; + int sealed = acquired == 1 ? cbm_daemon_ipc_local_transition_seal_legacy(transition) : -1; + int lifetime = + sealed == 1 ? cbm_daemon_ipc_local_transition_lifetime_probe(endpoint, transition) : -1; + bool begun = lifetime == 0 && cbm_daemon_ipc_local_transition_begin_work(transition); if (begun) { cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test(1); } - bool first_release = - cbm_daemon_ipc_local_transition_release(&transition); + bool first_release = cbm_daemon_ipc_local_transition_release(&transition); bool retained_after_failure = transition != NULL; - bool retry_release = - cbm_daemon_ipc_local_transition_release(&transition); + bool retry_release = cbm_daemon_ipc_local_transition_release(&transition); cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test(0); - int legacy_after = endpoint - ? cbm_daemon_ipc_legacy_generation_probe(endpoint) - : -1; - int startup_after = endpoint - ? cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &after) - : -1; + int legacy_after = endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; + int startup_after = endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &after) : -1; cbm_daemon_ipc_startup_lock_release(&after); (void)cbm_daemon_ipc_local_transition_release(&transition); cbm_daemon_ipc_endpoint_free(endpoint); @@ -2636,32 +2475,21 @@ TEST(daemon_ipc_windows_startup_release_retains_retry_authority) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } - int acquired = endpoint - ? cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &startup) - : -1; + int acquired = endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; if (acquired == 1) { cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test(1); } - bool first_release = - cbm_daemon_ipc_startup_lock_release(&startup); + bool first_release = cbm_daemon_ipc_startup_lock_release(&startup); bool retained_after_failure = startup != NULL; int blocked_while_retained = - retained_after_failure - ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &blocked) - : -1; - bool retry_release = - cbm_daemon_ipc_startup_lock_release(&startup); + retained_after_failure ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &blocked) : -1; + bool retry_release = cbm_daemon_ipc_startup_lock_release(&startup); cbm_daemon_ipc_windows_legacy_guard_release_failures_set_for_test(0); - int acquired_after = retry_release - ? cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &after) - : -1; - bool after_released = - cbm_daemon_ipc_startup_lock_release(&after); + int acquired_after = + retry_release ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &after) : -1; + bool after_released = cbm_daemon_ipc_startup_lock_release(&after); (void)cbm_daemon_ipc_startup_lock_release(&blocked); (void)cbm_daemon_ipc_startup_lock_release(&startup); cbm_daemon_ipc_endpoint_free(endpoint); @@ -2818,36 +2646,25 @@ static bool ipc_test_unix_address_set(struct sockaddr_un *address, const char *p return true; } -static bool ipc_test_socket_identity_path(char out[TEST_PATH_CAP], - const char *socket_path) { - int written = socket_path - ? snprintf(out, TEST_PATH_CAP, "%s.identity", socket_path) - : -1; +static bool ipc_test_socket_identity_path(char out[TEST_PATH_CAP], const char *socket_path) { + int written = socket_path ? snprintf(out, TEST_PATH_CAP, "%s.identity", socket_path) : -1; return written > 0 && written < TEST_PATH_CAP; } -static bool ipc_test_socket_anchor_path(char out[TEST_PATH_CAP], - const char *runtime_dir, +static bool ipc_test_socket_anchor_path(char out[TEST_PATH_CAP], const char *runtime_dir, const char *key) { - int written = runtime_dir && key - ? snprintf(out, TEST_PATH_CAP, "%s/cbm-%s.anc", - runtime_dir, key) - : -1; + int written = + runtime_dir && key ? snprintf(out, TEST_PATH_CAP, "%s/cbm-%s.anc", runtime_dir, key) : -1; return written > 0 && written < TEST_PATH_CAP; } -static bool ipc_test_socket_pending_path(char out[TEST_PATH_CAP], - const char *socket_path) { - int written = socket_path - ? snprintf(out, TEST_PATH_CAP, "%s.pending", - socket_path) - : -1; +static bool ipc_test_socket_pending_path(char out[TEST_PATH_CAP], const char *socket_path) { + int written = socket_path ? snprintf(out, TEST_PATH_CAP, "%s.pending", socket_path) : -1; return written > 0 && written < TEST_PATH_CAP; } #ifndef _WIN32 -static bool ipc_test_record_temp_path(char out[TEST_PATH_CAP], - const char *runtime_dir, +static bool ipc_test_record_temp_path(char out[TEST_PATH_CAP], const char *runtime_dir, const char *record_path) { if (!out || !runtime_dir || !record_path) { return false; @@ -2875,8 +2692,7 @@ static bool ipc_test_record_temp_path(char out[TEST_PATH_CAP], ambiguous = true; break; } - int written = snprintf(out, TEST_PATH_CAP, "%s/%s", runtime_dir, - entry->d_name); + int written = snprintf(out, TEST_PATH_CAP, "%s/%s", runtime_dir, entry->d_name); found = written > 0 && written < TEST_PATH_CAP; if (!found) { break; @@ -2891,8 +2707,8 @@ static bool ipc_test_record_temp_path(char out[TEST_PATH_CAP], } #endif -static void ipc_test_publication_crash_hook( - cbm_daemon_ipc_posix_publication_stage_t stage, void *opaque) { +static void ipc_test_publication_crash_hook(cbm_daemon_ipc_posix_publication_stage_t stage, + void *opaque) { const cbm_daemon_ipc_posix_publication_stage_t *target = opaque; if (target && stage == *target) { _exit(40 + (int)stage); @@ -3033,10 +2849,8 @@ TEST(daemon_ipc_posix_lifetime_reservation_rejects_fork_inheritance) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); - acquired = cbm_daemon_ipc_lifetime_reservation_try_acquire( - endpoint, &reservation); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + acquired = cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &reservation); } if (reservation && pipe(result_pipe) == 0) { child = fork(); @@ -3049,16 +2863,14 @@ TEST(daemon_ipc_posix_lifetime_reservation_rejects_fork_inheritance) { child_result = unexpected == NULL && inherited == reservation ? 1 : 0; cbm_daemon_ipc_listener_close(unexpected); cbm_daemon_ipc_lifetime_reservation_release(inherited); - bool reported = ipc_test_fd_write_all( - result_pipe[1], &child_result, sizeof(child_result)); + bool reported = ipc_test_fd_write_all(result_pipe[1], &child_result, sizeof(child_result)); (void)close(result_pipe[1]); _exit(reported && child_result == 1 ? 0 : 1); } if (child > 0) { (void)close(result_pipe[1]); result_pipe[1] = -1; - bool received = ipc_test_fd_read_all( - result_pipe[0], &child_result, sizeof(child_result)); + bool received = ipc_test_fd_read_all(result_pipe[0], &child_result, sizeof(child_result)); (void)close(result_pipe[0]); result_pipe[0] = -1; if (!received) { @@ -3066,20 +2878,15 @@ TEST(daemon_ipc_posix_lifetime_reservation_rejects_fork_inheritance) { } while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} } - if (child_result == 1 && WIFEXITED(child_status) && - WEXITSTATUS(child_status) == 0) { - held_after_child = - cbm_daemon_ipc_lifetime_reservation_probe(endpoint); - parent_listener = - cbm_daemon_ipc_listen_reserved(endpoint, &reservation); - parent_transfer_consumed = - parent_listener != NULL && reservation == NULL; + if (child_result == 1 && WIFEXITED(child_status) && WEXITSTATUS(child_status) == 0) { + held_after_child = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + parent_listener = cbm_daemon_ipc_listen_reserved(endpoint, &reservation); + parent_transfer_consumed = parent_listener != NULL && reservation == NULL; } cbm_daemon_ipc_listener_close(parent_listener); cbm_daemon_ipc_lifetime_reservation_release(reservation); if (endpoint) { - free_after_close = - cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + free_after_close = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); } for (size_t index = 0; index < 2; index++) { if (result_pipe[index] >= 0) { @@ -3125,21 +2932,15 @@ TEST(daemon_ipc_posix_child_participant_handoff_retains_legacy_bridge) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); - int path_length = snprintf(legacy_path, sizeof(legacy_path), - "%s/cbm-%s.lock", runtime_dir, key); - parent_ok = path_length > 0 && - path_length < (int)sizeof(legacy_path); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + int path_length = + snprintf(legacy_path, sizeof(legacy_path), "%s/cbm-%s.lock", runtime_dir, key); + parent_ok = path_length > 0 && path_length < (int)sizeof(legacy_path); } int startup_result = - parent_ok ? cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &startup) - : -1; - bool prepared = startup_result == 1 && - cbm_daemon_ipc_startup_lock_prepare_handoff(startup); - bool pipes_ok = prepared && pipe(parent_to_child) == 0 && - pipe(child_to_parent) == 0; + parent_ok ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; + bool prepared = startup_result == 1 && cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + bool pipes_ok = prepared && pipe(parent_to_child) == 0 && pipe(child_to_parent) == 0; if (pipes_ok) { child = fork(); } @@ -3147,54 +2948,45 @@ TEST(daemon_ipc_posix_child_participant_handoff_retains_legacy_bridge) { (void)close(parent_to_child[1]); (void)close(child_to_parent[0]); cbm_daemon_ipc_participant_guard_t *guard = NULL; - int join_result = cbm_daemon_ipc_participant_guard_try_join( - endpoint, &guard); + int join_result = cbm_daemon_ipc_participant_guard_try_join(endpoint, &guard); uint8_t join_byte = join_result == 1 && guard ? 1 : 0; - bool reported = ipc_test_fd_write_all( - child_to_parent[1], &join_byte, sizeof(join_byte)); + bool reported = ipc_test_fd_write_all(child_to_parent[1], &join_byte, sizeof(join_byte)); uint8_t command = 0; - bool commanded = reported && ipc_test_fd_read_all( - parent_to_child[0], &command, sizeof(command)); - bool guard_released = - cbm_daemon_ipc_participant_guard_release(&guard); - uint8_t release_byte = - commanded && command == 1 && guard_released && !guard ? 1 : 0; - bool release_reported = ipc_test_fd_write_all( - child_to_parent[1], &release_byte, sizeof(release_byte)); + bool commanded = + reported && ipc_test_fd_read_all(parent_to_child[0], &command, sizeof(command)); + bool guard_released = cbm_daemon_ipc_participant_guard_release(&guard); + uint8_t release_byte = commanded && command == 1 && guard_released && !guard ? 1 : 0; + bool release_reported = + ipc_test_fd_write_all(child_to_parent[1], &release_byte, sizeof(release_byte)); (void)close(parent_to_child[0]); (void)close(child_to_parent[1]); - _exit(join_byte == 1 && release_reported && release_byte == 1 ? 0 - : 1); + _exit(join_byte == 1 && release_reported && release_byte == 1 ? 0 : 1); } if (child > 0) { (void)close(parent_to_child[0]); parent_to_child[0] = -1; (void)close(child_to_parent[1]); child_to_parent[1] = -1; - bool joined_read = ipc_test_fd_read_all( - child_to_parent[0], &joined, sizeof(joined)); + bool joined_read = ipc_test_fd_read_all(child_to_parent[0], &joined, sizeof(joined)); cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; legacy_fd = open(legacy_path, O_RDWR | O_CLOEXEC | O_NOFOLLOW); if (joined_read && joined == 1 && legacy_fd >= 0) { - blocked_while_child = - flock(legacy_fd, LOCK_EX | LOCK_NB) == 0 ? 0 : - (errno == EWOULDBLOCK || errno == EAGAIN ? 1 : -1); + blocked_while_child = flock(legacy_fd, LOCK_EX | LOCK_NB) == 0 + ? 0 + : (errno == EWOULDBLOCK || errno == EAGAIN ? 1 : -1); } uint8_t command = 1; - bool commanded = ipc_test_fd_write_all( - parent_to_child[1], &command, sizeof(command)); - bool released_read = commanded && ipc_test_fd_read_all( - child_to_parent[0], &released, sizeof(released)); + bool commanded = ipc_test_fd_write_all(parent_to_child[1], &command, sizeof(command)); + bool released_read = + commanded && ipc_test_fd_read_all(child_to_parent[0], &released, sizeof(released)); (void)close(parent_to_child[1]); parent_to_child[1] = -1; (void)close(child_to_parent[0]); child_to_parent[0] = -1; while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} - if (released_read && released == 1 && WIFEXITED(child_status) && - legacy_fd >= 0) { - acquired_after_child = - flock(legacy_fd, LOCK_EX | LOCK_NB) == 0 ? 1 : 0; + if (released_read && released == 1 && WIFEXITED(child_status) && legacy_fd >= 0) { + acquired_after_child = flock(legacy_fd, LOCK_EX | LOCK_NB) == 0 ? 1 : 0; } } @@ -3243,8 +3035,7 @@ TEST(daemon_ipc_posix_publication_boundaries_recover_from_crash) { }; bool stage_ok[sizeof(stages) / sizeof(stages[0])] = {0}; - for (size_t index = 0; index < sizeof(stages) / sizeof(stages[0]); - index++) { + for (size_t index = 0; index < sizeof(stages) / sizeof(stages[0]); index++) { char parent[TEST_PATH_CAP] = {0}; char runtime_dir[TEST_PATH_CAP] = {0}; char socket_path[TEST_PATH_CAP] = {0}; @@ -3264,28 +3055,19 @@ TEST(daemon_ipc_posix_publication_boundaries_recover_from_crash) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path( - runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); - ipc_test_copy_path( - socket_path, - cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, cbm_daemon_ipc_endpoint_address(endpoint)); } - bool paths_ok = endpoint && - ipc_test_socket_anchor_path(anchor_path, runtime_dir, - key) && - ipc_test_socket_identity_path(identity_path, - socket_path) && - ipc_test_socket_pending_path(pending_path, - socket_path); + bool paths_ok = endpoint && ipc_test_socket_anchor_path(anchor_path, runtime_dir, key) && + ipc_test_socket_identity_path(identity_path, socket_path) && + ipc_test_socket_pending_path(pending_path, socket_path); if (paths_ok) { - cbm_daemon_ipc_posix_publication_hook_set_for_test( - ipc_test_publication_crash_hook, &stages[index]); + cbm_daemon_ipc_posix_publication_hook_set_for_test(ipc_test_publication_crash_hook, + &stages[index]); child = fork(); } if (child == 0) { - cbm_daemon_ipc_listener_t *listener = - cbm_daemon_ipc_listen(endpoint); + cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(endpoint); _exit(listener ? 90 : 91); } cbm_daemon_ipc_posix_publication_hook_set_for_test(NULL, NULL); @@ -3293,73 +3075,49 @@ TEST(daemon_ipc_posix_publication_boundaries_recover_from_crash) { while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} } - bool crashed_at_boundary = - child > 0 && WIFEXITED(child_status) && - WEXITSTATUS(child_status) == 40 + (int)stages[index]; - bool anchor_present = lstat(anchor_path, &anchor_status) == 0 && - S_ISSOCK(anchor_status.st_mode); - bool stable_present = lstat(socket_path, &socket_status) == 0 && - S_ISSOCK(socket_status.st_mode); - bool pending_present = lstat(pending_path, &record_status) == 0 && - S_ISREG(record_status.st_mode); - bool marker_present = lstat(identity_path, &record_status) == 0 && - S_ISREG(record_status.st_mode); - bool linked_shape = stages[index] < - CBM_DAEMON_IPC_POSIX_PUBLICATION_STABLE_DURABLE - ? anchor_present && - anchor_status.st_nlink == 1 && - !stable_present - : anchor_present && stable_present && - anchor_status.st_nlink == 2 && + bool crashed_at_boundary = child > 0 && WIFEXITED(child_status) && + WEXITSTATUS(child_status) == 40 + (int)stages[index]; + bool anchor_present = + lstat(anchor_path, &anchor_status) == 0 && S_ISSOCK(anchor_status.st_mode); + bool stable_present = + lstat(socket_path, &socket_status) == 0 && S_ISSOCK(socket_status.st_mode); + bool pending_present = + lstat(pending_path, &record_status) == 0 && S_ISREG(record_status.st_mode); + bool marker_present = + lstat(identity_path, &record_status) == 0 && S_ISREG(record_status.st_mode); + bool linked_shape = stages[index] < CBM_DAEMON_IPC_POSIX_PUBLICATION_STABLE_DURABLE + ? anchor_present && anchor_status.st_nlink == 1 && !stable_present + : anchor_present && stable_present && anchor_status.st_nlink == 2 && socket_status.st_nlink == 2 && - anchor_status.st_dev == - socket_status.st_dev && - anchor_status.st_ino == - socket_status.st_ino; + anchor_status.st_dev == socket_status.st_dev && + anchor_status.st_ino == socket_status.st_ino; bool expected_records = - pending_present == - (stages[index] >= - CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_DURABLE && - stages[index] < - CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_REMOVED) && - marker_present == - (stages[index] >= - CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_DURABLE); + pending_present == (stages[index] >= CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_DURABLE && + stages[index] < CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_REMOVED) && + marker_present == (stages[index] >= CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_DURABLE); int startup_result = - crashed_at_boundary - ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, - &startup) - : -1; + crashed_at_boundary ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; int cleanup_result = - startup_result == 1 - ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) - : -1; + startup_result == 1 ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) : -1; cbm_daemon_ipc_startup_lock_release(&startup); errno = 0; - bool stable_removed = lstat(socket_path, &record_status) != 0 && - errno == ENOENT; + bool stable_removed = lstat(socket_path, &record_status) != 0 && errno == ENOENT; errno = 0; - bool anchor_removed = lstat(anchor_path, &record_status) != 0 && - errno == ENOENT; + bool anchor_removed = lstat(anchor_path, &record_status) != 0 && errno == ENOENT; errno = 0; - bool pending_removed = lstat(pending_path, &record_status) != 0 && - errno == ENOENT; + bool pending_removed = lstat(pending_path, &record_status) != 0 && errno == ENOENT; errno = 0; - bool marker_removed = lstat(identity_path, &record_status) != 0 && - errno == ENOENT; - stage_ok[index] = parent_ok && paths_ok && crashed_at_boundary && - linked_shape && expected_records && - startup_result == 1 && cleanup_result == 1 && - stable_removed && anchor_removed && - pending_removed && marker_removed; + bool marker_removed = lstat(identity_path, &record_status) != 0 && errno == ENOENT; + stage_ok[index] = parent_ok && paths_ok && crashed_at_boundary && linked_shape && + expected_records && startup_result == 1 && cleanup_result == 1 && + stable_removed && anchor_removed && pending_removed && marker_removed; cbm_daemon_ipc_endpoint_free(endpoint); ipc_test_remove_tree(runtime_dir, parent); } - for (size_t index = 0; index < sizeof(stage_ok) / sizeof(stage_ok[0]); - index++) { + for (size_t index = 0; index < sizeof(stage_ok) / sizeof(stage_ok[0]); index++) { ASSERT_TRUE(stage_ok[index]); } PASS(); @@ -3376,19 +3134,14 @@ TEST(daemon_ipc_posix_record_publication_windows_recover_from_crash) { bool marker; bool linked; } cases[] = { - {CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_TEMP_SYNCED, false, - false}, - {CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_RECORD_LINKED, false, - true}, - {CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_TEMP_SYNCED, true, - false}, - {CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_RECORD_LINKED, true, - true}, + {CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_TEMP_SYNCED, false, false}, + {CBM_DAEMON_IPC_POSIX_PUBLICATION_PENDING_RECORD_LINKED, false, true}, + {CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_TEMP_SYNCED, true, false}, + {CBM_DAEMON_IPC_POSIX_PUBLICATION_MARKER_RECORD_LINKED, true, true}, }; bool case_ok[sizeof(cases) / sizeof(cases[0])] = {0}; - for (size_t index = 0; index < sizeof(cases) / sizeof(cases[0]); - index++) { + for (size_t index = 0; index < sizeof(cases) / sizeof(cases[0]); index++) { char parent[TEST_PATH_CAP] = {0}; char runtime_dir[TEST_PATH_CAP] = {0}; char socket_path[TEST_PATH_CAP] = {0}; @@ -3413,29 +3166,19 @@ TEST(daemon_ipc_posix_record_publication_windows_recover_from_crash) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path( - runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); - ipc_test_copy_path( - socket_path, - cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, cbm_daemon_ipc_endpoint_address(endpoint)); } - bool paths_ok = endpoint && - ipc_test_socket_anchor_path(anchor_path, runtime_dir, - key) && - ipc_test_socket_identity_path(identity_path, - socket_path) && - ipc_test_socket_pending_path(pending_path, - socket_path); + bool paths_ok = endpoint && ipc_test_socket_anchor_path(anchor_path, runtime_dir, key) && + ipc_test_socket_identity_path(identity_path, socket_path) && + ipc_test_socket_pending_path(pending_path, socket_path); if (paths_ok) { - cbm_daemon_ipc_posix_publication_hook_set_for_test( - ipc_test_publication_crash_hook, - (void *)&cases[index].stage); + cbm_daemon_ipc_posix_publication_hook_set_for_test(ipc_test_publication_crash_hook, + (void *)&cases[index].stage); child = fork(); } if (child == 0) { - cbm_daemon_ipc_listener_t *listener = - cbm_daemon_ipc_listen(endpoint); + cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(endpoint); _exit(listener ? 90 : 91); } cbm_daemon_ipc_posix_publication_hook_set_for_test(NULL, NULL); @@ -3444,91 +3187,64 @@ TEST(daemon_ipc_posix_record_publication_windows_recover_from_crash) { } bool crashed = child > 0 && WIFEXITED(child_status) && - WEXITSTATUS(child_status) == - 40 + (int)cases[index].stage; - const char *record_path = - cases[index].marker ? identity_path : pending_path; - bool temp_found = - crashed && ipc_test_record_temp_path(temp_path, runtime_dir, - record_path) && - lstat(temp_path, &temp_status) == 0 && - S_ISREG(temp_status.st_mode) && - temp_status.st_uid == geteuid() && - (temp_status.st_mode & 0777) == 0600 && - temp_status.st_nlink == (cases[index].linked ? 2 : 1); + WEXITSTATUS(child_status) == 40 + (int)cases[index].stage; + const char *record_path = cases[index].marker ? identity_path : pending_path; + bool temp_found = crashed && + ipc_test_record_temp_path(temp_path, runtime_dir, record_path) && + lstat(temp_path, &temp_status) == 0 && S_ISREG(temp_status.st_mode) && + temp_status.st_uid == geteuid() && (temp_status.st_mode & 0777) == 0600 && + temp_status.st_nlink == (cases[index].linked ? 2 : 1); errno = 0; - bool record_shape = - cases[index].linked - ? lstat(record_path, &record_status) == 0 && - S_ISREG(record_status.st_mode) && - record_status.st_nlink == 2 && temp_found && - record_status.st_dev == temp_status.st_dev && - record_status.st_ino == temp_status.st_ino - : lstat(record_path, &absent_status) != 0 && - errno == ENOENT; + bool record_shape = cases[index].linked + ? lstat(record_path, &record_status) == 0 && + S_ISREG(record_status.st_mode) && + record_status.st_nlink == 2 && temp_found && + record_status.st_dev == temp_status.st_dev && + record_status.st_ino == temp_status.st_ino + : lstat(record_path, &absent_status) != 0 && errno == ENOENT; bool socket_shape = cases[index].marker - ? lstat(socket_path, &socket_status) == 0 && - S_ISSOCK(socket_status.st_mode) && - socket_status.st_nlink == 2 && - lstat(anchor_path, &anchor_status) == 0 && - S_ISSOCK(anchor_status.st_mode) && - anchor_status.st_nlink == 2 && + ? lstat(socket_path, &socket_status) == 0 && S_ISSOCK(socket_status.st_mode) && + socket_status.st_nlink == 2 && lstat(anchor_path, &anchor_status) == 0 && + S_ISSOCK(anchor_status.st_mode) && anchor_status.st_nlink == 2 && socket_status.st_dev == anchor_status.st_dev && socket_status.st_ino == anchor_status.st_ino - : lstat(anchor_path, &anchor_status) == 0 && - S_ISSOCK(anchor_status.st_mode) && - anchor_status.st_nlink == 1 && - lstat(socket_path, &absent_status) != 0 && + : lstat(anchor_path, &anchor_status) == 0 && S_ISSOCK(anchor_status.st_mode) && + anchor_status.st_nlink == 1 && lstat(socket_path, &absent_status) != 0 && errno == ENOENT; errno = 0; bool preceding_record_shape = cases[index].marker - ? lstat(pending_path, &pending_status) == 0 && - S_ISREG(pending_status.st_mode) && + ? lstat(pending_path, &pending_status) == 0 && S_ISREG(pending_status.st_mode) && pending_status.st_nlink == 1 - : lstat(identity_path, &marker_status) != 0 && - errno == ENOENT; + : lstat(identity_path, &marker_status) != 0 && errno == ENOENT; int startup_result = - crashed - ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, - &startup) - : -1; + crashed ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; int cleanup_result = - startup_result == 1 - ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) - : -1; + startup_result == 1 ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) : -1; cbm_daemon_ipc_startup_lock_release(&startup); errno = 0; - bool stable_removed = lstat(socket_path, &absent_status) != 0 && - errno == ENOENT; + bool stable_removed = lstat(socket_path, &absent_status) != 0 && errno == ENOENT; errno = 0; - bool anchor_removed = lstat(anchor_path, &absent_status) != 0 && - errno == ENOENT; + bool anchor_removed = lstat(anchor_path, &absent_status) != 0 && errno == ENOENT; errno = 0; - bool pending_removed = lstat(pending_path, &absent_status) != 0 && - errno == ENOENT; + bool pending_removed = lstat(pending_path, &absent_status) != 0 && errno == ENOENT; errno = 0; - bool marker_removed = lstat(identity_path, &absent_status) != 0 && - errno == ENOENT; + bool marker_removed = lstat(identity_path, &absent_status) != 0 && errno == ENOENT; errno = 0; - bool temp_removed = temp_path[0] != '\0' && - lstat(temp_path, &absent_status) != 0 && - errno == ENOENT; - case_ok[index] = parent_ok && paths_ok && crashed && temp_found && - record_shape && socket_shape && - preceding_record_shape && startup_result == 1 && - cleanup_result == 1 && stable_removed && - anchor_removed && pending_removed && marker_removed && - temp_removed; + bool temp_removed = + temp_path[0] != '\0' && lstat(temp_path, &absent_status) != 0 && errno == ENOENT; + case_ok[index] = parent_ok && paths_ok && crashed && temp_found && record_shape && + socket_shape && preceding_record_shape && startup_result == 1 && + cleanup_result == 1 && stable_removed && anchor_removed && + pending_removed && marker_removed && temp_removed; cbm_daemon_ipc_endpoint_free(endpoint); ipc_test_remove_tree(runtime_dir, parent); } - for (size_t index = 0; index < sizeof(case_ok) / sizeof(case_ok[0]); - index++) { + for (size_t index = 0; index < sizeof(case_ok) / sizeof(case_ok[0]); index++) { ASSERT_TRUE(case_ok[index]); } PASS(); @@ -3557,46 +3273,31 @@ TEST(daemon_ipc_posix_unknown_record_temp_pair_is_preserved) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); - ipc_test_copy_path(socket_path, - cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, cbm_daemon_ipc_endpoint_address(endpoint)); } - bool pending_path_ok = - endpoint && ipc_test_socket_pending_path(pending_path, socket_path); + bool pending_path_ok = endpoint && ipc_test_socket_pending_path(pending_path, socket_path); int temp_written = - pending_path_ok - ? snprintf(temp_path, sizeof(temp_path), "%s.tmp", pending_path) - : -1; - bool paths_ok = pending_path_ok && temp_written > 0 && - temp_written < (int)sizeof(temp_path); + pending_path_ok ? snprintf(temp_path, sizeof(temp_path), "%s.tmp", pending_path) : -1; + bool paths_ok = pending_path_ok && temp_written > 0 && temp_written < (int)sizeof(temp_path); bool pair_created = paths_ok && ipc_test_write_byte(temp_path, 0x5a) && - chmod(temp_path, 0600) == 0 && - link(temp_path, pending_path) == 0 && + chmod(temp_path, 0600) == 0 && link(temp_path, pending_path) == 0 && lstat(temp_path, &temp_before) == 0 && - lstat(pending_path, &pending_before) == 0 && - S_ISREG(temp_before.st_mode) && - temp_before.st_nlink == 2 && - pending_before.st_dev == temp_before.st_dev && + lstat(pending_path, &pending_before) == 0 && S_ISREG(temp_before.st_mode) && + temp_before.st_nlink == 2 && pending_before.st_dev == temp_before.st_dev && pending_before.st_ino == temp_before.st_ino; int startup_result = - pair_created - ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) - : -1; + pair_created ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; int cleanup_result = - startup_result == 1 - ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) - : -1; + startup_result == 1 ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) : -1; cbm_daemon_ipc_startup_lock_release(&startup); bool pair_preserved = - lstat(temp_path, &temp_after) == 0 && - lstat(pending_path, &pending_after) == 0 && - temp_after.st_dev == temp_before.st_dev && - temp_after.st_ino == temp_before.st_ino && + lstat(temp_path, &temp_after) == 0 && lstat(pending_path, &pending_after) == 0 && + temp_after.st_dev == temp_before.st_dev && temp_after.st_ino == temp_before.st_ino && temp_after.st_nlink == 2 && pending_after.st_nlink == 2 && pending_after.st_dev == pending_before.st_dev && - pending_after.st_ino == pending_before.st_ino && - temp_after.st_size == 1 && pending_after.st_size == 1; + pending_after.st_ino == pending_before.st_ino && temp_after.st_size == 1 && + pending_after.st_size == 1; (void)unlink(pending_path); (void)unlink(temp_path); @@ -3642,22 +3343,16 @@ TEST(daemon_ipc_posix_recovery_preserves_replaced_stable_socket) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); - ipc_test_copy_path(socket_path, - cbm_daemon_ipc_endpoint_address(endpoint)); - } - bool paths_ok = endpoint && - ipc_test_socket_anchor_path(anchor_path, runtime_dir, - key) && - ipc_test_socket_identity_path(identity_path, - socket_path) && + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, cbm_daemon_ipc_endpoint_address(endpoint)); + } + bool paths_ok = endpoint && ipc_test_socket_anchor_path(anchor_path, runtime_dir, key) && + ipc_test_socket_identity_path(identity_path, socket_path) && ipc_test_socket_pending_path(pending_path, socket_path) && - ipc_test_unix_address_set(&address, socket_path, - &address_length); + ipc_test_unix_address_set(&address, socket_path, &address_length); if (paths_ok) { - cbm_daemon_ipc_posix_publication_hook_set_for_test( - ipc_test_publication_crash_hook, &crash_stage); + cbm_daemon_ipc_posix_publication_hook_set_for_test(ipc_test_publication_crash_hook, + &crash_stage); child = fork(); } if (child == 0) { @@ -3669,42 +3364,31 @@ TEST(daemon_ipc_posix_recovery_preserves_replaced_stable_socket) { while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} } bool crashed_committed = - child > 0 && WIFEXITED(child_status) && - WEXITSTATUS(child_status) == 40 + (int)crash_stage; + child > 0 && WIFEXITED(child_status) && WEXITSTATUS(child_status) == 40 + (int)crash_stage; bool stable_unlinked = crashed_committed && unlink(socket_path) == 0; if (stable_unlinked) { replacement = socket(AF_UNIX, SOCK_STREAM, 0); } bool replacement_ready = replacement >= 0 && - bind(replacement, (const struct sockaddr *)&address, - address_length) == 0 && + bind(replacement, (const struct sockaddr *)&address, address_length) == 0 && chmod(socket_path, 0600) == 0 && listen(replacement, 1) == 0 && - lstat(socket_path, &replacement_before) == 0 && - S_ISSOCK(replacement_before.st_mode); + lstat(socket_path, &replacement_before) == 0 && S_ISSOCK(replacement_before.st_mode); int startup_result = - replacement_ready - ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) - : -1; + replacement_ready ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; int cleanup_result = - startup_result == 1 - ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) - : -1; + startup_result == 1 ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) : -1; cbm_daemon_ipc_startup_lock_release(&startup); - bool replacement_preserved = - lstat(socket_path, &replacement_after) == 0 && - S_ISSOCK(replacement_after.st_mode) && - replacement_after.st_dev == replacement_before.st_dev && - replacement_after.st_ino == replacement_before.st_ino; + bool replacement_preserved = lstat(socket_path, &replacement_after) == 0 && + S_ISSOCK(replacement_after.st_mode) && + replacement_after.st_dev == replacement_before.st_dev && + replacement_after.st_ino == replacement_before.st_ino; errno = 0; - bool anchor_removed = lstat(anchor_path, &status) != 0 && - errno == ENOENT; + bool anchor_removed = lstat(anchor_path, &status) != 0 && errno == ENOENT; errno = 0; - bool pending_removed = lstat(pending_path, &status) != 0 && - errno == ENOENT; + bool pending_removed = lstat(pending_path, &status) != 0 && errno == ENOENT; errno = 0; - bool marker_removed = lstat(identity_path, &status) != 0 && - errno == ENOENT; + bool marker_removed = lstat(identity_path, &status) != 0 && errno == ENOENT; if (replacement >= 0) { (void)close(replacement); @@ -3753,18 +3437,14 @@ TEST(daemon_ipc_posix_pending_without_anchor_never_deletes_stable) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); - ipc_test_copy_path(socket_path, - cbm_daemon_ipc_endpoint_address(endpoint)); - } - bool paths_ok = endpoint && - ipc_test_socket_anchor_path(anchor_path, runtime_dir, - key) && + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, cbm_daemon_ipc_endpoint_address(endpoint)); + } + bool paths_ok = endpoint && ipc_test_socket_anchor_path(anchor_path, runtime_dir, key) && ipc_test_socket_pending_path(pending_path, socket_path); if (paths_ok) { - cbm_daemon_ipc_posix_publication_hook_set_for_test( - ipc_test_publication_crash_hook, &crash_stage); + cbm_daemon_ipc_posix_publication_hook_set_for_test(ipc_test_publication_crash_hook, + &crash_stage); child = fork(); } if (child == 0) { @@ -3776,28 +3456,19 @@ TEST(daemon_ipc_posix_pending_without_anchor_never_deletes_stable) { while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} } bool crashed_before_marker = - child > 0 && WIFEXITED(child_status) && - WEXITSTATUS(child_status) == 40 + (int)crash_stage; - bool anchor_removed = - crashed_before_marker && lstat(socket_path, &stable_before) == 0 && - S_ISSOCK(stable_before.st_mode) && unlink(anchor_path) == 0; + child > 0 && WIFEXITED(child_status) && WEXITSTATUS(child_status) == 40 + (int)crash_stage; + bool anchor_removed = crashed_before_marker && lstat(socket_path, &stable_before) == 0 && + S_ISSOCK(stable_before.st_mode) && unlink(anchor_path) == 0; int startup_result = - anchor_removed - ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) - : -1; + anchor_removed ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; int cleanup_result = - startup_result == 1 - ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) - : -1; + startup_result == 1 ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup) : -1; cbm_daemon_ipc_startup_lock_release(&startup); bool stable_preserved = - lstat(socket_path, &stable_after) == 0 && - S_ISSOCK(stable_after.st_mode) && - stable_after.st_dev == stable_before.st_dev && - stable_after.st_ino == stable_before.st_ino; + lstat(socket_path, &stable_after) == 0 && S_ISSOCK(stable_after.st_mode) && + stable_after.st_dev == stable_before.st_dev && stable_after.st_ino == stable_before.st_ino; errno = 0; - bool pending_removed = lstat(pending_path, &status) != 0 && - errno == ENOENT; + bool pending_removed = lstat(pending_path, &status) != 0 && errno == ENOENT; (void)unlink(socket_path); cbm_daemon_ipc_endpoint_free(endpoint); @@ -3847,25 +3518,20 @@ TEST(daemon_ipc_posix_current_generation_crash_cleanup_requires_startup_lock) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); - ipc_test_copy_path(socket_path, - cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, cbm_daemon_ipc_endpoint_address(endpoint)); paths_ok = ipc_test_socket_anchor_path(anchor_path, runtime_dir, key) && ipc_test_socket_identity_path(identity_path, socket_path) && - ipc_test_socket_pending_path(pending_path, socket_path) && - pipe(ready_pipe) == 0; + ipc_test_socket_pending_path(pending_path, socket_path) && pipe(ready_pipe) == 0; } if (paths_ok) { child = fork(); } if (child == 0) { (void)close(ready_pipe[0]); - cbm_daemon_ipc_listener_t *listener = - cbm_daemon_ipc_listen(endpoint); + cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(endpoint); uint8_t result = listener ? 'R' : 'E'; - bool reported = - ipc_test_fd_write_all(ready_pipe[1], &result, sizeof(result)); + bool reported = ipc_test_fd_write_all(ready_pipe[1], &result, sizeof(result)); (void)close(ready_pipe[1]); /* Deliberately bypass listener_close: the kernel releases descriptors * and locks, while the current-generation socket identity remains. */ @@ -3874,59 +3540,39 @@ TEST(daemon_ipc_posix_current_generation_crash_cleanup_requires_startup_lock) { if (child > 0) { (void)close(ready_pipe[1]); ready_pipe[1] = -1; - child_ready = ipc_test_fd_read_all(ready_pipe[0], &ready, - sizeof(ready)) && - ready == 'R'; + child_ready = ipc_test_fd_read_all(ready_pipe[0], &ready, sizeof(ready)) && ready == 'R'; (void)close(ready_pipe[0]); ready_pipe[0] = -1; while (waitpid(child, &child_status, 0) < 0 && errno == EINTR) {} } - if (child_ready && WIFEXITED(child_status) && - WEXITSTATUS(child_status) == 0) { - artifacts_survived_crash = - lstat(socket_path, &status) == 0 && S_ISSOCK(status.st_mode) && - status.st_nlink == 2 && - lstat(anchor_path, &status) == 0 && S_ISSOCK(status.st_mode) && - status.st_nlink == 2 && - lstat(identity_path, &status) == 0 && S_ISREG(status.st_mode); - lifetime_after_crash = - cbm_daemon_ipc_lifetime_reservation_probe(endpoint); - cleanup_without_lock = - cbm_daemon_ipc_stale_generation_cleanup(endpoint, NULL); - wrong_endpoint = cbm_daemon_ipc_endpoint_new( - "a1b2c3d4e5f60719", parent); + if (child_ready && WIFEXITED(child_status) && WEXITSTATUS(child_status) == 0) { + artifacts_survived_crash = lstat(socket_path, &status) == 0 && S_ISSOCK(status.st_mode) && + status.st_nlink == 2 && lstat(anchor_path, &status) == 0 && + S_ISSOCK(status.st_mode) && status.st_nlink == 2 && + lstat(identity_path, &status) == 0 && S_ISREG(status.st_mode); + lifetime_after_crash = cbm_daemon_ipc_lifetime_reservation_probe(endpoint); + cleanup_without_lock = cbm_daemon_ipc_stale_generation_cleanup(endpoint, NULL); + wrong_endpoint = cbm_daemon_ipc_endpoint_new("a1b2c3d4e5f60719", parent); wrong_startup_result = - wrong_endpoint - ? cbm_daemon_ipc_startup_lock_try_acquire( - wrong_endpoint, &wrong_startup) - : -1; + wrong_endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(wrong_endpoint, &wrong_startup) + : -1; cleanup_with_wrong_lock = - wrong_startup - ? cbm_daemon_ipc_stale_generation_cleanup( - endpoint, wrong_startup) - : -2; + wrong_startup ? cbm_daemon_ipc_stale_generation_cleanup(endpoint, wrong_startup) : -2; cbm_daemon_ipc_startup_lock_release(&wrong_startup); wrong_startup = NULL; - startup_result = cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &startup); + startup_result = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup); } if (startup) { - cleanup_result = cbm_daemon_ipc_stale_generation_cleanup( - endpoint, startup); + cleanup_result = cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup); errno = 0; - bool socket_removed = lstat(socket_path, &status) != 0 && - errno == ENOENT; + bool socket_removed = lstat(socket_path, &status) != 0 && errno == ENOENT; errno = 0; - bool identity_removed = lstat(identity_path, &status) != 0 && - errno == ENOENT; + bool identity_removed = lstat(identity_path, &status) != 0 && errno == ENOENT; errno = 0; - bool anchor_removed = lstat(anchor_path, &status) != 0 && - errno == ENOENT; + bool anchor_removed = lstat(anchor_path, &status) != 0 && errno == ENOENT; errno = 0; - bool pending_removed = lstat(pending_path, &status) != 0 && - errno == ENOENT; - artifacts_removed = socket_removed && anchor_removed && - identity_removed && pending_removed; + bool pending_removed = lstat(pending_path, &status) != 0 && errno == ENOENT; + artifacts_removed = socket_removed && anchor_removed && identity_removed && pending_removed; } cbm_daemon_ipc_startup_lock_release(&startup); @@ -3983,21 +3629,17 @@ TEST(daemon_ipc_posix_unknown_socket_without_identity_refuses_cleanup) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); - ipc_test_copy_path(socket_path, - cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, cbm_daemon_ipc_endpoint_address(endpoint)); paths_ok = ipc_test_socket_identity_path(identity_path, socket_path) && - ipc_test_unix_address_set(&address, socket_path, - &address_length); + ipc_test_unix_address_set(&address, socket_path, &address_length); } if (paths_ok) { raw_listener = socket(AF_UNIX, SOCK_STREAM, 0); } if (raw_listener >= 0) { unknown_created = - bind(raw_listener, (const struct sockaddr *)&address, - address_length) == 0 && + bind(raw_listener, (const struct sockaddr *)&address, address_length) == 0 && chmod(socket_path, 0600) == 0 && listen(raw_listener, 1) == 0 && lstat(socket_path, &before) == 0 && S_ISSOCK(before.st_mode) && before.st_uid == geteuid() && (before.st_mode & 0777) == 0600; @@ -4005,29 +3647,23 @@ TEST(daemon_ipc_posix_unknown_socket_without_identity_refuses_cleanup) { raw_listener = -1; } if (unknown_created) { - startup_result = cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &startup); + startup_result = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup); } if (startup) { - cleanup_result = cbm_daemon_ipc_stale_generation_cleanup( - endpoint, startup); - unknown_unchanged = - lstat(socket_path, &after) == 0 && S_ISSOCK(after.st_mode) && - after.st_dev == before.st_dev && after.st_ino == before.st_ino; + cleanup_result = cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup); + unknown_unchanged = lstat(socket_path, &after) == 0 && S_ISSOCK(after.st_mode) && + after.st_dev == before.st_dev && after.st_ino == before.st_ino; errno = 0; - identity_absent = lstat(identity_path, &after) != 0 && - errno == ENOENT; + identity_absent = lstat(identity_path, &after) != 0 && errno == ENOENT; } cbm_daemon_ipc_startup_lock_release(&startup); startup = NULL; if (unknown_unchanged) { - transition_result = cbm_daemon_ipc_local_transition_try_acquire( - endpoint, &transition); + transition_result = cbm_daemon_ipc_local_transition_try_acquire(endpoint, &transition); } if (transition) { - transition_seal_result = - cbm_daemon_ipc_local_transition_seal_legacy(transition); + transition_seal_result = cbm_daemon_ipc_local_transition_seal_legacy(transition); } (void)cbm_daemon_ipc_local_transition_release(&transition); if (raw_listener >= 0) { @@ -4078,54 +3714,42 @@ TEST(daemon_ipc_posix_active_listener_is_never_cleaned_under_queue_pressure) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); - ipc_test_copy_path(socket_path, - cbm_daemon_ipc_endpoint_address(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(socket_path, cbm_daemon_ipc_endpoint_address(endpoint)); paths_ok = ipc_test_socket_anchor_path(anchor_path, runtime_dir, key) && ipc_test_socket_identity_path(identity_path, socket_path); listener = cbm_daemon_ipc_listen(endpoint); } if (listener) { artifacts_before = - lstat(socket_path, &socket_before) == 0 && - S_ISSOCK(socket_before.st_mode) && - lstat(anchor_path, &anchor_before) == 0 && - S_ISSOCK(anchor_before.st_mode) && + lstat(socket_path, &socket_before) == 0 && S_ISSOCK(socket_before.st_mode) && + lstat(anchor_path, &anchor_before) == 0 && S_ISSOCK(anchor_before.st_mode) && anchor_before.st_dev == socket_before.st_dev && anchor_before.st_ino == socket_before.st_ino && - lstat(identity_path, &identity_before) == 0 && - S_ISREG(identity_before.st_mode); + lstat(identity_path, &identity_before) == 0 && S_ISREG(identity_before.st_mode); } /* Never accept: pressure the listen queue into the BSD case where a live * listener can reject further connects with ECONNREFUSED. Cleanup must * rely only on the retained lifetime reservation, never on connect(). */ while (listener && client_count < CLIENT_CAP) { - cbm_daemon_ipc_connection_t *client = - cbm_daemon_ipc_connect(endpoint, 1); + cbm_daemon_ipc_connection_t *client = cbm_daemon_ipc_connect(endpoint, 1); if (!client) { break; } clients[client_count++] = client; } if (listener) { - startup_result = cbm_daemon_ipc_startup_lock_try_acquire( - endpoint, &startup); + startup_result = cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup); } if (startup) { - cleanup_result = cbm_daemon_ipc_stale_generation_cleanup( - endpoint, startup); + cleanup_result = cbm_daemon_ipc_stale_generation_cleanup(endpoint, startup); artifacts_unchanged = - lstat(socket_path, &socket_after) == 0 && - S_ISSOCK(socket_after.st_mode) && + lstat(socket_path, &socket_after) == 0 && S_ISSOCK(socket_after.st_mode) && socket_after.st_dev == socket_before.st_dev && - socket_after.st_ino == socket_before.st_ino && - lstat(anchor_path, &anchor_after) == 0 && - S_ISSOCK(anchor_after.st_mode) && - anchor_after.st_dev == anchor_before.st_dev && + socket_after.st_ino == socket_before.st_ino && lstat(anchor_path, &anchor_after) == 0 && + S_ISSOCK(anchor_after.st_mode) && anchor_after.st_dev == anchor_before.st_dev && anchor_after.st_ino == anchor_before.st_ino && - lstat(identity_path, &identity_after) == 0 && - S_ISREG(identity_after.st_mode) && + lstat(identity_path, &identity_after) == 0 && S_ISREG(identity_after.st_mode) && identity_after.st_dev == identity_before.st_dev && identity_after.st_ino == identity_before.st_ino; endpoint_after_cleanup = cbm_daemon_ipc_endpoint_probe(endpoint, 1); @@ -4243,7 +3867,7 @@ TEST(daemon_ipc_posix_partial_frame_timeout_poisons_connection) { } #ifdef __APPLE__ -TEST(daemon_ipc_macos_clears_inherited_mutating_acl_from_new_runtime) { +TEST(daemon_ipc_macos_clears_inherited_deny_acl_from_new_runtime) { static const char key[] = "aa11bb22cc33dd44"; char parent[TEST_PATH_CAP] = {0}; char control[TEST_PATH_CAP] = {0}; @@ -4253,48 +3877,41 @@ TEST(daemon_ipc_macos_clears_inherited_mutating_acl_from_new_runtime) { struct stat control_status = {0}; struct stat runtime_status = {0}; bool parent_ok = ipc_test_parent_new(parent, "mac-acl-inherit"); - int acl_fixture = - parent_ok ? ipc_test_macos_set_mutating_acl(parent, true) : -1; + int acl_fixture = parent_ok ? ipc_test_macos_set_deny_acl(parent, true) : -1; if (acl_fixture == 0) { ipc_test_remove_flat_dir(parent); SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); } - bool parent_mode_stayed_private = - acl_fixture == 1 && lstat(parent, &parent_status) == 0 && - S_ISDIR(parent_status.st_mode) && (parent_status.st_mode & 0777) == 0700; - int control_written = - parent_mode_stayed_private - ? snprintf(control, sizeof(control), "%s/inheritance-control", parent) - : -1; + bool parent_mode_stayed_private = acl_fixture == 1 && lstat(parent, &parent_status) == 0 && + S_ISDIR(parent_status.st_mode) && + (parent_status.st_mode & 0777) == 0700; + int control_written = parent_mode_stayed_private + ? snprintf(control, sizeof(control), "%s/inheritance-control", parent) + : -1; bool control_created = - control_written > 0 && control_written < (int)sizeof(control) && - mkdir(control, 0700) == 0; - bool control_mode_stayed_private = - control_created && lstat(control, &control_status) == 0 && - S_ISDIR(control_status.st_mode) && (control_status.st_mode & 0777) == 0700; + control_written > 0 && control_written < (int)sizeof(control) && mkdir(control, 0700) == 0; + bool control_mode_stayed_private = control_created && lstat(control, &control_status) == 0 && + S_ISDIR(control_status.st_mode) && + (control_status.st_mode & 0777) == 0700; int control_acl_entries = control_created ? ipc_test_macos_extended_acl_entry_count(control) : -1; bool control_removed = !control_created || rmdir(control) == 0; - /* The control child proves this filesystem actually propagated the ACL; - * CBM must actively strip it from its own dedicated child, not merely rely - * on mkdir(0700)/fchmod(0700). */ + /* A deny-only ancestor is safe to traverse, but its inherited deny entry + * still must be stripped from CBM's final private directory. The control + * child proves that this filesystem propagated the ACL. */ if (control_removed && control_acl_entries > 0) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } bool runtime_mode_stayed_private = runtime_dir[0] != '\0' && lstat(runtime_dir, &runtime_status) == 0 && - S_ISDIR(runtime_status.st_mode) && - (runtime_status.st_mode & 0777) == 0700; + S_ISDIR(runtime_status.st_mode) && (runtime_status.st_mode & 0777) == 0700; int runtime_acl_entries = - runtime_dir[0] != '\0' - ? ipc_test_macos_extended_acl_entry_count(runtime_dir) - : -1; + runtime_dir[0] != '\0' ? ipc_test_macos_extended_acl_entry_count(runtime_dir) : -1; bool endpoint_created = endpoint != NULL; cbm_daemon_ipc_endpoint_free(endpoint); @@ -4313,6 +3930,50 @@ TEST(daemon_ipc_macos_clears_inherited_mutating_acl_from_new_runtime) { PASS(); } +TEST(daemon_ipc_macos_rejects_allow_acl_on_ancestor_without_mutation) { + static const char key[] = "bb22cc33dd44ee55"; + char parent[TEST_PATH_CAP] = {0}; + char runtime_dir[TEST_PATH_CAP] = {0}; + struct stat before = {0}; + struct stat after = {0}; + + bool parent_ok = ipc_test_parent_new(parent, "mac-acl-ancestor-allow"); + int runtime_written = parent_ok + ? snprintf(runtime_dir, sizeof(runtime_dir), "%s/cbm-daemon-%lu", + parent, (unsigned long)geteuid()) + : -1; + int acl_fixture = parent_ok ? ipc_test_macos_set_mutating_acl(parent, false) : -1; + if (acl_fixture == 0) { + ipc_test_remove_flat_dir(parent); + SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); + } + int entries_before = acl_fixture == 1 ? ipc_test_macos_extended_acl_entry_count(parent) : -1; + bool snapshot_before = entries_before > 0 && lstat(parent, &before) == 0; + + cbm_daemon_ipc_endpoint_t *endpoint = + snapshot_before ? cbm_daemon_ipc_endpoint_new(key, parent) : NULL; + + int entries_after = ipc_test_macos_extended_acl_entry_count(parent); + bool parent_unchanged = lstat(parent, &after) == 0 && before.st_dev == after.st_dev && + before.st_ino == after.st_ino && + (before.st_mode & 07777) == (after.st_mode & 07777) && + entries_after == entries_before; + errno = 0; + bool runtime_absent = runtime_written > 0 && runtime_written < (int)sizeof(runtime_dir) && + lstat(runtime_dir, &after) != 0 && errno == ENOENT; + + cbm_daemon_ipc_endpoint_free(endpoint); + ipc_test_remove_flat_dir(parent); + + ASSERT_TRUE(parent_ok); + ASSERT_EQ(acl_fixture, 1); + ASSERT_TRUE(snapshot_before); + ASSERT_NULL(endpoint); + ASSERT_TRUE(parent_unchanged); + ASSERT_TRUE(runtime_absent); + PASS(); +} + TEST(daemon_ipc_macos_repairs_or_rejects_existing_runtime_mutating_acl) { static const char key[] = "ee55aa66bb77cc88"; char parent[TEST_PATH_CAP] = {0}; @@ -4327,45 +3988,35 @@ TEST(daemon_ipc_macos_repairs_or_rejects_existing_runtime_mutating_acl) { seed = cbm_daemon_ipc_endpoint_new(key, parent); } if (seed) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(seed)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(seed)); } bool runtime_seeded = runtime_dir[0] != '\0'; cbm_daemon_ipc_endpoint_free(seed); seed = NULL; - int acl_fixture = - runtime_seeded ? ipc_test_macos_set_mutating_acl(runtime_dir, false) : -1; + int acl_fixture = runtime_seeded ? ipc_test_macos_set_mutating_acl(runtime_dir, false) : -1; if (acl_fixture == 0) { ipc_test_remove_tree(runtime_dir, parent); SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); } bool unsafe_mode_stayed_private = acl_fixture == 1 && lstat(runtime_dir, &runtime_status) == 0 && - S_ISDIR(runtime_status.st_mode) && - (runtime_status.st_mode & 0777) == 0700; + S_ISDIR(runtime_status.st_mode) && (runtime_status.st_mode & 0777) == 0700; int acl_entries_before = - acl_fixture == 1 - ? ipc_test_macos_extended_acl_entry_count(runtime_dir) - : -1; + acl_fixture == 1 ? ipc_test_macos_extended_acl_entry_count(runtime_dir) : -1; if (acl_entries_before > 0) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } int startup_status = - endpoint - ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) - : -1; - int acl_entries_after = - ipc_test_macos_extended_acl_entry_count(runtime_dir); + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; + int acl_entries_after = ipc_test_macos_extended_acl_entry_count(runtime_dir); /* A constructor rejection or a lock-use rejection is fail-closed. If use * succeeds, the dedicated directory must already have had its ACL cleared * and revalidated. Mode 0700 alone is deliberately insufficient. */ - bool rejected_before_use = - endpoint == NULL || startup_status == -1; - bool repaired_before_use = - endpoint != NULL && startup_status == 1 && acl_entries_after == 0; + bool rejected_before_use = endpoint == NULL || startup_status == -1; + bool repaired_before_use = endpoint != NULL && startup_status == 1 && acl_entries_after == 0; cbm_daemon_ipc_startup_lock_release(&startup); cbm_daemon_ipc_endpoint_free(endpoint); @@ -4393,26 +4044,20 @@ TEST(daemon_ipc_macos_runtime_acl_injection_invalidates_existing_endpoint) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } int acl_fixture = - runtime_dir[0] != '\0' - ? ipc_test_macos_set_mutating_acl(runtime_dir, false) - : -1; + runtime_dir[0] != '\0' ? ipc_test_macos_set_mutating_acl(runtime_dir, false) : -1; if (acl_fixture == 0) { cbm_daemon_ipc_endpoint_free(endpoint); ipc_test_remove_tree(runtime_dir, parent); SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); } - bool mode_stayed_private = - acl_fixture == 1 && lstat(runtime_dir, &runtime_status) == 0 && - S_ISDIR(runtime_status.st_mode) && - (runtime_status.st_mode & 07777) == 0700; + bool mode_stayed_private = acl_fixture == 1 && lstat(runtime_dir, &runtime_status) == 0 && + S_ISDIR(runtime_status.st_mode) && + (runtime_status.st_mode & 07777) == 0700; int startup_status = - mode_stayed_private - ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) - : 0; + mode_stayed_private ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : 0; cbm_daemon_ipc_startup_lock_release(&startup); cbm_daemon_ipc_endpoint_free(endpoint); @@ -4441,33 +4086,25 @@ TEST(daemon_ipc_macos_lock_acl_injection_invalidates_retained_startup) { endpoint = cbm_daemon_ipc_endpoint_new(key, parent); } if (endpoint) { - ipc_test_copy_path(runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); + ipc_test_copy_path(runtime_dir, cbm_daemon_ipc_endpoint_runtime_dir(endpoint)); } int startup_result = - endpoint - ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) - : -1; - int path_written = - startup_result == 1 - ? snprintf(startup_path, sizeof(startup_path), - "%s/cbm-%s.startup-v2.lock", runtime_dir, key) - : -1; + endpoint ? cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) : -1; + int path_written = startup_result == 1 ? snprintf(startup_path, sizeof(startup_path), + "%s/cbm-%s.startup-v2.lock", runtime_dir, key) + : -1; bool path_ok = path_written > 0 && path_written < (int)sizeof(startup_path); - int acl_fixture = - path_ok ? ipc_test_macos_set_mutating_acl(startup_path, false) : -1; + int acl_fixture = path_ok ? ipc_test_macos_set_mutating_acl(startup_path, false) : -1; if (acl_fixture == 0) { cbm_daemon_ipc_startup_lock_release(&startup); cbm_daemon_ipc_endpoint_free(endpoint); ipc_test_remove_tree(runtime_dir, parent); SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); } - bool mode_stayed_private = - acl_fixture == 1 && lstat(startup_path, &startup_status) == 0 && - S_ISREG(startup_status.st_mode) && - (startup_status.st_mode & 07777) == 0600; - bool prepared = mode_stayed_private && - cbm_daemon_ipc_startup_lock_prepare_handoff(startup); + bool mode_stayed_private = acl_fixture == 1 && lstat(startup_path, &startup_status) == 0 && + S_ISREG(startup_status.st_mode) && + (startup_status.st_mode & 07777) == 0600; + bool prepared = mode_stayed_private && cbm_daemon_ipc_startup_lock_prepare_handoff(startup); cbm_daemon_ipc_startup_lock_release(&startup); cbm_daemon_ipc_endpoint_free(endpoint); @@ -4521,24 +4158,17 @@ TEST(daemon_ipc_posix_runtime_and_socket_are_owner_only) { } if (listener) { socket_ok = lstat(socket_path, &socket_stat) == 0 && S_ISSOCK(socket_stat.st_mode) && - (socket_stat.st_mode & 0777) == 0600 && - socket_stat.st_uid == geteuid() && + (socket_stat.st_mode & 0777) == 0600 && socket_stat.st_uid == geteuid() && socket_stat.st_nlink == 2; - anchor_ok = lstat(anchor_path, &anchor_stat) == 0 && - S_ISSOCK(anchor_stat.st_mode) && - (anchor_stat.st_mode & 0777) == 0600 && - anchor_stat.st_uid == geteuid() && - anchor_stat.st_nlink == 2 && - anchor_stat.st_dev == socket_stat.st_dev && + anchor_ok = lstat(anchor_path, &anchor_stat) == 0 && S_ISSOCK(anchor_stat.st_mode) && + (anchor_stat.st_mode & 0777) == 0600 && anchor_stat.st_uid == geteuid() && + anchor_stat.st_nlink == 2 && anchor_stat.st_dev == socket_stat.st_dev && anchor_stat.st_ino == socket_stat.st_ino; - identity_ok = lstat(identity_path, &identity_stat) == 0 && - S_ISREG(identity_stat.st_mode) && - (identity_stat.st_mode & 0777) == 0600 && - identity_stat.st_uid == geteuid() && + identity_ok = lstat(identity_path, &identity_stat) == 0 && S_ISREG(identity_stat.st_mode) && + (identity_stat.st_mode & 0777) == 0600 && identity_stat.st_uid == geteuid() && identity_stat.st_nlink == 1; errno = 0; - pending_absent = lstat(pending_path, &identity_stat) != 0 && - errno == ENOENT; + pending_absent = lstat(pending_path, &identity_stat) != 0 && errno == ENOENT; } cbm_daemon_ipc_listener_close(listener); if (socket_path[0] != '\0') { @@ -4547,13 +4177,11 @@ TEST(daemon_ipc_posix_runtime_and_socket_are_owner_only) { } if (identity_path[0] != '\0') { errno = 0; - identity_removed = lstat(identity_path, &identity_stat) != 0 && - errno == ENOENT; + identity_removed = lstat(identity_path, &identity_stat) != 0 && errno == ENOENT; } if (anchor_path[0] != '\0') { errno = 0; - anchor_removed = lstat(anchor_path, &anchor_stat) != 0 && - errno == ENOENT; + anchor_removed = lstat(anchor_path, &anchor_stat) != 0 && errno == ENOENT; } cbm_daemon_ipc_endpoint_free(endpoint); ipc_test_remove_tree(runtime_dir, parent); @@ -4596,12 +4224,11 @@ TEST(daemon_ipc_posix_private_log_creates_first_ever_cache_tree_safely) { int first_written = snprintf(first, sizeof(first), "%s/first", parent); int cache_written = snprintf(cache, sizeof(cache), "%s/cache", first); int logs_written = snprintf(logs, sizeof(logs), "%s/logs", cache); - int log_written = - snprintf(log_path, sizeof(log_path), "%s/daemon.log", logs); - paths_ok = first_written > 0 && first_written < (int)sizeof(first) && - cache_written > 0 && cache_written < (int)sizeof(cache) && - logs_written > 0 && logs_written < (int)sizeof(logs) && - log_written > 0 && log_written < (int)sizeof(log_path); + int log_written = snprintf(log_path, sizeof(log_path), "%s/daemon.log", logs); + paths_ok = first_written > 0 && first_written < (int)sizeof(first) && cache_written > 0 && + cache_written < (int)sizeof(cache) && logs_written > 0 && + logs_written < (int)sizeof(logs) && log_written > 0 && + log_written < (int)sizeof(log_path); } if (paths_ok) { errno = 0; @@ -4618,19 +4245,13 @@ TEST(daemon_ipc_posix_private_log_creates_first_ever_cache_tree_safely) { struct stat first_status; struct stat cache_status; struct stat logs_status; - tree_private = - lstat(first, &first_status) == 0 && - S_ISDIR(first_status.st_mode) && - (first_status.st_mode & 0777) == 0700 && - lstat(cache, &cache_status) == 0 && - S_ISDIR(cache_status.st_mode) && - (cache_status.st_mode & 0777) == 0700 && - lstat(logs, &logs_status) == 0 && - S_ISDIR(logs_status.st_mode) && - (logs_status.st_mode & 0777) == 0700; - file_private = lstat(log_path, &status) == 0 && - S_ISREG(status.st_mode) && status.st_uid == geteuid() && - (status.st_mode & 0777) == 0600; + tree_private = lstat(first, &first_status) == 0 && S_ISDIR(first_status.st_mode) && + (first_status.st_mode & 0777) == 0700 && lstat(cache, &cache_status) == 0 && + S_ISDIR(cache_status.st_mode) && (cache_status.st_mode & 0777) == 0700 && + lstat(logs, &logs_status) == 0 && S_ISDIR(logs_status.st_mode) && + (logs_status.st_mode & 0777) == 0700; + file_private = lstat(log_path, &status) == 0 && S_ISREG(status.st_mode) && + status.st_uid == geteuid() && (status.st_mode & 0777) == 0600; } (void)unlink(log_path); @@ -4647,6 +4268,78 @@ TEST(daemon_ipc_posix_private_log_creates_first_ever_cache_tree_safely) { PASS(); } +TEST(daemon_ipc_posix_private_directory_hardens_existing_cache_root) { + char parent[TEST_PATH_CAP] = {0}; + char cache[TEST_PATH_CAP] = {0}; + struct stat status = {0}; + bool paths_ok = false; + bool deliberately_public = false; + bool hardened = false; + + if (ipc_test_parent_new(parent, "private-cache-root")) { + int cache_written = snprintf(cache, sizeof(cache), "%s/cache", parent); + paths_ok = + cache_written > 0 && cache_written < (int)sizeof(cache) && mkdir(cache, 0700) == 0; + } + if (paths_ok) { + deliberately_public = chmod(cache, 0755) == 0; + } + if (deliberately_public && cbm_daemon_ipc_private_directory_secure(cache)) { + hardened = lstat(cache, &status) == 0 && S_ISDIR(status.st_mode) && + status.st_uid == geteuid() && (status.st_mode & 07777) == 0700; + } + + (void)rmdir(cache); + ipc_test_remove_flat_dir(parent); + + ASSERT_TRUE(paths_ok); + ASSERT_TRUE(deliberately_public); + ASSERT_TRUE(hardened); + PASS(); +} + +TEST(daemon_ipc_posix_private_directory_rejects_world_writable_ancestor) { + char parent[TEST_PATH_CAP] = {0}; + char unsafe[TEST_PATH_CAP] = {0}; + char cache[TEST_PATH_CAP] = {0}; + struct stat status = {0}; + bool paths_ok = false; + bool unsafe_mode_set = false; + bool rejected = false; + bool ancestor_unchanged = false; + bool cache_absent = false; + + if (ipc_test_parent_new(parent, "private-cache-unsafe-ancestor")) { + int unsafe_written = snprintf(unsafe, sizeof(unsafe), "%s/unsafe", parent); + int cache_written = snprintf(cache, sizeof(cache), "%s/cache", unsafe); + paths_ok = unsafe_written > 0 && unsafe_written < (int)sizeof(unsafe) && + cache_written > 0 && cache_written < (int)sizeof(cache) && + mkdir(unsafe, 0700) == 0; + } + if (paths_ok) { + unsafe_mode_set = chmod(unsafe, 0777) == 0; + } + if (unsafe_mode_set) { + rejected = !cbm_daemon_ipc_private_directory_secure(cache); + ancestor_unchanged = lstat(unsafe, &status) == 0 && S_ISDIR(status.st_mode) && + (status.st_mode & 07777) == 0777; + errno = 0; + cache_absent = lstat(cache, &status) != 0 && errno == ENOENT; + } + + (void)rmdir(cache); + (void)chmod(unsafe, 0700); + (void)rmdir(unsafe); + ipc_test_remove_flat_dir(parent); + + ASSERT_TRUE(paths_ok); + ASSERT_TRUE(unsafe_mode_set); + ASSERT_TRUE(rejected); + ASSERT_TRUE(ancestor_unchanged); + ASSERT_TRUE(cache_absent); + PASS(); +} + TEST(daemon_ipc_posix_private_log_rejects_symlinks_and_is_owner_only) { char parent[TEST_PATH_CAP] = {0}; char logs[TEST_PATH_CAP] = {0}; @@ -4667,19 +4360,13 @@ TEST(daemon_ipc_posix_private_log_rejects_symlinks_and_is_owner_only) { if (ipc_test_parent_new(parent, "private-log")) { int logs_written = snprintf(logs, sizeof(logs), "%s/logs", parent); - int log_written = - snprintf(log_path, sizeof(log_path), "%s/daemon.log", logs); - int victim_dir_written = - snprintf(victim_dir, sizeof(victim_dir), "%s/victim", parent); - int victim_file_written = snprintf(victim_file, sizeof(victim_file), - "%s/sentinel", parent); - paths_ok = logs_written > 0 && logs_written < (int)sizeof(logs) && - log_written > 0 && log_written < (int)sizeof(log_path) && - victim_dir_written > 0 && - victim_dir_written < (int)sizeof(victim_dir) && - victim_file_written > 0 && - victim_file_written < (int)sizeof(victim_file) && - mkdir(victim_dir, 0700) == 0 && + int log_written = snprintf(log_path, sizeof(log_path), "%s/daemon.log", logs); + int victim_dir_written = snprintf(victim_dir, sizeof(victim_dir), "%s/victim", parent); + int victim_file_written = snprintf(victim_file, sizeof(victim_file), "%s/sentinel", parent); + paths_ok = logs_written > 0 && logs_written < (int)sizeof(logs) && log_written > 0 && + log_written < (int)sizeof(log_path) && victim_dir_written > 0 && + victim_dir_written < (int)sizeof(victim_dir) && victim_file_written > 0 && + victim_file_written < (int)sizeof(victim_file) && mkdir(victim_dir, 0700) == 0 && ipc_test_write_byte(victim_file, 0x4d); } if (paths_ok) { @@ -4702,12 +4389,10 @@ TEST(daemon_ipc_posix_private_log_rejects_symlinks_and_is_owner_only) { (void)fclose(log); log = NULL; } - directory_owner_only = - lstat(logs, &status) == 0 && S_ISDIR(status.st_mode) && - status.st_uid == geteuid() && (status.st_mode & 0777) == 0700; - file_owner_only = - lstat(log_path, &status) == 0 && S_ISREG(status.st_mode) && - status.st_uid == geteuid() && (status.st_mode & 0777) == 0600; + directory_owner_only = lstat(logs, &status) == 0 && S_ISDIR(status.st_mode) && + status.st_uid == geteuid() && (status.st_mode & 0777) == 0700; + file_owner_only = lstat(log_path, &status) == 0 && S_ISREG(status.st_mode) && + status.st_uid == geteuid() && (status.st_mode & 0777) == 0600; } if (file_owner_only) { (void)unlink(log_path); @@ -4721,8 +4406,8 @@ TEST(daemon_ipc_posix_private_log_rejects_symlinks_and_is_owner_only) { log = NULL; } status.st_size = -1; - victim_unchanged = stat(victim_file, &status) == 0 && - S_ISREG(status.st_mode) && status.st_size == 1; + victim_unchanged = + stat(victim_file, &status) == 0 && S_ISREG(status.st_mode) && status.st_size == 1; } (void)unlink(log_path); @@ -4818,6 +4503,7 @@ SUITE(daemon_ipc) { RUN_TEST(daemon_ipc_windows_rendezvous_record_is_exact_and_canonical); #ifdef _WIN32 RUN_TEST(daemon_ipc_windows_default_endpoint_ignores_temp_environment); + RUN_TEST(daemon_ipc_windows_private_directory_rejects_untrusted_ancestor_acl); RUN_TEST(daemon_ipc_windows_legacy_bridge_covers_handoff_and_lifetime); RUN_TEST(daemon_ipc_windows_local_transition_atomically_reserves_legacy_pipe); RUN_TEST(daemon_ipc_windows_startup_retries_transient_rendezvous_reader); @@ -4857,13 +4543,16 @@ SUITE(daemon_ipc) { RUN_TEST(daemon_ipc_posix_active_listener_is_never_cleaned_under_queue_pressure); RUN_TEST(daemon_ipc_posix_partial_frame_timeout_poisons_connection); #ifdef __APPLE__ - RUN_TEST(daemon_ipc_macos_clears_inherited_mutating_acl_from_new_runtime); + RUN_TEST(daemon_ipc_macos_clears_inherited_deny_acl_from_new_runtime); + RUN_TEST(daemon_ipc_macos_rejects_allow_acl_on_ancestor_without_mutation); RUN_TEST(daemon_ipc_macos_repairs_or_rejects_existing_runtime_mutating_acl); RUN_TEST(daemon_ipc_macos_runtime_acl_injection_invalidates_existing_endpoint); RUN_TEST(daemon_ipc_macos_lock_acl_injection_invalidates_retained_startup); #endif RUN_TEST(daemon_ipc_posix_runtime_and_socket_are_owner_only); RUN_TEST(daemon_ipc_posix_private_log_creates_first_ever_cache_tree_safely); + RUN_TEST(daemon_ipc_posix_private_directory_hardens_existing_cache_root); + RUN_TEST(daemon_ipc_posix_private_directory_rejects_world_writable_ancestor); RUN_TEST(daemon_ipc_posix_private_log_rejects_symlinks_and_is_owner_only); RUN_TEST(daemon_ipc_posix_rejects_non_socket_and_symlink_endpoints); #endif diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index eedd8e37d..d1dda0ecb 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -8,6 +8,7 @@ #include "test_framework.h" #include "test_helpers.h" +#include "daemon/application.h" #include "daemon/host.h" #include "daemon/host_internal.h" #include "daemon/ipc.h" @@ -18,6 +19,8 @@ #include "foundation/compat_thread.h" #include "foundation/log.h" #include "foundation/platform.h" +#include "pipeline/pipeline.h" +#include "store/store.h" #include #include @@ -72,6 +75,8 @@ enum { RUNTIME_TEST_ACTIVATION_RESPONSE_CONNECTIONS_OFFSET = 16, }; +#define RUNTIME_TEST_BLOCKING_GIT_MARKER_ENV "CBM_TEST_RUNTIME_BLOCKING_GIT_PID_FILE" + _Static_assert(CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN == 8 && CBM_DAEMON_ACTIVATION_SHUTDOWN_REQUEST_SIZE == RUNTIME_TEST_ACTIVATION_REQUEST_SIZE && @@ -81,24 +86,23 @@ _Static_assert(CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN == 8 && static const char RUNTIME_BUILD_B[] = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +static const char RUNTIME_CACHE_A[] = + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; static char runtime_self_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; static atomic_bool runtime_conflict_log_fallback_seen; static atomic_bool runtime_activation_shutdown_log_seen; static void runtime_test_conflict_log_fallback_sink(const char *line) { if (line && strstr(line, "daemon.conflict_log_append_failed")) { - atomic_store_explicit(&runtime_conflict_log_fallback_seen, true, - memory_order_release); + atomic_store_explicit(&runtime_conflict_log_fallback_seen, true, memory_order_release); } } static void runtime_test_activation_shutdown_sink(const char *line) { - if (line && strstr(line, "daemon.activation_shutdown") && - strstr(line, "requester_pid") && strstr(line, "requester_build") && - strstr(line, "action") && strstr(line, "active_clients") && - strstr(line, "active_connections")) { - atomic_store_explicit(&runtime_activation_shutdown_log_seen, true, - memory_order_release); + if (line && strstr(line, "daemon.activation_shutdown") && strstr(line, "requester_pid") && + strstr(line, "requester_build") && strstr(line, "action") && + strstr(line, "active_clients") && strstr(line, "active_connections")) { + atomic_store_explicit(&runtime_activation_shutdown_log_seen, true, memory_order_release); } } @@ -147,6 +151,16 @@ typedef struct { uint32_t response_length; } runtime_application_client_call_t; +typedef struct { + cbm_daemon_runtime_client_t *client; + char arguments[RUNTIME_TEST_PATH_CAP + 64]; + uint32_t timeout_ms; + atomic_bool completed; + cbm_daemon_runtime_application_status_t status; + uint8_t *response; + uint32_t response_length; +} runtime_real_application_call_t; + typedef struct { const cbm_daemon_ipc_endpoint_t *endpoint; cbm_daemon_build_identity_t identity; @@ -155,8 +169,7 @@ typedef struct { atomic_bool completed; } runtime_application_connect_call_t; -static cbm_daemon_build_identity_t runtime_test_identity(const char *version, - const char *build) { +static cbm_daemon_build_identity_t runtime_test_identity(const char *version, const char *build) { cbm_daemon_build_identity_t identity = { .semantic_version = version, .build_fingerprint = build, @@ -177,8 +190,8 @@ static uint64_t runtime_test_process_id(void) { static const char *runtime_test_self_build(void) { if (runtime_self_build[0] == '\0') { - (void)cbm_daemon_runtime_process_build_fingerprint( - runtime_test_process_id(), runtime_self_build); + (void)cbm_daemon_runtime_process_build_fingerprint(runtime_test_process_id(), + runtime_self_build); } return runtime_self_build; } @@ -222,57 +235,42 @@ typedef struct { static bool runtime_test_windows_copy_self(const char *destination) { wchar_t source[32768]; - DWORD source_length = GetModuleFileNameW( - NULL, source, (DWORD)(sizeof(source) / sizeof(source[0]))); + DWORD source_length = + GetModuleFileNameW(NULL, source, (DWORD)(sizeof(source) / sizeof(source[0]))); wchar_t *destination_wide = cbm_utf8_to_wide(destination); bool copied = source_length > 0 && - source_length < - (DWORD)(sizeof(source) / sizeof(source[0])) && - destination_wide && + source_length < (DWORD)(sizeof(source) / sizeof(source[0])) && destination_wide && CopyFileW(source, destination_wide, TRUE) != 0; free(destination_wide); return copied; } -static bool runtime_test_windows_posix_replace(const char *source, - const char *destination) { +static bool runtime_test_windows_posix_replace(const char *source, const char *destination) { wchar_t *source_wide = cbm_utf8_to_wide(source); wchar_t *destination_wide = cbm_utf8_to_wide(destination); - size_t destination_length = - destination_wide ? wcslen(destination_wide) : 0; + size_t destination_length = destination_wide ? wcslen(destination_wide) : 0; size_t destination_bytes = destination_length * sizeof(wchar_t); - size_t information_size = - offsetof(runtime_test_windows_rename_info_t, file_name) + - destination_bytes + sizeof(wchar_t); - bool sizes_ok = destination_length > 0 && - destination_bytes <= MAXDWORD && - information_size <= MAXDWORD; - runtime_test_windows_rename_info_t *information = - sizes_ok ? calloc(1, information_size) : NULL; + size_t information_size = offsetof(runtime_test_windows_rename_info_t, file_name) + + destination_bytes + sizeof(wchar_t); + bool sizes_ok = + destination_length > 0 && destination_bytes <= MAXDWORD && information_size <= MAXDWORD; + runtime_test_windows_rename_info_t *information = sizes_ok ? calloc(1, information_size) : NULL; if (information) { information->flags = - RUNTIME_TEST_FILE_RENAME_REPLACE_IF_EXISTS | - RUNTIME_TEST_FILE_RENAME_POSIX_SEMANTICS; + RUNTIME_TEST_FILE_RENAME_REPLACE_IF_EXISTS | RUNTIME_TEST_FILE_RENAME_POSIX_SEMANTICS; information->file_name_length = (DWORD)destination_bytes; - memcpy(information->file_name, destination_wide, - destination_bytes + sizeof(wchar_t)); + memcpy(information->file_name, destination_wide, destination_bytes + sizeof(wchar_t)); } HANDLE source_file = source_wide && information ? CreateFileW(source_wide, DELETE | SYNCHRONIZE, - FILE_SHARE_READ | FILE_SHARE_WRITE | - FILE_SHARE_DELETE, - NULL, OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL | - FILE_FLAG_OPEN_REPARSE_POINT, - NULL) + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OPEN_REPARSE_POINT, NULL) : INVALID_HANDLE_VALUE; - bool replaced = - source_file != INVALID_HANDLE_VALUE && - SetFileInformationByHandle( - source_file, - (FILE_INFO_BY_HANDLE_CLASS)RUNTIME_TEST_FILE_RENAME_INFO_EX, - information, (DWORD)information_size) != 0; + bool replaced = source_file != INVALID_HANDLE_VALUE && + SetFileInformationByHandle( + source_file, (FILE_INFO_BY_HANDLE_CLASS)RUNTIME_TEST_FILE_RENAME_INFO_EX, + information, (DWORD)information_size) != 0; if (source_file != INVALID_HANDLE_VALUE && !CloseHandle(source_file)) { replaced = false; } @@ -282,28 +280,24 @@ static bool runtime_test_windows_posix_replace(const char *source, return replaced; } -static bool runtime_test_windows_spawn_image_holder( - const char *image_path, const char *ready_event, - PROCESS_INFORMATION *process_out) { +static bool runtime_test_windows_spawn_image_holder(const char *image_path, const char *ready_event, + PROCESS_INFORMATION *process_out) { if (!image_path || !ready_event || !process_out) { return false; } memset(process_out, 0, sizeof(*process_out)); char command_line[RUNTIME_TEST_PATH_CAP * 2]; int written = snprintf(command_line, sizeof(command_line), - "\"%s\" __cbm_runtime_image_holder \"%s\"", - image_path, ready_event); + "\"%s\" __cbm_runtime_image_holder \"%s\"", image_path, ready_event); wchar_t *application = cbm_utf8_to_wide(image_path); - wchar_t *command = written > 0 && written < (int)sizeof(command_line) - ? cbm_utf8_to_wide(command_line) - : NULL; + wchar_t *command = + written > 0 && written < (int)sizeof(command_line) ? cbm_utf8_to_wide(command_line) : NULL; STARTUPINFOW startup; memset(&startup, 0, sizeof(startup)); startup.cb = sizeof(startup); bool created = application && command && - CreateProcessW(application, command, NULL, NULL, FALSE, - CREATE_NO_WINDOW, NULL, NULL, &startup, - process_out) != 0; + CreateProcessW(application, command, NULL, NULL, FALSE, CREATE_NO_WINDOW, NULL, + NULL, &startup, process_out) != 0; free(command); free(application); if (created) { @@ -317,14 +311,10 @@ static bool runtime_test_windows_spawn_image_holder( #if defined(__APPLE__) || defined(__linux__) -static bool runtime_test_copy_executable(const char *source, - const char *destination) { +static bool runtime_test_copy_executable(const char *source, const char *destination) { int source_fd = open(source, O_RDONLY | O_CLOEXEC); - int destination_fd = source_fd >= 0 - ? open(destination, - O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, - 0700) - : -1; + int destination_fd = + source_fd >= 0 ? open(destination, O_WRONLY | O_CREAT | O_EXCL | O_CLOEXEC, 0700) : -1; unsigned char buffer[64 * 1024]; bool ok = source_fd >= 0 && destination_fd >= 0; while (ok) { @@ -341,8 +331,7 @@ static bool runtime_test_copy_executable(const char *source, } size_t written = 0; while (written < (size_t)count) { - ssize_t chunk = write(destination_fd, buffer + written, - (size_t)count - written); + ssize_t chunk = write(destination_fd, buffer + written, (size_t)count - written); if (chunk > 0) { written += (size_t)chunk; } else if (chunk < 0 && errno == EINTR) { @@ -368,8 +357,7 @@ static bool runtime_test_copy_executable(const char *source, return ok; } -static pid_t runtime_test_spawn_blocked_executable(const char *path, - int *release_fd_out) { +static pid_t runtime_test_spawn_blocked_executable(const char *path, int *release_fd_out) { int ready[2] = {-1, -1}; int input[2] = {-1, -1}; if (!path || !release_fd_out || pipe(ready) != 0 || pipe(input) != 0) { @@ -384,8 +372,7 @@ static pid_t runtime_test_spawn_blocked_executable(const char *path, return -1; } int ready_flags = fcntl(ready[1], F_GETFD); - if (ready_flags < 0 || - fcntl(ready[1], F_SETFD, ready_flags | FD_CLOEXEC) != 0) { + if (ready_flags < 0 || fcntl(ready[1], F_SETFD, ready_flags | FD_CLOEXEC) != 0) { (void)close(ready[0]); (void)close(ready[1]); (void)close(input[0]); @@ -416,14 +403,12 @@ static pid_t runtime_test_spawn_blocked_executable(const char *path, } while (ready_count < 0 && errno == EINTR); (void)close(ready[0]); int status = 0; - bool running = child > 0 && ready_count == 0 && - waitpid(child, &status, WNOHANG) == 0; + bool running = child > 0 && ready_count == 0 && waitpid(child, &status, WNOHANG) == 0; if (!running) { (void)close(input[1]); if (child > 0) { (void)kill(child, SIGKILL); - while (waitpid(child, &status, 0) < 0 && errno == EINTR) { - } + while (waitpid(child, &status, 0) < 0 && errno == EINTR) {} } return -1; } @@ -440,15 +425,13 @@ static void runtime_test_stop_blocked_executable(pid_t child, int release_fd) { } (void)kill(child, SIGTERM); int status = 0; - while (waitpid(child, &status, 0) < 0 && errno == EINTR) { - } + while (waitpid(child, &status, 0) < 0 && errno == EINTR) {} } #endif #if defined(__APPLE__) || defined(__linux__) -static bool runtime_test_self_image_path( - char source[RUNTIME_TEST_PATH_CAP]) { +static bool runtime_test_self_image_path(char source[RUNTIME_TEST_PATH_CAP]) { #ifdef __APPLE__ int length = proc_pidpath(getpid(), source, RUNTIME_TEST_PATH_CAP); bool resolved = length > 0 && length < RUNTIME_TEST_PATH_CAP; @@ -456,10 +439,8 @@ static bool runtime_test_self_image_path( source[length] = '\0'; } #else - ssize_t length = - readlink("/proc/self/exe", source, RUNTIME_TEST_PATH_CAP - 1); - bool resolved = length > 0 && - length < (ssize_t)RUNTIME_TEST_PATH_CAP - 1; + ssize_t length = readlink("/proc/self/exe", source, RUNTIME_TEST_PATH_CAP - 1); + bool resolved = length > 0 && length < (ssize_t)RUNTIME_TEST_PATH_CAP - 1; if (resolved) { source[length] = '\0'; } @@ -481,6 +462,142 @@ static bool runtime_test_copy_self_image(const char *destination) { #endif } +static bool runtime_test_paths_refer_to_same_file(const char *left, const char *right) { + enum { CANONICAL_CAP = 4096 }; + char left_canonical[CANONICAL_CAP]; + char right_canonical[CANONICAL_CAP]; + if (!left || !right || !cbm_canonical_path(left, left_canonical, sizeof(left_canonical)) || + !cbm_canonical_path(right, right_canonical, sizeof(right_canonical))) { + return false; + } +#ifdef _WIN32 + return _stricmp(left_canonical, right_canonical) == 0; +#else + return strcmp(left_canonical, right_canonical) == 0; +#endif +} + +static bool runtime_test_process_image_matches(uint64_t process_id, const char *expected_image) { + if (process_id <= 1 || process_id == runtime_test_process_id() || !expected_image) { + return false; + } + char observed[4096] = {0}; +#ifdef _WIN32 + if (process_id > UINT32_MAX) { + return false; + } + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, (DWORD)process_id); + wchar_t wide[32768]; + DWORD wide_length = (DWORD)(sizeof(wide) / sizeof(wide[0])); + bool queried = process && QueryFullProcessImageNameW(process, 0, wide, &wide_length) != 0; + char *utf8 = queried ? cbm_wide_to_utf8(wide) : NULL; + if (utf8) { + (void)snprintf(observed, sizeof(observed), "%s", utf8); + } + free(utf8); + if (process) { + (void)CloseHandle(process); + } +#elif defined(__APPLE__) + int length = process_id <= INT_MAX + ? proc_pidpath((int)process_id, observed, (uint32_t)sizeof(observed)) + : 0; + if (length <= 0 || length >= (int)sizeof(observed)) { + observed[0] = '\0'; + } +#elif defined(__linux__) + char proc_path[64]; + int written = + snprintf(proc_path, sizeof(proc_path), "/proc/%llu/exe", (unsigned long long)process_id); + ssize_t length = written > 0 && written < (int)sizeof(proc_path) + ? readlink(proc_path, observed, sizeof(observed) - 1) + : -1; + if (length > 0 && length < (ssize_t)sizeof(observed)) { + observed[length] = '\0'; + } else { + observed[0] = '\0'; + } +#else + (void)process_id; +#endif + return observed[0] && runtime_test_paths_refer_to_same_file(observed, expected_image); +} + +static bool runtime_test_wait_pid_marker(const char *path, uint32_t timeout_ms, + uint64_t *process_id_out) { + if (process_id_out) { + *process_id_out = 0; + } + uint64_t deadline = cbm_now_ms() + timeout_ms; + do { + FILE *marker = cbm_fopen(path, "rb"); + unsigned long long parsed = 0; + bool valid = marker && fscanf(marker, "%llu", &parsed) == 1 && parsed > 1 && + parsed != runtime_test_process_id(); + if (marker) { + (void)fclose(marker); + } + if (valid) { + if (process_id_out) { + *process_id_out = (uint64_t)parsed; + } + return true; + } + cbm_usleep(1000); + } while (cbm_now_ms() < deadline); + return false; +} + +static bool runtime_test_wait_process_image_gone(uint64_t process_id, const char *expected_image, + uint32_t timeout_ms) { + uint64_t deadline = cbm_now_ms() + timeout_ms; + do { + if (!runtime_test_process_image_matches(process_id, expected_image)) { + return true; + } + cbm_usleep(1000); + } while (cbm_now_ms() < deadline); + return !runtime_test_process_image_matches(process_id, expected_image); +} + +static bool runtime_test_force_terminate_verified(uint64_t process_id, const char *expected_image) { + if (!runtime_test_process_image_matches(process_id, expected_image)) { + return true; + } +#ifdef _WIN32 + HANDLE process = + process_id <= UINT32_MAX + ? OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_TERMINATE | SYNCHRONIZE, + FALSE, (DWORD)process_id) + : NULL; + if (!process) { + return false; + } + wchar_t wide[32768]; + DWORD wide_length = (DWORD)(sizeof(wide) / sizeof(wide[0])); + bool queried = QueryFullProcessImageNameW(process, 0, wide, &wide_length) != 0; + char *utf8 = queried ? cbm_wide_to_utf8(wide) : NULL; + bool exact = utf8 && runtime_test_paths_refer_to_same_file(utf8, expected_image); + free(utf8); + bool terminated = exact && TerminateProcess(process, 99) != 0; + if (terminated) { + terminated = WaitForSingleObject(process, RUNTIME_TEST_TIMEOUT_MS) == WAIT_OBJECT_0; + } + (void)CloseHandle(process); + return terminated; +#elif defined(__APPLE__) || defined(__linux__) + /* The marker lives in a private test directory and was written by this + * exact copied image. Revalidate immediately before signaling so the + * cleanup backstop never targets an unrelated or PID-reused process. */ + return runtime_test_process_image_matches(process_id, expected_image) && + kill((pid_t)process_id, SIGKILL) == 0 && + runtime_test_wait_process_image_gone(process_id, expected_image, + RUNTIME_TEST_TIMEOUT_MS); +#else + return false; +#endif +} + #if defined(_WIN32) || defined(__linux__) static bool runtime_test_append_image_marker(const char *path) { FILE *file = cbm_fopen(path, "ab"); @@ -499,9 +616,8 @@ static bool runtime_test_mac_ad_hoc_sign(const char *path) { } pid_t child = fork(); if (child == 0) { - execl("/usr/bin/codesign", "codesign", "--force", "--sign", "-", - "--timestamp=none", "--identifier", - "org.deusdata.cbm.foreign-test", path, (char *)NULL); + execl("/usr/bin/codesign", "codesign", "--force", "--sign", "-", "--timestamp=none", + "--identifier", "org.deusdata.cbm.foreign-test", path, (char *)NULL); _exit(127); } int status = 0; @@ -513,9 +629,10 @@ static bool runtime_test_mac_ad_hoc_sign(const char *path) { } #endif -static bool runtime_test_run_hello_image( - const char *image_path, const runtime_test_fixture_t *fixture, - const cbm_daemon_build_identity_t *identity, int *exit_code_out) { +static bool runtime_test_run_hello_image(const char *image_path, + const runtime_test_fixture_t *fixture, + const cbm_daemon_build_identity_t *identity, + int *exit_code_out) { if (!image_path || !fixture || !identity || !identity->semantic_version || !identity->build_fingerprint || !exit_code_out) { return false; @@ -523,28 +640,24 @@ static bool runtime_test_run_hello_image( *exit_code_out = -1; #ifdef _WIN32 char command_line[RUNTIME_TEST_PATH_CAP * 3]; - int written = snprintf( - command_line, sizeof(command_line), - "\"%s\" __cbm_runtime_hello_client \"%s\" %s %s %s", image_path, - fixture->parent, fixture->key, identity->semantic_version, - identity->build_fingerprint); + int written = + snprintf(command_line, sizeof(command_line), + "\"%s\" __cbm_runtime_hello_client \"%s\" %s %s %s", image_path, fixture->parent, + fixture->key, identity->semantic_version, identity->build_fingerprint); wchar_t *application = cbm_utf8_to_wide(image_path); - wchar_t *command = written > 0 && written < (int)sizeof(command_line) - ? cbm_utf8_to_wide(command_line) - : NULL; + wchar_t *command = + written > 0 && written < (int)sizeof(command_line) ? cbm_utf8_to_wide(command_line) : NULL; STARTUPINFOW startup; PROCESS_INFORMATION process; memset(&startup, 0, sizeof(startup)); memset(&process, 0, sizeof(process)); startup.cb = sizeof(startup); bool started = application && command && - CreateProcessW(application, command, NULL, NULL, FALSE, - CREATE_NO_WINDOW, NULL, NULL, &startup, - &process) != 0; + CreateProcessW(application, command, NULL, NULL, FALSE, CREATE_NO_WINDOW, NULL, + NULL, &startup, &process) != 0; free(command); free(application); - bool waited = started && - WaitForSingleObject(process.hProcess, 10000) == WAIT_OBJECT_0; + bool waited = started && WaitForSingleObject(process.hProcess, 10000) == WAIT_OBJECT_0; DWORD exit_code = 0; bool read = waited && GetExitCodeProcess(process.hProcess, &exit_code) != 0; if (started) { @@ -558,9 +671,8 @@ static bool runtime_test_run_hello_image( #elif defined(__APPLE__) || defined(__linux__) pid_t child = fork(); if (child == 0) { - execl(image_path, image_path, "__cbm_runtime_hello_client", - fixture->parent, fixture->key, identity->semantic_version, - identity->build_fingerprint, (char *)NULL); + execl(image_path, image_path, "__cbm_runtime_hello_client", fixture->parent, fixture->key, + identity->semantic_version, identity->build_fingerprint, (char *)NULL); _exit(127); } int status = 0; @@ -581,10 +693,11 @@ static bool runtime_test_run_hello_image( #endif } -static bool runtime_test_run_activation_image( - const char *image_path, const runtime_test_fixture_t *fixture, - const cbm_daemon_build_identity_t *identity, - cbm_daemon_runtime_activation_action_t action, int *exit_code_out) { +static bool runtime_test_run_activation_image(const char *image_path, + const runtime_test_fixture_t *fixture, + const cbm_daemon_build_identity_t *identity, + cbm_daemon_runtime_activation_action_t action, + int *exit_code_out) { if (!image_path || !fixture || !identity || !identity->semantic_version || !identity->build_fingerprint || !exit_code_out) { return false; @@ -592,37 +705,30 @@ static bool runtime_test_run_activation_image( *exit_code_out = -1; #ifdef _WIN32 char command_line[RUNTIME_TEST_PATH_CAP * 3]; - int written = snprintf( - command_line, sizeof(command_line), - "\"%s\" __cbm_runtime_activation_client \"%s\" %s %s %s %u", - image_path, fixture->parent, fixture->key, - identity->semantic_version, identity->build_fingerprint, - (unsigned int)action); + int written = snprintf(command_line, sizeof(command_line), + "\"%s\" __cbm_runtime_activation_client \"%s\" %s %s %s %u", image_path, + fixture->parent, fixture->key, identity->semantic_version, + identity->build_fingerprint, (unsigned int)action); wchar_t *application = cbm_utf8_to_wide(image_path); - wchar_t *command = written > 0 && written < (int)sizeof(command_line) - ? cbm_utf8_to_wide(command_line) - : NULL; + wchar_t *command = + written > 0 && written < (int)sizeof(command_line) ? cbm_utf8_to_wide(command_line) : NULL; STARTUPINFOW startup; PROCESS_INFORMATION process; memset(&startup, 0, sizeof(startup)); memset(&process, 0, sizeof(process)); startup.cb = sizeof(startup); bool started = application && command && - CreateProcessW(application, command, NULL, NULL, FALSE, - CREATE_NO_WINDOW, NULL, NULL, &startup, - &process) != 0; + CreateProcessW(application, command, NULL, NULL, FALSE, CREATE_NO_WINDOW, NULL, + NULL, &startup, &process) != 0; free(command); free(application); - bool waited = started && - WaitForSingleObject(process.hProcess, 10000) == - WAIT_OBJECT_0; + bool waited = started && WaitForSingleObject(process.hProcess, 10000) == WAIT_OBJECT_0; if (started && !waited) { (void)TerminateProcess(process.hProcess, 30); (void)WaitForSingleObject(process.hProcess, 5000); } DWORD exit_code = 0; - bool read = waited && - GetExitCodeProcess(process.hProcess, &exit_code) != 0; + bool read = waited && GetExitCodeProcess(process.hProcess, &exit_code) != 0; if (started) { (void)CloseHandle(process.hThread); (void)CloseHandle(process.hProcess); @@ -633,16 +739,13 @@ static bool runtime_test_run_activation_image( return read && exit_code <= INT_MAX; #elif defined(__APPLE__) || defined(__linux__) char action_text[16]; - int action_written = snprintf(action_text, sizeof(action_text), "%u", - (unsigned int)action); - pid_t child = action_written > 0 && action_written < (int)sizeof(action_text) - ? fork() - : -1; + int action_written = snprintf(action_text, sizeof(action_text), "%u", (unsigned int)action); + pid_t child = action_written > 0 && action_written < (int)sizeof(action_text) ? fork() : -1; if (child == 0) { (void)alarm(10); - execl(image_path, image_path, "__cbm_runtime_activation_client", - fixture->parent, fixture->key, identity->semantic_version, - identity->build_fingerprint, action_text, (char *)NULL); + execl(image_path, image_path, "__cbm_runtime_activation_client", fixture->parent, + fixture->key, identity->semantic_version, identity->build_fingerprint, action_text, + (char *)NULL); _exit(127); } int status = 0; @@ -665,22 +768,21 @@ static bool runtime_test_run_activation_image( } #ifdef __APPLE__ -static bool runtime_test_run_mapped_hello_image( - const char *image_path, const char *mapped_image_path, - const runtime_test_fixture_t *fixture, - const cbm_daemon_build_identity_t *identity, int *exit_code_out) { - if (!image_path || !mapped_image_path || !fixture || !identity || - !identity->semantic_version || !identity->build_fingerprint || - !exit_code_out) { +static bool runtime_test_run_mapped_hello_image(const char *image_path, + const char *mapped_image_path, + const runtime_test_fixture_t *fixture, + const cbm_daemon_build_identity_t *identity, + int *exit_code_out) { + if (!image_path || !mapped_image_path || !fixture || !identity || !identity->semantic_version || + !identity->build_fingerprint || !exit_code_out) { return false; } *exit_code_out = -1; pid_t child = fork(); if (child == 0) { - execl(image_path, image_path, "__cbm_runtime_mapped_hello_client", - mapped_image_path, fixture->parent, fixture->key, - identity->semantic_version, identity->build_fingerprint, - (char *)NULL); + execl(image_path, image_path, "__cbm_runtime_mapped_hello_client", mapped_image_path, + fixture->parent, fixture->key, identity->semantic_version, + identity->build_fingerprint, (char *)NULL); _exit(127); } int status = 0; @@ -697,8 +799,7 @@ static bool runtime_test_run_mapped_hello_image( #endif static cbm_daemon_runtime_application_session_t *runtime_application_session_open( - void *opaque, cbm_daemon_client_id_t client_id, - uint64_t authenticated_process_id) { + void *opaque, cbm_daemon_client_id_t client_id, uint64_t authenticated_process_id) { runtime_application_context_t *context = opaque; runtime_application_session_t *session = calloc(1, sizeof(*session)); if (!context || !session || client_id == CBM_DAEMON_CLIENT_ID_INVALID || @@ -710,15 +811,11 @@ static cbm_daemon_runtime_application_session_t *runtime_application_session_ope session->client_id = client_id; session->authenticated_process_id = authenticated_process_id; atomic_init(&session->cancel_requested, false); - int open_index = atomic_fetch_add_explicit(&context->opened, 1, - memory_order_relaxed); + int open_index = atomic_fetch_add_explicit(&context->opened, 1, memory_order_relaxed); if (open_index == 1 && - atomic_load_explicit(&context->block_second_open, - memory_order_acquire)) { - atomic_store_explicit(&context->second_open_started, true, - memory_order_release); - while (!atomic_load_explicit(&context->release_second_open, - memory_order_acquire)) { + atomic_load_explicit(&context->block_second_open, memory_order_acquire)) { + atomic_store_explicit(&context->second_open_started, true, memory_order_release); + while (!atomic_load_explicit(&context->release_second_open, memory_order_acquire)) { struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; (void)cbm_nanosleep(&pause, NULL); } @@ -728,33 +825,26 @@ static cbm_daemon_runtime_application_session_t *runtime_application_session_ope static cbm_daemon_runtime_application_status_t runtime_application_request( void *opaque, cbm_daemon_runtime_application_session_t *opaque_session, - cbm_daemon_runtime_application_token_t request_token, - const uint8_t *request, uint32_t request_length, uint8_t **response_out, - uint32_t *response_length_out) { + cbm_daemon_runtime_application_token_t request_token, const uint8_t *request, + uint32_t request_length, uint8_t **response_out, uint32_t *response_length_out) { (void)request_token; runtime_application_context_t *context = opaque; - runtime_application_session_t *session = - (runtime_application_session_t *)opaque_session; + runtime_application_session_t *session = (runtime_application_session_t *)opaque_session; if (!context || !session || session->context != context || !response_out || !response_length_out || (request_length > 0 && !request)) { return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR; } *response_out = NULL; *response_length_out = 0; - int request_index = - atomic_fetch_add_explicit(&context->requests, 1, memory_order_relaxed); + int request_index = atomic_fetch_add_explicit(&context->requests, 1, memory_order_relaxed); if (request_index == 0 && - atomic_load_explicit(&context->block_first_request, - memory_order_acquire)) { - atomic_store_explicit(&context->first_request_started, true, - memory_order_release); - bool ignore_cancel = atomic_load_explicit( - &context->ignore_first_request_cancel, memory_order_acquire); + atomic_load_explicit(&context->block_first_request, memory_order_acquire)) { + atomic_store_explicit(&context->first_request_started, true, memory_order_release); + bool ignore_cancel = + atomic_load_explicit(&context->ignore_first_request_cancel, memory_order_acquire); while (!(ignore_cancel - ? atomic_load_explicit(&context->release_first_request, - memory_order_acquire) - : atomic_load_explicit(&session->cancel_requested, - memory_order_acquire))) { + ? atomic_load_explicit(&context->release_first_request, memory_order_acquire) + : atomic_load_explicit(&session->cancel_requested, memory_order_acquire))) { struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; (void)cbm_nanosleep(&pause, NULL); } @@ -777,15 +867,11 @@ static void *runtime_application_client_request_thread(void *opaque) { runtime_application_client_call_t *call = opaque; call->status = call->tagged ? cbm_daemon_runtime_client_application_request_tagged( - call->client, call->request_token, call->request, - call->request_length, &call->response, - &call->response_length, - RUNTIME_TEST_TIMEOUT_MS) + call->client, call->request_token, call->request, call->request_length, + &call->response, &call->response_length, RUNTIME_TEST_TIMEOUT_MS) : cbm_daemon_runtime_client_application_request( - call->client, call->request, - call->request_length, &call->response, - &call->response_length, - RUNTIME_TEST_TIMEOUT_MS); + call->client, call->request, call->request_length, &call->response, + &call->response_length, RUNTIME_TEST_TIMEOUT_MS); if (call->completed) { /* This must remain the helper's final access to call/client state. A * failed OS join can then fall back to this lifetime sentinel without @@ -795,11 +881,41 @@ static void *runtime_application_client_request_thread(void *opaque) { return NULL; } +static void *runtime_real_application_detect_changes_thread(void *opaque) { + runtime_real_application_call_t *call = opaque; + uint32_t timeout_ms = call->timeout_ms ? call->timeout_ms : RUNTIME_TEST_TIMEOUT_MS; + call->status = + cbm_daemon_application_client_tool(call->client, "detect_changes", call->arguments, + &call->response, &call->response_length, timeout_ms); + atomic_store_explicit(&call->completed, true, memory_order_release); + return NULL; +} + +static bool runtime_real_application_ingest_probe(cbm_daemon_runtime_client_t *client) { + uint8_t *response = NULL; + uint32_t response_length = 0; + cbm_daemon_runtime_application_status_t status = + cbm_daemon_application_client_tool(client, "ingest_traces", "{\"traces\":[]}", &response, + &response_length, RUNTIME_TEST_TIMEOUT_MS); + bool usable = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response && response_length > 0 && + strstr((const char *)response, "traces_received"); + free(response); + return usable; +} + +static void runtime_test_restore_environment(const char *name, const char *saved_value, + bool was_set) { + if (was_set) { + (void)cbm_setenv(name, saved_value, 1); + } else { + (void)cbm_unsetenv(name); + } +} + static void *runtime_application_client_connect_thread(void *opaque) { runtime_application_connect_call_t *call = opaque; - call->client = cbm_daemon_runtime_client_connect( - call->endpoint, &call->identity, RUNTIME_TEST_TIMEOUT_MS, - &call->result); + call->client = cbm_daemon_runtime_client_connect(call->endpoint, &call->identity, + RUNTIME_TEST_TIMEOUT_MS, &call->result); atomic_store_explicit(&call->completed, true, memory_order_release); return NULL; } @@ -809,41 +925,33 @@ static void runtime_application_request_cancel( cbm_daemon_runtime_application_token_t request_token) { (void)request_token; runtime_application_context_t *context = opaque; - runtime_application_session_t *session = - (runtime_application_session_t *)opaque_session; + runtime_application_session_t *session = (runtime_application_session_t *)opaque_session; if (!context || !session || session->context != context) { return; } - atomic_store_explicit(&session->cancel_requested, true, - memory_order_release); - (void)atomic_fetch_add_explicit(&context->request_cancels, 1, - memory_order_relaxed); + atomic_store_explicit(&session->cancel_requested, true, memory_order_release); + (void)atomic_fetch_add_explicit(&context->request_cancels, 1, memory_order_relaxed); } static void runtime_application_session_cancel( void *opaque, cbm_daemon_runtime_application_session_t *opaque_session) { runtime_application_context_t *context = opaque; - runtime_application_session_t *session = - (runtime_application_session_t *)opaque_session; + runtime_application_session_t *session = (runtime_application_session_t *)opaque_session; if (!context || !session || session->context != context) { return; } - atomic_store_explicit(&session->cancel_requested, true, - memory_order_release); - (void)atomic_fetch_add_explicit(&context->cancelled, 1, - memory_order_relaxed); + atomic_store_explicit(&session->cancel_requested, true, memory_order_release); + (void)atomic_fetch_add_explicit(&context->cancelled, 1, memory_order_relaxed); } static void runtime_application_session_close( void *opaque, cbm_daemon_runtime_application_session_t *opaque_session) { runtime_application_context_t *context = opaque; - runtime_application_session_t *session = - (runtime_application_session_t *)opaque_session; + runtime_application_session_t *session = (runtime_application_session_t *)opaque_session; if (!context || !session || session->context != context) { return; } - (void)atomic_fetch_add_explicit(&context->closed, 1, - memory_order_relaxed); + (void)atomic_fetch_add_explicit(&context->closed, 1, memory_order_relaxed); free(session); } @@ -877,8 +985,7 @@ static void runtime_application_context_init(runtime_application_context_t *cont atomic_init(&context->release_second_open, false); } -static bool runtime_test_wait_atomic_bool(atomic_bool *value, - uint32_t timeout_ms) { +static bool runtime_test_wait_atomic_bool(atomic_bool *value, uint32_t timeout_ms) { uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms; while (!atomic_load_explicit(value, memory_order_acquire)) { if (cbm_now_ms() >= deadline) { @@ -890,8 +997,7 @@ static bool runtime_test_wait_atomic_bool(atomic_bool *value, return true; } -static bool runtime_test_wait_atomic_int(atomic_int *value, int expected, - uint32_t timeout_ms) { +static bool runtime_test_wait_atomic_int(atomic_int *value, int expected, uint32_t timeout_ms) { uint64_t deadline = cbm_now_ms() + (uint64_t)timeout_ms; while (atomic_load_explicit(value, memory_order_acquire) != expected) { if (cbm_now_ms() >= deadline) { @@ -911,8 +1017,8 @@ static void runtime_test_put_u32(uint8_t out[4], uint32_t value) { } static uint32_t runtime_test_get_u32(const uint8_t in[4]) { - return ((uint32_t)in[0] << 24) | ((uint32_t)in[1] << 16) | - ((uint32_t)in[2] << 8) | (uint32_t)in[3]; + return ((uint32_t)in[0] << 24) | ((uint32_t)in[1] << 16) | ((uint32_t)in[2] << 8) | + (uint32_t)in[3]; } static void runtime_test_put_u64(uint8_t out[8], uint64_t value) { @@ -930,8 +1036,7 @@ static uint64_t runtime_test_get_u64(const uint8_t in[8]) { } static bool runtime_test_raw_activation_exchange( - const cbm_daemon_ipc_endpoint_t *endpoint, const uint8_t *request, - uint32_t request_length, + const cbm_daemon_ipc_endpoint_t *endpoint, const uint8_t *request, uint32_t request_length, cbm_daemon_runtime_activation_result_t *result_out) { if (result_out) { memset(result_out, 0, sizeof(*result_out)); @@ -941,33 +1046,25 @@ static bool runtime_test_raw_activation_exchange( } cbm_daemon_ipc_connection_t *connection = cbm_daemon_ipc_connect(endpoint, RUNTIME_TEST_TIMEOUT_MS); - bool sent = connection && cbm_daemon_ipc_send_frame( - connection, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN, - request, request_length); + bool sent = connection && cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN, + request, request_length); cbm_daemon_frame_t frame = {0}; uint8_t *response = NULL; - int received = sent ? cbm_daemon_ipc_receive_frame( - connection, RUNTIME_TEST_TIMEOUT_MS, &frame, - &response) - : 0; - uint32_t status = received == 1 && response - ? runtime_test_get_u32(response + 4) - : UINT32_MAX; - bool valid = received == 1 && - frame.type == CBM_DAEMON_FRAME_RESPONSE && + int received = + sent ? cbm_daemon_ipc_receive_frame(connection, RUNTIME_TEST_TIMEOUT_MS, &frame, &response) + : 0; + uint32_t status = received == 1 && response ? runtime_test_get_u32(response + 4) : UINT32_MAX; + bool valid = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && frame.flags == CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN && - frame.length == RUNTIME_TEST_ACTIVATION_RESPONSE_SIZE && - response && - runtime_test_get_u32(response) == - RUNTIME_TEST_RENDEZVOUS_ABI && - status <= 1U; + frame.length == RUNTIME_TEST_ACTIVATION_RESPONSE_SIZE && response && + runtime_test_get_u32(response) == RUNTIME_TEST_RENDEZVOUS_ABI && status <= 1U; if (valid) { result_out->accepted = status == 1U; - result_out->active_clients = runtime_test_get_u64( - response + RUNTIME_TEST_ACTIVATION_RESPONSE_CLIENTS_OFFSET); - result_out->active_connections = runtime_test_get_u64( - response + RUNTIME_TEST_ACTIVATION_RESPONSE_CONNECTIONS_OFFSET); + result_out->active_clients = + runtime_test_get_u64(response + RUNTIME_TEST_ACTIVATION_RESPONSE_CLIENTS_OFFSET); + result_out->active_connections = + runtime_test_get_u64(response + RUNTIME_TEST_ACTIVATION_RESPONSE_CONNECTIONS_OFFSET); } free(response); cbm_daemon_ipc_connection_close(connection); @@ -976,26 +1073,23 @@ static bool runtime_test_raw_activation_exchange( static bool runtime_test_activation_request_encode( uint8_t out[RUNTIME_TEST_ACTIVATION_REQUEST_SIZE], - cbm_daemon_runtime_activation_action_t action, - const cbm_daemon_build_identity_t *identity) { + cbm_daemon_runtime_activation_action_t action, const cbm_daemon_build_identity_t *identity) { if (!out || !identity) { return false; } memset(out, 0, RUNTIME_TEST_ACTIVATION_REQUEST_SIZE); runtime_test_put_u32(out, (uint32_t)action); - return cbm_daemon_runtime_hello_request_encode( - out + RUNTIME_TEST_ACTIVATION_IDENTITY_OFFSET, identity); + return cbm_daemon_runtime_hello_request_encode(out + RUNTIME_TEST_ACTIVATION_IDENTITY_OFFSET, + identity); } -static bool runtime_test_fixed_string_equals(const uint8_t *wire, - size_t capacity, +static bool runtime_test_fixed_string_equals(const uint8_t *wire, size_t capacity, const char *expected) { if (!wire || !expected) { return false; } size_t length = strlen(expected); - if (length >= capacity || memcmp(wire, expected, length) != 0 || - wire[length] != 0) { + if (length >= capacity || memcmp(wire, expected, length) != 0 || wire[length] != 0) { return false; } for (size_t i = length + 1; i < capacity; i++) { @@ -1007,8 +1101,7 @@ static bool runtime_test_fixed_string_equals(const uint8_t *wire, } static cbm_daemon_ipc_connection_t *runtime_test_raw_client_connect( - const cbm_daemon_ipc_endpoint_t *endpoint, - const cbm_daemon_build_identity_t *identity) { + const cbm_daemon_ipc_endpoint_t *endpoint, const cbm_daemon_build_identity_t *identity) { uint8_t hello[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE]; if (!cbm_daemon_runtime_hello_request_encode(hello, identity)) { return NULL; @@ -1017,19 +1110,16 @@ static cbm_daemon_ipc_connection_t *runtime_test_raw_client_connect( cbm_daemon_ipc_connect(endpoint, RUNTIME_TEST_TIMEOUT_MS); if (!connection || !cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_HELLO, hello, - (uint32_t)sizeof(hello))) { + CBM_DAEMON_RUNTIME_OP_HELLO, hello, (uint32_t)sizeof(hello))) { cbm_daemon_ipc_connection_close(connection); return NULL; } cbm_daemon_frame_t frame = {0}; uint8_t *payload = NULL; - int received = cbm_daemon_ipc_receive_frame(connection, - RUNTIME_TEST_TIMEOUT_MS, - &frame, &payload); + int received = + cbm_daemon_ipc_receive_frame(connection, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); bool accepted = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && - frame.flags == CBM_DAEMON_RUNTIME_OP_HELLO && - frame.length > 0; + frame.flags == CBM_DAEMON_RUNTIME_OP_HELLO && frame.length > 0; free(payload); if (!accepted) { cbm_daemon_ipc_connection_close(connection); @@ -1038,9 +1128,10 @@ static cbm_daemon_ipc_connection_t *runtime_test_raw_client_connect( return connection; } -static bool runtime_test_raw_application_send_token( - cbm_daemon_ipc_connection_t *connection, uint64_t request_token, - const void *payload, uint32_t payload_length, uint32_t declared_length) { +static bool runtime_test_raw_application_send_token(cbm_daemon_ipc_connection_t *connection, + uint64_t request_token, const void *payload, + uint32_t payload_length, + uint32_t declared_length) { uint64_t wire_length = 12ULL + payload_length; if (!connection || request_token == 0 || wire_length > UINT32_MAX || (payload_length > 0 && !payload)) { @@ -1055,52 +1146,45 @@ static bool runtime_test_raw_application_send_token( if (payload_length > 0) { memcpy(wire + 12, payload, payload_length); } - bool sent = cbm_daemon_ipc_send_frame( - connection, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST, wire, - (uint32_t)wire_length); + bool sent = cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST, wire, + (uint32_t)wire_length); free(wire); return sent; } -static bool runtime_test_raw_application_send( - cbm_daemon_ipc_connection_t *connection, const void *payload, - uint32_t payload_length, uint32_t declared_length) { +static bool runtime_test_raw_application_send(cbm_daemon_ipc_connection_t *connection, + const void *payload, uint32_t payload_length, + uint32_t declared_length) { static atomic_uint_fast64_t next_token = ATOMIC_VAR_INIT(1); - uint64_t request_token = atomic_fetch_add_explicit( - &next_token, 1, memory_order_relaxed); - return runtime_test_raw_application_send_token( - connection, request_token, payload, payload_length, declared_length); + uint64_t request_token = atomic_fetch_add_explicit(&next_token, 1, memory_order_relaxed); + return runtime_test_raw_application_send_token(connection, request_token, payload, + payload_length, declared_length); } -static bool runtime_test_raw_application_cancel( - cbm_daemon_ipc_connection_t *connection, uint64_t request_token) { +static bool runtime_test_raw_application_cancel(cbm_daemon_ipc_connection_t *connection, + uint64_t request_token) { uint8_t wire[8]; if (!connection || request_token == 0) { return false; } runtime_test_put_u64(wire, request_token); - return cbm_daemon_ipc_send_frame( - connection, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, wire, - (uint32_t)sizeof(wire)); + return cbm_daemon_ipc_send_frame(connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, wire, + (uint32_t)sizeof(wire)); } static bool runtime_test_raw_application_receive_status_token( - cbm_daemon_ipc_connection_t *connection, - uint64_t expected_token, + cbm_daemon_ipc_connection_t *connection, uint64_t expected_token, cbm_daemon_runtime_application_status_t expected_status) { cbm_daemon_frame_t frame = {0}; uint8_t *payload = NULL; - int received = cbm_daemon_ipc_receive_frame(connection, - RUNTIME_TEST_TIMEOUT_MS, - &frame, &payload); + int received = + cbm_daemon_ipc_receive_frame(connection, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); bool valid = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && - frame.flags == CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST && - frame.length >= 16 && payload && - runtime_test_get_u64(payload) == expected_token && - runtime_test_get_u32(payload + 8) == - (uint32_t)expected_status && + frame.flags == CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST && frame.length >= 16 && + payload && runtime_test_get_u64(payload) == expected_token && + runtime_test_get_u32(payload + 8) == (uint32_t)expected_status && runtime_test_get_u32(payload + 12) == frame.length - 16; free(payload); return valid; @@ -1111,15 +1195,12 @@ static bool runtime_test_raw_application_receive_status( cbm_daemon_runtime_application_status_t expected_status) { cbm_daemon_frame_t frame = {0}; uint8_t *payload = NULL; - int received = cbm_daemon_ipc_receive_frame(connection, - RUNTIME_TEST_TIMEOUT_MS, - &frame, &payload); + int received = + cbm_daemon_ipc_receive_frame(connection, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); bool valid = received == 1 && frame.type == CBM_DAEMON_FRAME_RESPONSE && - frame.flags == CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST && - frame.length >= 16 && payload && - runtime_test_get_u64(payload) != 0 && - runtime_test_get_u32(payload + 8) == - (uint32_t)expected_status && + frame.flags == CBM_DAEMON_RUNTIME_OP_APPLICATION_REQUEST && frame.length >= 16 && + payload && runtime_test_get_u64(payload) != 0 && + runtime_test_get_u32(payload + 8) == (uint32_t)expected_status && runtime_test_get_u32(payload + 12) == frame.length - 16; free(payload); return valid; @@ -1133,68 +1214,55 @@ static long long runtime_test_last_os_error(void) { #endif } -static bool runtime_test_fixture_start_failed(const char *tag, - const char *stage, +static bool runtime_test_fixture_start_failed(const char *tag, const char *stage, long long detail) { - printf(" runtime fixture startup failed: tag=%s stage=%s detail=%lld\n", - tag ? tag : "(null)", stage ? stage : "(null)", detail); + printf(" runtime fixture startup failed: tag=%s stage=%s detail=%lld\n", tag ? tag : "(null)", + stage ? stage : "(null)", detail); return false; } static bool runtime_test_fixture_start_configured( - runtime_test_fixture_t *fixture, const char *tag, - const cbm_daemon_build_identity_t *identity, uint32_t max_clients, - uint64_t lease_timeout_ms, + runtime_test_fixture_t *fixture, const char *tag, const cbm_daemon_build_identity_t *identity, + uint32_t max_clients, uint64_t lease_timeout_ms, const cbm_daemon_runtime_application_callbacks_t *application) { memset(fixture, 0, sizeof(*fixture)); int parent_written = snprintf(fixture->parent, sizeof(fixture->parent), "%s/cbm-runtime-%s-XXXXXX", cbm_tmpdir(), tag); if (parent_written <= 0 || parent_written >= (int)sizeof(fixture->parent)) { - return runtime_test_fixture_start_failed(tag, "temporary-path", - parent_written); + return runtime_test_fixture_start_failed(tag, "temporary-path", parent_written); } if (!cbm_mkdtemp(fixture->parent)) { - return runtime_test_fixture_start_failed( - tag, "temporary-directory", runtime_test_last_os_error()); + return runtime_test_fixture_start_failed(tag, "temporary-directory", + runtime_test_last_os_error()); } if (!cbm_daemon_rendezvous_key(fixture->key)) { - return runtime_test_fixture_start_failed( - tag, "rendezvous-key", runtime_test_last_os_error()); + return runtime_test_fixture_start_failed(tag, "rendezvous-key", + runtime_test_last_os_error()); } - fixture->endpoint = - cbm_daemon_ipc_endpoint_new(fixture->key, fixture->parent); + fixture->endpoint = cbm_daemon_ipc_endpoint_new(fixture->key, fixture->parent); if (!fixture->endpoint) { - return runtime_test_fixture_start_failed( - tag, "endpoint", runtime_test_last_os_error()); + return runtime_test_fixture_start_failed(tag, "endpoint", runtime_test_last_os_error()); } - if (!runtime_test_copy_path( - fixture->runtime_dir, - cbm_daemon_ipc_endpoint_runtime_dir(fixture->endpoint))) { + if (!runtime_test_copy_path(fixture->runtime_dir, + cbm_daemon_ipc_endpoint_runtime_dir(fixture->endpoint))) { return runtime_test_fixture_start_failed(tag, "runtime-path", 0); } int log_written = snprintf(fixture->log_path, sizeof(fixture->log_path), "%s/conflicts.ndjson", fixture->parent); - int rotated_written = snprintf(fixture->rotated_log_path, - sizeof(fixture->rotated_log_path), "%s.1", - fixture->log_path); - int lock_written = snprintf(fixture->lock_log_path, - sizeof(fixture->lock_log_path), "%s.lock", + int rotated_written = snprintf(fixture->rotated_log_path, sizeof(fixture->rotated_log_path), + "%s.1", fixture->log_path); + int lock_written = snprintf(fixture->lock_log_path, sizeof(fixture->lock_log_path), "%s.lock", fixture->log_path); if (log_written <= 0 || log_written >= (int)sizeof(fixture->log_path)) { - return runtime_test_fixture_start_failed(tag, "conflict-log-path", - log_written); + return runtime_test_fixture_start_failed(tag, "conflict-log-path", log_written); } - if (rotated_written <= 0 || - rotated_written >= (int)sizeof(fixture->rotated_log_path)) { - return runtime_test_fixture_start_failed(tag, "rotated-log-path", - rotated_written); + if (rotated_written <= 0 || rotated_written >= (int)sizeof(fixture->rotated_log_path)) { + return runtime_test_fixture_start_failed(tag, "rotated-log-path", rotated_written); } - if (lock_written <= 0 || - lock_written >= (int)sizeof(fixture->lock_log_path)) { - return runtime_test_fixture_start_failed(tag, "lock-log-path", - lock_written); + if (lock_written <= 0 || lock_written >= (int)sizeof(fixture->lock_log_path)) { + return runtime_test_fixture_start_failed(tag, "lock-log-path", lock_written); } cbm_daemon_runtime_service_config_t config = { @@ -1212,24 +1280,20 @@ static bool runtime_test_fixture_start_configured( } fixture->service = cbm_daemon_runtime_service_start(&config); if (!fixture->service) { - return runtime_test_fixture_start_failed( - tag, "service-start", runtime_test_last_os_error()); + return runtime_test_fixture_start_failed(tag, "service-start", + runtime_test_last_os_error()); } - cbm_daemon_runtime_service_state_t state = - cbm_daemon_runtime_service_state(fixture->service); + cbm_daemon_runtime_service_state_t state = cbm_daemon_runtime_service_state(fixture->service); if (state != CBM_DAEMON_RUNTIME_SERVICE_RUNNING) { - return runtime_test_fixture_start_failed(tag, "service-state", - (long long)state); + return runtime_test_fixture_start_failed(tag, "service-state", (long long)state); } return true; } -static bool runtime_test_fixture_start_limited(runtime_test_fixture_t *fixture, - const char *tag, +static bool runtime_test_fixture_start_limited(runtime_test_fixture_t *fixture, const char *tag, const cbm_daemon_build_identity_t *identity, uint32_t max_clients) { - return runtime_test_fixture_start_configured(fixture, tag, identity, - max_clients, 5000, NULL); + return runtime_test_fixture_start_configured(fixture, tag, identity, max_clients, 5000, NULL); } static bool runtime_test_fixture_start(runtime_test_fixture_t *fixture, const char *tag, @@ -1247,21 +1311,15 @@ TEST(daemon_runtime_convenience_service_owns_participant_guard) { runtime_test_identity("2.4.0", runtime_test_self_build()); runtime_test_fixture_t fixture; bool started = runtime_test_fixture_start(&fixture, "participant", &identity); - int active = started - ? cbm_daemon_ipc_legacy_generation_probe(fixture.endpoint) - : -1; - bool running_free_refused = - started && !cbm_daemon_runtime_service_free(fixture.service); - bool stopped = started && cbm_daemon_runtime_service_stop( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); - bool freed = stopped && - cbm_daemon_runtime_service_free(fixture.service); + int active = started ? cbm_daemon_ipc_legacy_generation_probe(fixture.endpoint) : -1; + bool running_free_refused = started && !cbm_daemon_runtime_service_free(fixture.service); + bool stopped = + started && cbm_daemon_runtime_service_stop(fixture.service, RUNTIME_TEST_TIMEOUT_MS); + bool freed = stopped && cbm_daemon_runtime_service_free(fixture.service); if (freed) { fixture.service = NULL; } - int released = freed - ? cbm_daemon_ipc_legacy_generation_probe(fixture.endpoint) - : -1; + int released = freed ? cbm_daemon_ipc_legacy_generation_probe(fixture.endpoint) : -1; runtime_test_fixture_finish(&fixture); ASSERT_TRUE(started); @@ -1273,14 +1331,11 @@ TEST(daemon_runtime_convenience_service_owns_participant_guard) { PASS(); } -static bool runtime_test_fixture_start_application( - runtime_test_fixture_t *fixture, const char *tag, - const cbm_daemon_build_identity_t *identity, - runtime_application_context_t *context) { - cbm_daemon_runtime_application_callbacks_t application = - runtime_application_callbacks(context); - return runtime_test_fixture_start_configured(fixture, tag, identity, 8, - 5000, &application); +static bool runtime_test_fixture_start_application(runtime_test_fixture_t *fixture, const char *tag, + const cbm_daemon_build_identity_t *identity, + runtime_application_context_t *context) { + cbm_daemon_runtime_application_callbacks_t application = runtime_application_callbacks(context); + return runtime_test_fixture_start_configured(fixture, tag, identity, 8, 5000, &application); } static void runtime_test_fixture_finish(runtime_test_fixture_t *fixture) { @@ -1288,8 +1343,7 @@ static void runtime_test_fixture_finish(runtime_test_fixture_t *fixture) { cbm_daemon_runtime_service_state_t state = cbm_daemon_runtime_service_state(fixture->service); if (state != CBM_DAEMON_RUNTIME_SERVICE_EXITED) { - (void)cbm_daemon_runtime_service_stop(fixture->service, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_service_stop(fixture->service, RUNTIME_TEST_TIMEOUT_MS); } (void)cbm_daemon_runtime_service_free(fixture->service); } @@ -1314,25 +1368,109 @@ static bool runtime_test_read_log(const char *path, char out[RUNTIME_TEST_LOG_CA return complete; } +/* RED on the former host preparation contract: a failed _config.db open was + * silently converted into default settings, so startup continued and could + * enable background behavior the user had explicitly disabled. */ +TEST(daemon_host_refuses_unopenable_runtime_config_database) { + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + bool snapshot_ok = !had_cache || saved_cache; + + char parent[RUNTIME_TEST_PATH_CAP] = {0}; + char cache[RUNTIME_TEST_PATH_CAP] = {0}; + char config_blocker[RUNTIME_TEST_PATH_CAP] = {0}; + int parent_written = + snprintf(parent, sizeof(parent), "%s/cbm-host-config-XXXXXX", cbm_tmpdir()); + bool parent_created = snapshot_ok && parent_written > 0 && + parent_written < (int)sizeof(parent) && cbm_mkdtemp(parent) != NULL; + int cache_written = parent_created ? snprintf(cache, sizeof(cache), "%s/cache", parent) : -1; + int blocker_written = + parent_created ? snprintf(config_blocker, sizeof(config_blocker), "%s/_config.db", cache) + : -1; + bool blocker_created = cache_written > 0 && cache_written < (int)sizeof(cache) && + blocker_written > 0 && blocker_written < (int)sizeof(config_blocker) && + cbm_mkdir_p(cache, 0700) && cbm_mkdir_p(config_blocker, 0700); + bool environment_ready = blocker_created && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + cbm_daemon_ipc_endpoint_t *endpoint = + environment_ready ? cbm_daemon_ipc_endpoint_new("0123456789abcdef", parent) : NULL; + bool endpoint_created = endpoint != NULL; + bool prepared = endpoint && cbm_daemon_host_state_prepare_for_test(endpoint); + + cbm_daemon_ipc_endpoint_free(endpoint); + runtime_test_restore_environment("CBM_CACHE_DIR", saved_cache, had_cache); + free(saved_cache); + bool cleaned = !parent_created || th_rmtree(parent) == 0; + + ASSERT_TRUE(snapshot_ok); + ASSERT_TRUE(parent_created); + ASSERT_TRUE(blocker_created); + ASSERT_TRUE(environment_ready); + ASSERT_TRUE(endpoint_created); + ASSERT_FALSE(prepared); + ASSERT_TRUE(cleaned); + PASS(); +} + +TEST(daemon_host_http_reconcile_rate_limits_and_retries_transient_failures) { + const uint64_t timestamps[] = { + 0, 100, 999, 1000, 1500, 2000, 2999, 3000, + }; + cbm_daemon_host_http_reconcile_test_result_t result = {0}; + bool driven = cbm_daemon_host_http_reconcile_sequence_for_test( + timestamps, sizeof(timestamps) / sizeof(timestamps[0]), 1, 1, &result); + + ASSERT_TRUE(driven); + ASSERT_EQ(result.config_loads, 4); + ASSERT_EQ(result.server_create_attempts, 3); + ASSERT_EQ(result.thread_start_attempts, 2); + ASSERT_TRUE(result.active_after_sequence); + ASSERT_EQ(result.largest_scheduled_retry_ms, 2000); + ASSERT_EQ(result.next_retry_ms, 0); + ASSERT_EQ(result.server_stops, 1); + ASSERT_EQ(result.server_frees, 2); + ASSERT_EQ(result.thread_joins, 1); + PASS(); +} + +TEST(daemon_host_http_retry_backoff_is_bounded) { + const uint64_t timestamps[] = { + 0, 1000, 3000, 7000, 15000, 31000, 61000, 91000, + }; + cbm_daemon_host_http_reconcile_test_result_t result = {0}; + bool driven = cbm_daemon_host_http_reconcile_sequence_for_test( + timestamps, sizeof(timestamps) / sizeof(timestamps[0]), + sizeof(timestamps) / sizeof(timestamps[0]), 0, &result); + + ASSERT_TRUE(driven); + ASSERT_EQ(result.config_loads, sizeof(timestamps) / sizeof(timestamps[0])); + ASSERT_EQ(result.server_create_attempts, sizeof(timestamps) / sizeof(timestamps[0])); + ASSERT_EQ(result.thread_start_attempts, 0); + ASSERT_FALSE(result.active_after_sequence); + ASSERT_EQ(result.largest_scheduled_retry_ms, 30000); + ASSERT_EQ(result.next_retry_ms, 121000); + ASSERT_EQ(result.server_stops, 0); + ASSERT_EQ(result.server_frees, 0); + ASSERT_EQ(result.thread_joins, 0); + PASS(); +} + #ifndef _WIN32 -static int runtime_test_failed_host_child(const char *parent, - const char *key) { +static int runtime_test_failed_host_child(const char *parent, const char *key) { char cache[RUNTIME_TEST_PATH_CAP]; char log_path[RUNTIME_TEST_PATH_CAP]; int cache_written = snprintf(cache, sizeof(cache), "%s/cache", parent); - int log_written = snprintf(log_path, sizeof(log_path), - "%s/logs/cbm-daemon.log", cache); - if (cache_written <= 0 || cache_written >= (int)sizeof(cache) || - log_written <= 0 || log_written >= (int)sizeof(log_path) || - cbm_setenv("CBM_CACHE_DIR", cache, 1) != 0) { + int log_written = snprintf(log_path, sizeof(log_path), "%s/logs/cbm-daemon.log", cache); + if (cache_written <= 0 || cache_written >= (int)sizeof(cache) || log_written <= 0 || + log_written >= (int)sizeof(log_path) || cbm_setenv("CBM_CACHE_DIR", cache, 1) != 0) { return 40; } - cbm_daemon_ipc_endpoint_t *endpoint = - cbm_daemon_ipc_endpoint_new(key, parent); + cbm_daemon_ipc_endpoint_t *endpoint = cbm_daemon_ipc_endpoint_new(key, parent); atomic_int stop_requested = ATOMIC_VAR_INIT(0); - cbm_daemon_build_identity_t mismatched = runtime_test_identity( - "host-order-test", RUNTIME_BUILD_B); + cbm_daemon_build_identity_t mismatched = + runtime_test_identity("host-order-test", RUNTIME_BUILD_B); + mismatched.cache_fingerprint = RUNTIME_CACHE_A; cbm_daemon_host_config_t config = { .endpoint = endpoint, .identity = mismatched, @@ -1344,8 +1482,7 @@ static int runtime_test_failed_host_child(const char *parent, char log[RUNTIME_TEST_LOG_CAP] = {0}; bool read = runtime_test_read_log(log_path, log); bool failed_at_runtime = - read && strstr(log, "daemon.start_failed") != NULL && - strstr(log, "runtime") != NULL; + read && strstr(log, "daemon.start_failed") != NULL && strstr(log, "runtime") != NULL; bool watcher_started = read && strstr(log, "watcher.start") != NULL; cbm_daemon_ipc_endpoint_free(endpoint); if (run_result != -1 || !failed_at_runtime) { @@ -1359,18 +1496,15 @@ static int runtime_test_failed_host_child(const char *parent, * always contained watcher.start even though no daemon could serve a client. */ TEST(daemon_host_failed_listener_reservation_starts_no_background_work) { char parent[RUNTIME_TEST_PATH_CAP]; - int written = snprintf(parent, sizeof(parent), - "%s/cbm-host-order-XXXXXX", cbm_tmpdir()); - bool parent_created = written > 0 && written < (int)sizeof(parent) && - cbm_mkdtemp(parent) != NULL; + int written = snprintf(parent, sizeof(parent), "%s/cbm-host-order-XXXXXX", cbm_tmpdir()); + bool parent_created = + written > 0 && written < (int)sizeof(parent) && cbm_mkdtemp(parent) != NULL; char key[CBM_DAEMON_KEY_SIZE] = {0}; - cbm_daemon_ipc_endpoint_t *endpoint = - parent_created && cbm_daemon_rendezvous_key(key) - ? cbm_daemon_ipc_endpoint_new(key, parent) - : NULL; + cbm_daemon_ipc_endpoint_t *endpoint = parent_created && cbm_daemon_rendezvous_key(key) + ? cbm_daemon_ipc_endpoint_new(key, parent) + : NULL; cbm_daemon_ipc_startup_lock_t *startup = NULL; - bool setup = endpoint && - cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) == 1 && + bool setup = endpoint && cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) == 1 && cbm_daemon_ipc_startup_lock_prepare_handoff(startup); pid_t child = setup ? fork() : -1; if (child == 0) { @@ -1379,8 +1513,7 @@ TEST(daemon_host_failed_listener_reservation_starts_no_background_work) { int status = 0; bool waited = child > 0 && waitpid(child, &status, 0) == child; - bool startup_released = - cbm_daemon_ipc_startup_lock_release(&startup); + bool startup_released = cbm_daemon_ipc_startup_lock_release(&startup); cbm_daemon_ipc_endpoint_free(endpoint); th_cleanup(parent_created ? parent : NULL); @@ -1393,8 +1526,7 @@ TEST(daemon_host_failed_listener_reservation_starts_no_background_work) { PASS(); } -static _Noreturn void runtime_test_forced_host_shutdown_child( - const char *parent) { +static _Noreturn void runtime_test_forced_host_shutdown_child(const char *parent) { char cache[RUNTIME_TEST_PATH_CAP]; int written = snprintf(cache, sizeof(cache), "%s/cache", parent); if (written <= 0 || written >= (int)sizeof(cache) || @@ -1410,8 +1542,7 @@ static bool runtime_test_cleanup_release_never_succeeds(void *context) { return false; } -static _Noreturn void runtime_test_persistent_cleanup_failure_child( - const char *parent) { +static _Noreturn void runtime_test_persistent_cleanup_failure_child(const char *parent) { char cache[RUNTIME_TEST_PATH_CAP]; int written = snprintf(cache, sizeof(cache), "%s/cache", parent); if (written <= 0 || written >= (int)sizeof(cache) || @@ -1432,15 +1563,13 @@ static _Noreturn void runtime_test_persistent_cleanup_failure_child( TEST(daemon_host_persistent_cleanup_release_failure_is_process_bounded) { char parent[RUNTIME_TEST_PATH_CAP]; char log_path[RUNTIME_TEST_PATH_CAP]; - int parent_written = snprintf(parent, sizeof(parent), - "%s/cbm-host-cleanup-XXXXXX", cbm_tmpdir()); - bool parent_created = parent_written > 0 && - parent_written < (int)sizeof(parent) && - cbm_mkdtemp(parent) != NULL; - int log_written = parent_created - ? snprintf(log_path, sizeof(log_path), - "%s/cache/logs/cbm-daemon.log", parent) - : -1; + int parent_written = + snprintf(parent, sizeof(parent), "%s/cbm-host-cleanup-XXXXXX", cbm_tmpdir()); + bool parent_created = + parent_written > 0 && parent_written < (int)sizeof(parent) && cbm_mkdtemp(parent) != NULL; + int log_written = parent_created ? snprintf(log_path, sizeof(log_path), + "%s/cache/logs/cbm-daemon.log", parent) + : -1; bool path_ok = log_written > 0 && log_written < (int)sizeof(log_path); pid_t child = path_ok ? fork() : -1; if (child == 0) { @@ -1463,8 +1592,7 @@ TEST(daemon_host_persistent_cleanup_release_failure_is_process_bounded) { } if (child > 0 && !completed) { (void)kill(child, SIGKILL); - while (waitpid(child, &status, 0) < 0 && errno == EINTR) { - } + while (waitpid(child, &status, 0) < 0 && errno == EINTR) {} } char log[RUNTIME_TEST_LOG_CAP] = {0}; @@ -1486,15 +1614,12 @@ TEST(daemon_host_persistent_cleanup_release_failure_is_process_bounded) { TEST(daemon_host_forced_shutdown_is_logged_flushed_and_process_bounded) { char parent[RUNTIME_TEST_PATH_CAP]; char log_path[RUNTIME_TEST_PATH_CAP]; - int parent_written = snprintf(parent, sizeof(parent), - "%s/cbm-host-force-XXXXXX", cbm_tmpdir()); - bool parent_created = parent_written > 0 && - parent_written < (int)sizeof(parent) && - cbm_mkdtemp(parent) != NULL; - int log_written = parent_created - ? snprintf(log_path, sizeof(log_path), - "%s/cache/logs/cbm-daemon.log", parent) - : -1; + int parent_written = snprintf(parent, sizeof(parent), "%s/cbm-host-force-XXXXXX", cbm_tmpdir()); + bool parent_created = + parent_written > 0 && parent_written < (int)sizeof(parent) && cbm_mkdtemp(parent) != NULL; + int log_written = parent_created ? snprintf(log_path, sizeof(log_path), + "%s/cache/logs/cbm-daemon.log", parent) + : -1; bool path_ok = log_written > 0 && log_written < (int)sizeof(log_path); pid_t child = path_ok ? fork() : -1; if (child == 0) { @@ -1544,15 +1669,13 @@ TEST(daemon_runtime_exact_hello_issues_connection_bound_identity) { result.hello_status == CBM_DAEMON_HELLO_COMPATIBLE && result.client_id != CBM_DAEMON_CLIENT_ID_INVALID && result.client_id == cbm_daemon_runtime_client_id(client); - identity_anchored = - result.authenticated_process_id == expected_pid && - cbm_daemon_runtime_client_process_id(client) == expected_pid && - cbm_daemon_runtime_service_client_process_id(fixture.service, result.client_id) == - expected_pid; + identity_anchored = result.authenticated_process_id == expected_pid && + cbm_daemon_runtime_client_process_id(client) == expected_pid && + cbm_daemon_runtime_service_client_process_id( + fixture.service, result.client_id) == expected_pid; closed = cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); client = NULL; - exited = cbm_daemon_runtime_service_wait_exited(fixture.service, - RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } if (client) { @@ -1571,11 +1694,9 @@ TEST(daemon_runtime_exact_hello_issues_connection_bound_identity) { TEST(daemon_runtime_activation_rejects_forged_and_malformed_without_stop) { cbm_daemon_build_identity_t identity = runtime_test_identity("2.4.0", runtime_test_self_build()); - cbm_daemon_build_identity_t forged = - runtime_test_identity("9.9.9", RUNTIME_BUILD_B); + cbm_daemon_build_identity_t forged = runtime_test_identity("9.9.9", RUNTIME_BUILD_B); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start( - &fixture, "activation-reject", &identity); + bool started = runtime_test_fixture_start(&fixture, "activation-reject", &identity); cbm_daemon_runtime_connect_result_t owner_result = {0}; cbm_daemon_runtime_client_t *owner = NULL; uint8_t request[RUNTIME_TEST_ACTIVATION_REQUEST_SIZE]; @@ -1595,55 +1716,43 @@ TEST(daemon_runtime_activation_rejects_forged_and_malformed_without_stop) { bool exited = false; if (started) { - owner = cbm_daemon_runtime_client_connect( - fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, - &owner_result); + owner = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &owner_result); } if (owner && encoded) { short_rejected = runtime_test_raw_activation_exchange( - fixture.endpoint, request, - RUNTIME_TEST_ACTIVATION_REQUEST_SIZE - 1U, + fixture.endpoint, request, RUNTIME_TEST_ACTIVATION_REQUEST_SIZE - 1U, &short_result) && !short_result.accepted; runtime_test_put_u32(request, UINT32_C(99)); - action_rejected = runtime_test_raw_activation_exchange( - fixture.endpoint, request, - RUNTIME_TEST_ACTIVATION_REQUEST_SIZE, - &action_result) && + action_rejected = runtime_test_raw_activation_exchange(fixture.endpoint, request, + RUNTIME_TEST_ACTIVATION_REQUEST_SIZE, + &action_result) && !action_result.accepted; - runtime_test_put_u32( - request, (uint32_t)CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE); - runtime_test_put_u32( - request + RUNTIME_TEST_ACTIVATION_IDENTITY_OFFSET, - RUNTIME_TEST_RENDEZVOUS_ABI + 1U); - abi_rejected = runtime_test_raw_activation_exchange( - fixture.endpoint, request, - RUNTIME_TEST_ACTIVATION_REQUEST_SIZE, - &abi_result) && + runtime_test_put_u32(request, (uint32_t)CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE); + runtime_test_put_u32(request + RUNTIME_TEST_ACTIVATION_IDENTITY_OFFSET, + RUNTIME_TEST_RENDEZVOUS_ABI + 1U); + abi_rejected = runtime_test_raw_activation_exchange(fixture.endpoint, request, + RUNTIME_TEST_ACTIVATION_REQUEST_SIZE, + &abi_result) && !abi_result.accepted; - forged_rejected = - cbm_daemon_runtime_request_activation_shutdown( - fixture.endpoint, &forged, - CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL, - RUNTIME_TEST_TIMEOUT_MS, &forged_result) && - !forged_result.accepted; - unchanged = - cbm_daemon_runtime_service_state(fixture.service) == - CBM_DAEMON_RUNTIME_SERVICE_RUNNING && - cbm_daemon_runtime_service_active_clients(fixture.service) == 1 && - cbm_daemon_runtime_service_wait_for_connections( - fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); - heartbeat = cbm_daemon_runtime_client_heartbeat( - owner, RUNTIME_TEST_TIMEOUT_MS); + forged_rejected = cbm_daemon_runtime_request_activation_shutdown( + fixture.endpoint, &forged, CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL, + RUNTIME_TEST_TIMEOUT_MS, &forged_result) && + !forged_result.accepted; + unchanged = cbm_daemon_runtime_service_state(fixture.service) == + CBM_DAEMON_RUNTIME_SERVICE_RUNNING && + cbm_daemon_runtime_service_active_clients(fixture.service) == 1 && + cbm_daemon_runtime_service_wait_for_connections(fixture.service, 1, + RUNTIME_TEST_TIMEOUT_MS); + heartbeat = cbm_daemon_runtime_client_heartbeat(owner, RUNTIME_TEST_TIMEOUT_MS); } if (owner) { - closed = cbm_daemon_runtime_client_close( - owner, RUNTIME_TEST_TIMEOUT_MS); + closed = cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); owner = NULL; } if (started) { - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } runtime_test_fixture_finish(&fixture); @@ -1665,8 +1774,7 @@ TEST(daemon_runtime_activation_ack_snapshots_then_interrupts_all_clients) { cbm_daemon_build_identity_t identity = runtime_test_identity("2.4.0", runtime_test_self_build()); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start( - &fixture, "activation-drain", &identity); + bool started = runtime_test_fixture_start(&fixture, "activation-drain", &identity); cbm_daemon_runtime_connect_result_t first_result = {0}; cbm_daemon_runtime_connect_result_t second_result = {0}; cbm_daemon_runtime_client_t *first = NULL; @@ -1676,42 +1784,33 @@ TEST(daemon_runtime_activation_ack_snapshots_then_interrupts_all_clients) { bool first_interrupted = false; bool second_interrupted = false; bool exited = false; - atomic_store_explicit(&runtime_activation_shutdown_log_seen, false, - memory_order_release); + atomic_store_explicit(&runtime_activation_shutdown_log_seen, false, memory_order_release); cbm_log_set_sink(runtime_test_activation_shutdown_sink); if (started) { - first = cbm_daemon_runtime_client_connect( - fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, - &first_result); - second = cbm_daemon_runtime_client_connect( - fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, - &second_result); + first = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &first_result); + second = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &second_result); } if (first && second) { requested = cbm_daemon_runtime_request_activation_shutdown( - fixture.endpoint, &identity, - CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE, + fixture.endpoint, &identity, CBM_DAEMON_RUNTIME_ACTIVATION_UPDATE, RUNTIME_TEST_TIMEOUT_MS, &activation); - first_interrupted = !cbm_daemon_runtime_client_heartbeat( - first, RUNTIME_TEST_TIMEOUT_MS); - second_interrupted = !cbm_daemon_runtime_client_heartbeat( - second, RUNTIME_TEST_TIMEOUT_MS); + first_interrupted = !cbm_daemon_runtime_client_heartbeat(first, RUNTIME_TEST_TIMEOUT_MS); + second_interrupted = !cbm_daemon_runtime_client_heartbeat(second, RUNTIME_TEST_TIMEOUT_MS); } cbm_log_set_sink(NULL); if (first) { - (void)cbm_daemon_runtime_client_close(first, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS); first = NULL; } if (second) { - (void)cbm_daemon_runtime_client_close(second, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(second, RUNTIME_TEST_TIMEOUT_MS); second = NULL; } if (started) { - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } runtime_test_fixture_finish(&fixture); @@ -1723,8 +1822,7 @@ TEST(daemon_runtime_activation_ack_snapshots_then_interrupts_all_clients) { ASSERT_EQ(activation.active_clients, 2); /* The one-shot activation requester is not part of the drain snapshot. */ ASSERT_EQ(activation.active_connections, 2); - ASSERT_TRUE(atomic_load_explicit(&runtime_activation_shutdown_log_seen, - memory_order_acquire)); + ASSERT_TRUE(atomic_load_explicit(&runtime_activation_shutdown_log_seen, memory_order_acquire)); ASSERT_TRUE(first_interrupted); ASSERT_TRUE(second_interrupted); ASSERT_TRUE(exited); @@ -1737,18 +1835,14 @@ TEST(daemon_runtime_activation_accepts_authenticated_different_build) { runtime_test_identity("2.4.0", runtime_test_self_build()); char directory[RUNTIME_TEST_PATH_CAP] = {0}; char foreign_image[RUNTIME_TEST_PATH_CAP] = {0}; - int directory_written = snprintf( - directory, sizeof(directory), "%s/cbm-runtime-activation-XXXXXX", - cbm_tmpdir()); - bool directory_created = directory_written > 0 && - directory_written < (int)sizeof(directory) && + int directory_written = + snprintf(directory, sizeof(directory), "%s/cbm-runtime-activation-XXXXXX", cbm_tmpdir()); + bool directory_created = directory_written > 0 && directory_written < (int)sizeof(directory) && cbm_mkdtemp(directory) != NULL; - int image_written = directory_created - ? snprintf(foreign_image, sizeof(foreign_image), - "%s/foreign-activation", directory) - : -1; - bool copied = image_written > 0 && - image_written < (int)sizeof(foreign_image) && + int image_written = directory_created ? snprintf(foreign_image, sizeof(foreign_image), + "%s/foreign-activation", directory) + : -1; + bool copied = image_written > 0 && image_written < (int)sizeof(foreign_image) && runtime_test_copy_self_image(foreign_image); #ifdef __APPLE__ /* Appending an overlay invalidates Mach-O strict validation. A distinct @@ -1762,13 +1856,9 @@ TEST(daemon_runtime_activation_accepts_authenticated_different_build) { #endif char foreign_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; bool fingerprinted = - runnable && cbm_daemon_build_fingerprint_file(foreign_image, - foreign_build); - bool differs = fingerprinted && - strcmp(foreign_build, - active_identity.build_fingerprint) != 0; - cbm_daemon_build_identity_t foreign_identity = - runtime_test_identity("9.9.9", foreign_build); + runnable && cbm_daemon_build_fingerprint_file(foreign_image, foreign_build); + bool differs = fingerprinted && strcmp(foreign_build, active_identity.build_fingerprint) != 0; + cbm_daemon_build_identity_t foreign_identity = runtime_test_identity("9.9.9", foreign_build); /* Finalize the foreign executable before the runtime starts any worker * threads. In particular, macOS code signing is an external process; the @@ -1777,32 +1867,27 @@ TEST(daemon_runtime_activation_accepts_authenticated_different_build) { * the activation request below. */ runtime_test_fixture_t fixture; memset(&fixture, 0, sizeof(fixture)); - bool started = differs && runtime_test_fixture_start( - &fixture, "activation-foreign", - &active_identity); + bool started = + differs && runtime_test_fixture_start(&fixture, "activation-foreign", &active_identity); cbm_daemon_runtime_connect_result_t owner_result = {0}; cbm_daemon_runtime_client_t *owner = NULL; if (started) { - owner = cbm_daemon_runtime_client_connect( - fixture.endpoint, &active_identity, RUNTIME_TEST_TIMEOUT_MS, - &owner_result); + owner = cbm_daemon_runtime_client_connect(fixture.endpoint, &active_identity, + RUNTIME_TEST_TIMEOUT_MS, &owner_result); } int foreign_exit = -1; - bool foreign_ran = owner && differs && - runtime_test_run_activation_image( - foreign_image, &fixture, &foreign_identity, - CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL, - &foreign_exit); + bool foreign_ran = + owner && differs && + runtime_test_run_activation_image(foreign_image, &fixture, &foreign_identity, + CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL, &foreign_exit); bool owner_interrupted = owner && foreign_ran && - !cbm_daemon_runtime_client_heartbeat( - owner, RUNTIME_TEST_TIMEOUT_MS); + !cbm_daemon_runtime_client_heartbeat(owner, RUNTIME_TEST_TIMEOUT_MS); if (owner) { - (void)cbm_daemon_runtime_client_close(owner, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); owner = NULL; } - bool exited = started && cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + bool exited = + started && cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); runtime_test_fixture_finish(&fixture); if (image_written > 0 && image_written < (int)sizeof(foreign_image)) { (void)cbm_unlink(foreign_image); @@ -1828,8 +1913,7 @@ TEST(daemon_runtime_activation_accepts_authenticated_different_build) { #endif TEST(daemon_runtime_rendezvous_layout_is_frozen_and_detailed_abi_independent) { - cbm_daemon_build_identity_t first = - runtime_test_identity("2.4.0", runtime_test_self_build()); + cbm_daemon_build_identity_t first = runtime_test_identity("2.4.0", runtime_test_self_build()); cbm_daemon_build_identity_t different_detail = first; different_detail.protocol_abi = 0; different_detail.store_abi = 0; @@ -1837,39 +1921,30 @@ TEST(daemon_runtime_rendezvous_layout_is_frozen_and_detailed_abi_independent) { uint8_t first_wire[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE]; uint8_t second_wire[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE]; - bool first_encoded = - cbm_daemon_runtime_hello_request_encode(first_wire, &first); - bool second_encoded = cbm_daemon_runtime_hello_request_encode( - second_wire, &different_detail); + bool first_encoded = cbm_daemon_runtime_hello_request_encode(first_wire, &first); + bool second_encoded = cbm_daemon_runtime_hello_request_encode(second_wire, &different_detail); ASSERT_TRUE(first_encoded); ASSERT_TRUE(second_encoded); - ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE, - RUNTIME_TEST_RENDEZVOUS_REQUEST_SIZE); - ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE, - RUNTIME_TEST_RENDEZVOUS_RESPONSE_SIZE); - ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, - CBM_DAEMON_VERSION_TEXT_SIZE); - ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, - CBM_DAEMON_BUILD_FINGERPRINT_SIZE); - ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP, - CBM_DAEMON_CONFLICT_MESSAGE_SIZE); - ASSERT_EQ(runtime_test_get_u32(first_wire), - RUNTIME_TEST_RENDEZVOUS_ABI); - ASSERT_TRUE(runtime_test_fixed_string_equals( - first_wire + RUNTIME_TEST_RENDEZVOUS_VERSION_OFFSET, - CBM_DAEMON_VERSION_TEXT_SIZE, first.semantic_version)); - ASSERT_TRUE(runtime_test_fixed_string_equals( - first_wire + RUNTIME_TEST_RENDEZVOUS_BUILD_OFFSET, - CBM_DAEMON_BUILD_FINGERPRINT_SIZE, first.build_fingerprint)); + ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE, RUNTIME_TEST_RENDEZVOUS_REQUEST_SIZE); + ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_RESPONSE_SIZE, RUNTIME_TEST_RENDEZVOUS_RESPONSE_SIZE); + ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_VERSION_TEXT_CAP, CBM_DAEMON_VERSION_TEXT_SIZE); + ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_BUILD_FINGERPRINT_CAP, CBM_DAEMON_BUILD_FINGERPRINT_SIZE); + ASSERT_EQ(CBM_DAEMON_RENDEZVOUS_MESSAGE_CAP, CBM_DAEMON_CONFLICT_MESSAGE_SIZE); + ASSERT_EQ(runtime_test_get_u32(first_wire), RUNTIME_TEST_RENDEZVOUS_ABI); + ASSERT_TRUE( + runtime_test_fixed_string_equals(first_wire + RUNTIME_TEST_RENDEZVOUS_VERSION_OFFSET, + CBM_DAEMON_VERSION_TEXT_SIZE, first.semantic_version)); + ASSERT_TRUE(runtime_test_fixed_string_equals(first_wire + RUNTIME_TEST_RENDEZVOUS_BUILD_OFFSET, + CBM_DAEMON_BUILD_FINGERPRINT_SIZE, + first.build_fingerprint)); ASSERT_EQ(memcmp(first_wire, second_wire, sizeof(first_wire)), 0); PASS(); } TEST(daemon_runtime_future_generation_gets_stable_explicit_conflict) { const char *active_build = runtime_test_self_build(); - cbm_daemon_build_identity_t active = - runtime_test_identity("2.4.0", active_build); + cbm_daemon_build_identity_t active = runtime_test_identity("2.4.0", active_build); cbm_daemon_build_identity_t future = runtime_test_identity("9.0.0-future-wire-v2", RUNTIME_BUILD_B); future.protocol_abi = active.protocol_abi + 1000; @@ -1877,8 +1952,7 @@ TEST(daemon_runtime_future_generation_gets_stable_explicit_conflict) { future.feature_abi = active.feature_abi + 3000; runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start( - &fixture, "future-generation", &active); + bool started = runtime_test_fixture_start(&fixture, "future-generation", &active); cbm_daemon_runtime_connect_result_t owner_result = {0}; cbm_daemon_runtime_client_t *owner = NULL; cbm_daemon_ipc_connection_t *future_connection = NULL; @@ -1897,56 +1971,49 @@ TEST(daemon_runtime_future_generation_gets_stable_explicit_conflict) { "CBM could not start because a conflicting CBM process is active " "(version; active version %s, build %s; requested version %s, build %s). " "Close all CBM sessions and commands, then retry.", - active.semantic_version, active_build, future.semantic_version, - future.build_fingerprint); + active.semantic_version, active_build, future.semantic_version, future.build_fingerprint); bool expected_message_valid = expected_length > 0 && (size_t)expected_length < sizeof(expected_message); if (started) { - owner = cbm_daemon_runtime_client_connect( - fixture.endpoint, &active, RUNTIME_TEST_TIMEOUT_MS, &owner_result); + owner = cbm_daemon_runtime_client_connect(fixture.endpoint, &active, + RUNTIME_TEST_TIMEOUT_MS, &owner_result); } if (owner) { encoded = cbm_daemon_runtime_hello_request_encode(future_wire, &future); - future_connection = cbm_daemon_ipc_connect( - fixture.endpoint, RUNTIME_TEST_TIMEOUT_MS); + future_connection = cbm_daemon_ipc_connect(fixture.endpoint, RUNTIME_TEST_TIMEOUT_MS); } if (future_connection && encoded) { /* A future generation sends only the permanent identity envelope here; * its detailed runtime layout is negotiated by exact executable build, * never added to this stable endpoint message. */ - sent = cbm_daemon_ipc_send_frame( - future_connection, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_HELLO, future_wire, - RUNTIME_TEST_RENDEZVOUS_REQUEST_SIZE); - int received = cbm_daemon_ipc_receive_frame( - future_connection, RUNTIME_TEST_TIMEOUT_MS, &response_frame, - &response_payload); + sent = cbm_daemon_ipc_send_frame(future_connection, CBM_DAEMON_FRAME_REQUEST, + CBM_DAEMON_RUNTIME_OP_HELLO, future_wire, + RUNTIME_TEST_RENDEZVOUS_REQUEST_SIZE); + int received = cbm_daemon_ipc_receive_frame(future_connection, RUNTIME_TEST_TIMEOUT_MS, + &response_frame, &response_payload); explicit_conflict = expected_message_valid && received == 1 && response_payload && response_frame.type == CBM_DAEMON_FRAME_RESPONSE && response_frame.flags == CBM_DAEMON_RUNTIME_OP_HELLO && response_frame.length == RUNTIME_TEST_RENDEZVOUS_RESPONSE_SIZE && - runtime_test_get_u32(response_payload) == - CBM_DAEMON_RUNTIME_CONNECT_CONFLICT && - runtime_test_get_u32(response_payload + 4) == - CBM_DAEMON_HELLO_VERSION_CONFLICT && + runtime_test_get_u32(response_payload) == CBM_DAEMON_RUNTIME_CONNECT_CONFLICT && + runtime_test_get_u32(response_payload + 4) == CBM_DAEMON_HELLO_VERSION_CONFLICT && runtime_test_fixed_string_equals( response_payload + RUNTIME_TEST_RENDEZVOUS_ACTIVE_VERSION_OFFSET, CBM_DAEMON_VERSION_TEXT_SIZE, active.semantic_version) && - runtime_test_fixed_string_equals( - response_payload + RUNTIME_TEST_RENDEZVOUS_ACTIVE_BUILD_OFFSET, - CBM_DAEMON_BUILD_FINGERPRINT_SIZE, active_build) && + runtime_test_fixed_string_equals(response_payload + + RUNTIME_TEST_RENDEZVOUS_ACTIVE_BUILD_OFFSET, + CBM_DAEMON_BUILD_FINGERPRINT_SIZE, active_build) && runtime_test_fixed_string_equals( response_payload + RUNTIME_TEST_RENDEZVOUS_REQUESTED_VERSION_OFFSET, CBM_DAEMON_VERSION_TEXT_SIZE, future.semantic_version) && runtime_test_fixed_string_equals( response_payload + RUNTIME_TEST_RENDEZVOUS_REQUESTED_BUILD_OFFSET, - CBM_DAEMON_BUILD_FINGERPRINT_SIZE, - future.build_fingerprint) && - runtime_test_fixed_string_equals( - response_payload + RUNTIME_TEST_RENDEZVOUS_MESSAGE_OFFSET, - CBM_DAEMON_CONFLICT_MESSAGE_SIZE, expected_message); + CBM_DAEMON_BUILD_FINGERPRINT_SIZE, future.build_fingerprint) && + runtime_test_fixed_string_equals(response_payload + + RUNTIME_TEST_RENDEZVOUS_MESSAGE_OFFSET, + CBM_DAEMON_CONFLICT_MESSAGE_SIZE, expected_message); } cbm_daemon_ipc_connection_close(future_connection); future_connection = NULL; @@ -1956,20 +2023,16 @@ TEST(daemon_runtime_future_generation_gets_stable_explicit_conflict) { if (owner) { logged = runtime_test_read_log(fixture.log_path, log) && strstr(log, "\"reason\":\"version\"") != NULL && - strstr(log, active_build) != NULL && - strstr(log, RUNTIME_BUILD_B) != NULL; - (void)cbm_daemon_runtime_client_close(owner, - RUNTIME_TEST_TIMEOUT_MS); + strstr(log, active_build) != NULL && strstr(log, RUNTIME_BUILD_B) != NULL; + (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); owner = NULL; - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } cbm_daemon_ipc_connection_close(future_connection); free(response_payload); if (owner) { - (void)cbm_daemon_runtime_client_close(owner, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); } runtime_test_fixture_finish(&fixture); @@ -2002,9 +2065,8 @@ TEST(daemon_runtime_matching_clients_share_one_service_endpoint) { same_endpoint = cbm_daemon_ipc_endpoint_new(key, fixture.parent); } if (same_endpoint) { - same_address = - strcmp(cbm_daemon_ipc_endpoint_address(fixture.endpoint), - cbm_daemon_ipc_endpoint_address(same_endpoint)) == 0; + same_address = strcmp(cbm_daemon_ipc_endpoint_address(fixture.endpoint), + cbm_daemon_ipc_endpoint_address(same_endpoint)) == 0; first = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &first_result); second = cbm_daemon_runtime_client_connect(same_endpoint, &identity, @@ -2015,12 +2077,11 @@ TEST(daemon_runtime_matching_clients_share_one_service_endpoint) { cbm_daemon_runtime_service_active_clients(fixture.service) == 2; (void)cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS); first = NULL; - one_remains = cbm_daemon_runtime_service_wait_for_clients( - fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); + one_remains = cbm_daemon_runtime_service_wait_for_clients(fixture.service, 1, + RUNTIME_TEST_TIMEOUT_MS); (void)cbm_daemon_runtime_client_close(second, RUNTIME_TEST_TIMEOUT_MS); second = NULL; - exited = cbm_daemon_runtime_service_wait_exited(fixture.service, - RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } if (second) { @@ -2063,23 +2124,20 @@ TEST(daemon_runtime_same_version_different_build_is_visible_and_logged) { if (owner) { rejected = cbm_daemon_runtime_client_connect(fixture.endpoint, &rebuilt, RUNTIME_TEST_TIMEOUT_MS, &conflict_result); - explicit_conflict = - rejected == NULL && - conflict_result.status == CBM_DAEMON_RUNTIME_CONNECT_CONFLICT && - conflict_result.hello_status == CBM_DAEMON_HELLO_BUILD_CONFLICT && - strstr(conflict_result.message, "could not start") != NULL && - strstr(conflict_result.message, active_build) != NULL && - strstr(conflict_result.message, RUNTIME_BUILD_B) != NULL; - owner_unchanged = - cbm_daemon_runtime_service_active_clients(fixture.service) == 1; + explicit_conflict = rejected == NULL && + conflict_result.status == CBM_DAEMON_RUNTIME_CONNECT_CONFLICT && + conflict_result.hello_status == CBM_DAEMON_HELLO_BUILD_CONFLICT && + strstr(conflict_result.message, "could not start") != NULL && + strstr(conflict_result.message, active_build) != NULL && + strstr(conflict_result.message, RUNTIME_BUILD_B) != NULL; + owner_unchanged = cbm_daemon_runtime_service_active_clients(fixture.service) == 1; logged = runtime_test_read_log(fixture.log_path, log) && strstr(log, "daemon.version_conflict") != NULL && - strstr(log, "\"reason\":\"build\"") != NULL && - strstr(log, active_build) != NULL && strstr(log, RUNTIME_BUILD_B) != NULL; + strstr(log, "\"reason\":\"build\"") != NULL && strstr(log, active_build) != NULL && + strstr(log, RUNTIME_BUILD_B) != NULL; (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); owner = NULL; - exited = cbm_daemon_runtime_service_wait_exited(fixture.service, - RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } if (rejected) { @@ -2113,41 +2171,34 @@ TEST(daemon_runtime_conflict_log_failure_uses_operation_log_fallback) { bool explicit_conflict = false; bool exited = false; - atomic_store_explicit(&runtime_conflict_log_fallback_seen, false, - memory_order_release); + atomic_store_explicit(&runtime_conflict_log_fallback_seen, false, memory_order_release); if (obstruction_created) { cbm_log_set_level(CBM_LOG_ERROR); cbm_log_set_sink(runtime_test_conflict_log_fallback_sink); - owner = cbm_daemon_runtime_client_connect( - fixture.endpoint, &active, RUNTIME_TEST_TIMEOUT_MS, &owner_result); + owner = cbm_daemon_runtime_client_connect(fixture.endpoint, &active, + RUNTIME_TEST_TIMEOUT_MS, &owner_result); } if (owner) { - rejected = cbm_daemon_runtime_client_connect( - fixture.endpoint, &rebuilt, RUNTIME_TEST_TIMEOUT_MS, - &conflict_result); - explicit_conflict = - rejected == NULL && - conflict_result.status == CBM_DAEMON_RUNTIME_CONNECT_CONFLICT && - conflict_result.hello_status == CBM_DAEMON_HELLO_BUILD_CONFLICT; - (void)cbm_daemon_runtime_client_close(owner, - RUNTIME_TEST_TIMEOUT_MS); + rejected = cbm_daemon_runtime_client_connect(fixture.endpoint, &rebuilt, + RUNTIME_TEST_TIMEOUT_MS, &conflict_result); + explicit_conflict = rejected == NULL && + conflict_result.status == CBM_DAEMON_RUNTIME_CONNECT_CONFLICT && + conflict_result.hello_status == CBM_DAEMON_HELLO_BUILD_CONFLICT; + (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); owner = NULL; - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } cbm_log_set_sink(NULL); cbm_log_set_level(prior_level); if (rejected) { - (void)cbm_daemon_runtime_client_close(rejected, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(rejected, RUNTIME_TEST_TIMEOUT_MS); } if (owner) { - (void)cbm_daemon_runtime_client_close(owner, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); } - bool fallback_seen = atomic_load_explicit( - &runtime_conflict_log_fallback_seen, memory_order_acquire); + bool fallback_seen = + atomic_load_explicit(&runtime_conflict_log_fallback_seen, memory_order_acquire); if (obstruction_created) { (void)cbm_rmdir(fixture.log_path); } @@ -2185,12 +2236,10 @@ TEST(daemon_runtime_disconnect_releases_only_connection_subscriptions) { RUNTIME_TEST_TIMEOUT_MS, &second_result); } if (first && second) { - cbm_daemon_subscription_result_t first_status = - cbm_daemon_runtime_client_job_subscribe(first, project, &first_subscription, - RUNTIME_TEST_TIMEOUT_MS); - cbm_daemon_subscription_result_t second_status = - cbm_daemon_runtime_client_job_subscribe(second, project, &second_subscription, - RUNTIME_TEST_TIMEOUT_MS); + cbm_daemon_subscription_result_t first_status = cbm_daemon_runtime_client_job_subscribe( + first, project, &first_subscription, RUNTIME_TEST_TIMEOUT_MS); + cbm_daemon_subscription_result_t second_status = cbm_daemon_runtime_client_job_subscribe( + second, project, &second_subscription, RUNTIME_TEST_TIMEOUT_MS); subscribed = first_status == CBM_DAEMON_SUBSCRIPTION_STARTED && second_status == CBM_DAEMON_SUBSCRIPTION_JOINED && first_subscription != CBM_DAEMON_SUBSCRIPTION_ID_INVALID && @@ -2200,19 +2249,17 @@ TEST(daemon_runtime_disconnect_releases_only_connection_subscriptions) { (void)cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS); first = NULL; - second_survived = cbm_daemon_runtime_service_wait_for_clients( - fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS) && - cbm_daemon_runtime_service_job_subscribers(fixture.service, project) == - 1 && - cbm_daemon_runtime_client_job_unsubscribe( - second, second_subscription, RUNTIME_TEST_TIMEOUT_MS) && - cbm_daemon_runtime_service_job_subscribers(fixture.service, project) == - 0; + second_survived = + cbm_daemon_runtime_service_wait_for_clients(fixture.service, 1, + RUNTIME_TEST_TIMEOUT_MS) && + cbm_daemon_runtime_service_job_subscribers(fixture.service, project) == 1 && + cbm_daemon_runtime_client_job_unsubscribe(second, second_subscription, + RUNTIME_TEST_TIMEOUT_MS) && + cbm_daemon_runtime_service_job_subscribers(fixture.service, project) == 0; reaped = cbm_daemon_runtime_service_job_reaped(fixture.service, project); (void)cbm_daemon_runtime_client_close(second, RUNTIME_TEST_TIMEOUT_MS); second = NULL; - exited = cbm_daemon_runtime_service_wait_exited(fixture.service, - RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } if (second) { @@ -2252,8 +2299,7 @@ TEST(daemon_runtime_final_disconnect_automatically_exits_within_bound) { cbm_daemon_runtime_service_state(fixture.service); terminal_transition = after_close == CBM_DAEMON_RUNTIME_SERVICE_STOPPING || after_close == CBM_DAEMON_RUNTIME_SERVICE_EXITED; - exited = cbm_daemon_runtime_service_wait_exited(fixture.service, - RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } if (client) { @@ -2271,8 +2317,8 @@ TEST(daemon_runtime_authenticated_idle_connection_outlives_lease_interval) { cbm_daemon_build_identity_t identity = runtime_test_identity("2.4.0", runtime_test_self_build()); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_configured( - &fixture, "idle-connection", &identity, 8, 20, NULL); + bool started = + runtime_test_fixture_start_configured(&fixture, "idle-connection", &identity, 8, 20, NULL); cbm_daemon_runtime_connect_result_t result = {0}; cbm_daemon_runtime_client_t *client = NULL; bool remained_connected = false; @@ -2280,26 +2326,20 @@ TEST(daemon_runtime_authenticated_idle_connection_outlives_lease_interval) { if (started) { client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, - RUNTIME_TEST_TIMEOUT_MS, - &result); + RUNTIME_TEST_TIMEOUT_MS, &result); } if (client) { struct timespec beyond_lease = {.tv_sec = 0, .tv_nsec = 60000000}; (void)cbm_nanosleep(&beyond_lease, NULL); - remained_connected = - cbm_daemon_runtime_service_active_clients(fixture.service) == 1 && - cbm_daemon_runtime_client_heartbeat(client, - RUNTIME_TEST_TIMEOUT_MS); - (void)cbm_daemon_runtime_client_close(client, - RUNTIME_TEST_TIMEOUT_MS); + remained_connected = cbm_daemon_runtime_service_active_clients(fixture.service) == 1 && + cbm_daemon_runtime_client_heartbeat(client, RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); client = NULL; - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } if (client) { - (void)cbm_daemon_runtime_client_close(client, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); } runtime_test_fixture_finish(&fixture); @@ -2339,11 +2379,11 @@ TEST(daemon_runtime_connection_cap_covers_slow_hello_and_stopping_is_terminal) { if (accepted) { overflow = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &overflow_result); - capacity_rejected = - overflow == NULL && overflow_result.status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED && - strstr(overflow_result.message, "capacity") != NULL && - cbm_daemon_runtime_service_active_connections(fixture.service) == 2 && - cbm_daemon_runtime_service_active_clients(fixture.service) == 1; + capacity_rejected = overflow == NULL && + overflow_result.status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED && + strstr(overflow_result.message, "capacity") != NULL && + cbm_daemon_runtime_service_active_connections(fixture.service) == 2 && + cbm_daemon_runtime_service_active_clients(fixture.service) == 1; cbm_daemon_ipc_connection_close(slow_hello); slow_hello = NULL; @@ -2351,20 +2391,18 @@ TEST(daemon_runtime_connection_cap_covers_slow_hello_and_stopping_is_terminal) { fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); (void)cbm_daemon_runtime_client_close(accepted, RUNTIME_TEST_TIMEOUT_MS); accepted = NULL; - exited = cbm_daemon_runtime_service_wait_exited(fixture.service, - RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); resurrection = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, 100, &resurrection_result); cbm_daemon_runtime_service_state_t terminal_state = cbm_daemon_runtime_service_state(fixture.service); - no_resurrection = - resurrection == NULL && - (resurrection_result.status == CBM_DAEMON_RUNTIME_CONNECT_ERROR || - resurrection_result.status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED) && - cbm_daemon_runtime_service_active_clients(fixture.service) == 0 && - (terminal_state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING || - terminal_state == CBM_DAEMON_RUNTIME_SERVICE_EXITED); + no_resurrection = resurrection == NULL && + (resurrection_result.status == CBM_DAEMON_RUNTIME_CONNECT_ERROR || + resurrection_result.status == CBM_DAEMON_RUNTIME_CONNECT_REJECTED) && + cbm_daemon_runtime_service_active_clients(fixture.service) == 0 && + (terminal_state == CBM_DAEMON_RUNTIME_SERVICE_STOPPING || + terminal_state == CBM_DAEMON_RUNTIME_SERVICE_EXITED); } if (resurrection) { @@ -2394,8 +2432,7 @@ TEST(daemon_runtime_rejects_forged_identity_extension) { runtime_test_fixture_t fixture; bool started = runtime_test_fixture_start(&fixture, "forged-identity", &identity); cbm_daemon_ipc_connection_t *raw = NULL; - uint8_t forged[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE + - sizeof(uint64_t) * 2] = {0}; + uint8_t forged[CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE + sizeof(uint64_t) * 2] = {0}; uint64_t forged_client_id = UINT64_MAX - 1; uint64_t forged_process_id = UINT64_MAX; bool encoded = false; @@ -2406,22 +2443,18 @@ TEST(daemon_runtime_rejects_forged_identity_extension) { if (started) { encoded = cbm_daemon_runtime_hello_request_encode(forged, &identity); - memcpy(forged + CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE, - &forged_client_id, + memcpy(forged + CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE, &forged_client_id, sizeof(forged_client_id)); - memcpy(forged + CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE + - sizeof(forged_client_id), + memcpy(forged + CBM_DAEMON_RENDEZVOUS_REQUEST_SIZE + sizeof(forged_client_id), &forged_process_id, sizeof(forged_process_id)); raw = cbm_daemon_ipc_connect(fixture.endpoint, RUNTIME_TEST_TIMEOUT_MS); } if (raw && encoded) { - sent = cbm_daemon_ipc_send_frame(raw, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_HELLO, forged, - (uint32_t)sizeof(forged)); - int received = cbm_daemon_ipc_receive_frame(raw, RUNTIME_TEST_TIMEOUT_MS, - &response_frame, &response_payload); - rejected = received != 1 && - cbm_daemon_runtime_service_active_clients(fixture.service) == 0; + sent = cbm_daemon_ipc_send_frame(raw, CBM_DAEMON_FRAME_REQUEST, CBM_DAEMON_RUNTIME_OP_HELLO, + forged, (uint32_t)sizeof(forged)); + int received = cbm_daemon_ipc_receive_frame(raw, RUNTIME_TEST_TIMEOUT_MS, &response_frame, + &response_payload); + rejected = received != 1 && cbm_daemon_runtime_service_active_clients(fixture.service) == 0; } free(response_payload); cbm_daemon_ipc_connection_close(raw); @@ -2442,8 +2475,7 @@ TEST(daemon_runtime_rejects_forged_identity_extension) { valid_result.client_id != forged_client_id; (void)cbm_daemon_runtime_client_close(valid, RUNTIME_TEST_TIMEOUT_MS); valid = NULL; - exited = cbm_daemon_runtime_service_wait_exited(fixture.service, - RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } if (valid) { @@ -2469,43 +2501,37 @@ TEST(daemon_runtime_application_response_roundtrip_is_byte_exact) { runtime_application_context_t context; runtime_application_context_init(&context, false); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_application( - &fixture, "application-roundtrip", &identity, &context); + bool started = runtime_test_fixture_start_application(&fixture, "application-roundtrip", + &identity, &context); cbm_daemon_runtime_connect_result_t result = {0}; cbm_daemon_runtime_client_t *client = NULL; uint8_t *response = NULL; uint32_t response_length = 0; - cbm_daemon_runtime_application_status_t status = - CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; + cbm_daemon_runtime_application_status_t status = CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR; bool exact = false; bool closed = false; bool exited = false; if (started) { client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, - RUNTIME_TEST_TIMEOUT_MS, - &result); + RUNTIME_TEST_TIMEOUT_MS, &result); } if (client) { status = cbm_daemon_runtime_client_application_request( - client, request, (uint32_t)sizeof(request), &response, - &response_length, RUNTIME_TEST_TIMEOUT_MS); - exact = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && - response_length == sizeof(request) && response && - memcmp(response, request, sizeof(request)) == 0; + client, request, (uint32_t)sizeof(request), &response, &response_length, + RUNTIME_TEST_TIMEOUT_MS); + exact = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response_length == sizeof(request) && + response && memcmp(response, request, sizeof(request)) == 0; free(response); response = NULL; - closed = cbm_daemon_runtime_client_close(client, - RUNTIME_TEST_TIMEOUT_MS); + closed = cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); client = NULL; - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } free(response); if (client) { - (void)cbm_daemon_runtime_client_close(client, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); } runtime_test_fixture_finish(&fixture); @@ -2525,11 +2551,10 @@ TEST(daemon_runtime_final_disconnect_rejects_blocked_provisional_session) { runtime_test_identity("2.4.0", runtime_test_self_build()); runtime_application_context_t context; runtime_application_context_init(&context, false); - atomic_store_explicit(&context.block_second_open, true, - memory_order_release); + atomic_store_explicit(&context.block_second_open, true, memory_order_release); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_application( - &fixture, "application-open-race", &identity, &context); + bool started = runtime_test_fixture_start_application(&fixture, "application-open-race", + &identity, &context); cbm_daemon_runtime_connect_result_t owner_result = {0}; cbm_daemon_runtime_client_t *owner = NULL; runtime_application_connect_call_t contender = { @@ -2547,24 +2572,20 @@ TEST(daemon_runtime_final_disconnect_rejects_blocked_provisional_session) { bool exited = false; if (started) { - owner = cbm_daemon_runtime_client_connect( - fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, - &owner_result); + owner = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &owner_result); } if (owner) { contender.endpoint = fixture.endpoint; connect_thread_create_rc = cbm_thread_create( - &connect_thread, 128U * 1024U, - runtime_application_client_connect_thread, &contender); + &connect_thread, 128U * 1024U, runtime_application_client_connect_thread, &contender); connect_thread_started = connect_thread_create_rc == 0; - provisional_started = connect_thread_started && - runtime_test_wait_atomic_bool( - &context.second_open_started, - RUNTIME_TEST_TIMEOUT_MS); + provisional_started = + connect_thread_started && + runtime_test_wait_atomic_bool(&context.second_open_started, RUNTIME_TEST_TIMEOUT_MS); } if (provisional_started) { - owner_closed = cbm_daemon_runtime_client_close( - owner, RUNTIME_TEST_TIMEOUT_MS); + owner_closed = cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); owner = NULL; shutdown_won = cbm_daemon_runtime_service_state(fixture.service) == CBM_DAEMON_RUNTIME_SERVICE_STOPPING; @@ -2572,8 +2593,7 @@ TEST(daemon_runtime_final_disconnect_rejects_blocked_provisional_session) { /* Release on every setup outcome so neither the server worker nor the * helper can retain stack-owned test state during cleanup. */ - atomic_store_explicit(&context.release_second_open, true, - memory_order_release); + atomic_store_explicit(&context.release_second_open, true, memory_order_release); if (connect_thread_started) { connect_thread_join_rc = cbm_thread_join(&connect_thread); if (connect_thread_join_rc == 0) { @@ -2581,26 +2601,22 @@ TEST(daemon_runtime_final_disconnect_rejects_blocked_provisional_session) { } } if (connect_thread_started) { - while (!atomic_load_explicit(&contender.completed, - memory_order_acquire)) { + while (!atomic_load_explicit(&contender.completed, memory_order_acquire)) { struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; (void)cbm_nanosleep(&pause, NULL); } } contender_accepted = contender.client != NULL; if (owner) { - (void)cbm_daemon_runtime_client_close(owner, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); owner = NULL; } if (contender.client) { - (void)cbm_daemon_runtime_client_close(contender.client, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(contender.client, RUNTIME_TEST_TIMEOUT_MS); contender.client = NULL; } if (started) { - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } runtime_test_fixture_finish(&fixture); @@ -2611,8 +2627,7 @@ TEST(daemon_runtime_final_disconnect_rejects_blocked_provisional_session) { ASSERT_TRUE(owner_closed); ASSERT_TRUE(shutdown_won); ASSERT_EQ(connect_thread_join_rc, 0); - ASSERT_TRUE(atomic_load_explicit(&contender.completed, - memory_order_acquire)); + ASSERT_TRUE(atomic_load_explicit(&contender.completed, memory_order_acquire)); ASSERT_FALSE(contender_accepted); ASSERT_TRUE(exited); ASSERT_EQ(atomic_load(&context.opened), 2); @@ -2631,8 +2646,8 @@ TEST(daemon_runtime_request_cancel_is_exact_and_session_remains_usable) { atomic_bool request_thread_completed; atomic_init(&request_thread_completed, false); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_application( - &fixture, "application-request-cancel", &identity, &context); + bool started = runtime_test_fixture_start_application(&fixture, "application-request-cancel", + &identity, &context); cbm_daemon_runtime_connect_result_t result = {0}; cbm_daemon_runtime_client_t *client = NULL; cbm_daemon_runtime_application_token_t request_token = @@ -2651,14 +2666,11 @@ TEST(daemon_runtime_request_cancel_is_exact_and_session_remains_usable) { bool token_reserved = false; bool request_thread_started = false; bool callback_started = false; - cbm_daemon_runtime_cancel_result_t wrong_cancel = - CBM_DAEMON_RUNTIME_CANCEL_ERROR; + cbm_daemon_runtime_cancel_result_t wrong_cancel = CBM_DAEMON_RUNTIME_CANCEL_ERROR; int cancels_after_wrong = -1; - cbm_daemon_runtime_cancel_result_t exact_cancel = - CBM_DAEMON_RUNTIME_CANCEL_ERROR; + cbm_daemon_runtime_cancel_result_t exact_cancel = CBM_DAEMON_RUNTIME_CANCEL_ERROR; bool cancel_delivered = false; - cbm_daemon_runtime_cancel_result_t late_duplicate_cancel = - CBM_DAEMON_RUNTIME_CANCEL_ERROR; + cbm_daemon_runtime_cancel_result_t late_duplicate_cancel = CBM_DAEMON_RUNTIME_CANCEL_ERROR; int cancels_after_duplicate = -1; bool close_begun = false; bool request_thread_joined = false; @@ -2672,40 +2684,33 @@ TEST(daemon_runtime_request_cancel_is_exact_and_session_remains_usable) { bool exited = false; if (started) { - client = cbm_daemon_runtime_client_connect( - fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &result); + client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &result); } if (client) { - token_reserved = cbm_daemon_runtime_client_application_token_reserve( - client, &request_token); + token_reserved = + cbm_daemon_runtime_client_application_token_reserve(client, &request_token); } if (token_reserved) { call.client = client; call.request_token = request_token; request_thread_create_rc = cbm_thread_create( - &request_thread, 128U * 1024U, - runtime_application_client_request_thread, &call); + &request_thread, 128U * 1024U, runtime_application_client_request_thread, &call); request_thread_started = request_thread_create_rc == 0; if (!request_thread_started) { - printf(" runtime helper thread create failed: rc=%d\n", - request_thread_create_rc); + printf(" runtime helper thread create failed: rc=%d\n", request_thread_create_rc); } - callback_started = request_thread_started && - runtime_test_wait_atomic_bool( - &context.first_request_started, - RUNTIME_TEST_TIMEOUT_MS); + callback_started = + request_thread_started && + runtime_test_wait_atomic_bool(&context.first_request_started, RUNTIME_TEST_TIMEOUT_MS); } if (callback_started) { - wrong_cancel = cbm_daemon_runtime_client_application_cancel( - client, request_token + 1U); - cancels_after_wrong = atomic_load_explicit( - &context.request_cancels, memory_order_acquire); - exact_cancel = cbm_daemon_runtime_client_application_cancel( - client, request_token); + wrong_cancel = cbm_daemon_runtime_client_application_cancel(client, request_token + 1U); + cancels_after_wrong = atomic_load_explicit(&context.request_cancels, memory_order_acquire); + exact_cancel = cbm_daemon_runtime_client_application_cancel(client, request_token); cancel_delivered = exact_cancel == CBM_DAEMON_RUNTIME_CANCEL_ACCEPTED && - runtime_test_wait_atomic_int(&context.request_cancels, 1, - RUNTIME_TEST_TIMEOUT_MS); + runtime_test_wait_atomic_int(&context.request_cancels, 1, RUNTIME_TEST_TIMEOUT_MS); } if (request_thread_started && !cancel_delivered && client) { close_begun = cbm_daemon_runtime_client_close_begin(client); @@ -2717,35 +2722,27 @@ TEST(daemon_runtime_request_cancel_is_exact_and_session_remains_usable) { if (request_thread_joined) { request_thread_started = false; } else { - printf(" runtime helper thread join failed: rc=%d\n", - request_thread_join_rc); + printf(" runtime helper thread join failed: rc=%d\n", request_thread_join_rc); } } if (request_thread_joined && cancel_delivered && !close_begun) { - late_duplicate_cancel = - cbm_daemon_runtime_client_application_cancel(client, - request_token); - cancels_after_duplicate = atomic_load_explicit( - &context.request_cancels, memory_order_acquire); + late_duplicate_cancel = cbm_daemon_runtime_client_application_cancel(client, request_token); + cancels_after_duplicate = + atomic_load_explicit(&context.request_cancels, memory_order_acquire); next_status = cbm_daemon_runtime_client_application_request( - client, next_request, (uint32_t)sizeof(next_request), - &next_response, &next_response_length, - RUNTIME_TEST_TIMEOUT_MS); + client, next_request, (uint32_t)sizeof(next_request), &next_response, + &next_response_length, RUNTIME_TEST_TIMEOUT_MS); next_exact = next_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && - next_response_length == sizeof(next_request) && - next_response && - memcmp(next_response, next_request, - sizeof(next_request)) == 0; - heartbeat = cbm_daemon_runtime_client_heartbeat( - client, RUNTIME_TEST_TIMEOUT_MS); + next_response_length == sizeof(next_request) && next_response && + memcmp(next_response, next_request, sizeof(next_request)) == 0; + heartbeat = cbm_daemon_runtime_client_heartbeat(client, RUNTIME_TEST_TIMEOUT_MS); } free(next_response); if (request_thread_started) { /* Keep call/client storage alive until the helper's final release * store even when an exceptional OS join failure prevents proving * termination through the thread API. */ - while (!atomic_load_explicit(&request_thread_completed, - memory_order_acquire)) { + while (!atomic_load_explicit(&request_thread_completed, memory_order_acquire)) { struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; (void)cbm_nanosleep(&pause, NULL); } @@ -2756,26 +2753,21 @@ TEST(daemon_runtime_request_cancel_is_exact_and_session_remains_usable) { if (request_thread_joined) { request_thread_started = false; } else { - printf(" runtime helper thread join failed: rc=%d\n", - request_thread_join_rc); + printf(" runtime helper thread join failed: rc=%d\n", request_thread_join_rc); } } } if (client) { closed = close_begun - ? cbm_daemon_runtime_client_close_finish( - client, RUNTIME_TEST_TIMEOUT_MS) - : cbm_daemon_runtime_client_close( - client, RUNTIME_TEST_TIMEOUT_MS); + ? cbm_daemon_runtime_client_close_finish(client, RUNTIME_TEST_TIMEOUT_MS) + : cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); client = NULL; } if (started) { - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } - bool cancelled_response = - call.status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && - call.response == NULL && call.response_length == 0; + bool cancelled_response = call.status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED && + call.response == NULL && call.response_length == 0; free(call.response); runtime_test_fixture_finish(&fixture); @@ -2813,15 +2805,14 @@ TEST(daemon_runtime_presend_request_cancel_is_sticky_and_nonterminal) { runtime_application_context_t context; runtime_application_context_init(&context, true); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_application( - &fixture, "application-presend-cancel", &identity, &context); + bool started = runtime_test_fixture_start_application(&fixture, "application-presend-cancel", + &identity, &context); cbm_daemon_runtime_connect_result_t result = {0}; cbm_daemon_runtime_client_t *client = NULL; cbm_daemon_runtime_application_token_t request_token = CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; bool token_reserved = false; - cbm_daemon_runtime_cancel_result_t cancel = - CBM_DAEMON_RUNTIME_CANCEL_ERROR; + cbm_daemon_runtime_cancel_result_t cancel = CBM_DAEMON_RUNTIME_CANCEL_ERROR; uint8_t *cancelled_response = NULL; uint32_t cancelled_response_length = 0; cbm_daemon_runtime_application_status_t cancelled_status = @@ -2835,43 +2826,35 @@ TEST(daemon_runtime_presend_request_cancel_is_sticky_and_nonterminal) { bool exited = false; if (started) { - client = cbm_daemon_runtime_client_connect( - fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &result); + client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &result); } if (client) { - token_reserved = cbm_daemon_runtime_client_application_token_reserve( - client, &request_token); + token_reserved = + cbm_daemon_runtime_client_application_token_reserve(client, &request_token); } if (token_reserved) { - cancel = cbm_daemon_runtime_client_application_cancel( - client, request_token); - cancelled_status = - cbm_daemon_runtime_client_application_request_tagged( - client, request_token, blocking_request, - (uint32_t)sizeof(blocking_request), &cancelled_response, - &cancelled_response_length, RUNTIME_TEST_TIMEOUT_MS); + cancel = cbm_daemon_runtime_client_application_cancel(client, request_token); + cancelled_status = cbm_daemon_runtime_client_application_request_tagged( + client, request_token, blocking_request, (uint32_t)sizeof(blocking_request), + &cancelled_response, &cancelled_response_length, RUNTIME_TEST_TIMEOUT_MS); } if (cancelled_status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED) { next_status = cbm_daemon_runtime_client_application_request( - client, next_request, (uint32_t)sizeof(next_request), - &next_response, &next_response_length, - RUNTIME_TEST_TIMEOUT_MS); + client, next_request, (uint32_t)sizeof(next_request), &next_response, + &next_response_length, RUNTIME_TEST_TIMEOUT_MS); next_exact = next_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && - next_response_length == sizeof(next_request) && - next_response && - memcmp(next_response, next_request, - sizeof(next_request)) == 0; + next_response_length == sizeof(next_request) && next_response && + memcmp(next_response, next_request, sizeof(next_request)) == 0; } free(cancelled_response); free(next_response); if (client) { - closed = cbm_daemon_runtime_client_close( - client, RUNTIME_TEST_TIMEOUT_MS); + closed = cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); client = NULL; } if (started) { - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } runtime_test_fixture_finish(&fixture); @@ -2899,8 +2882,8 @@ TEST(daemon_runtime_allows_only_one_unstarted_application_token) { runtime_application_context_t context; runtime_application_context_init(&context, false); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_application( - &fixture, "application-token-reservation", &identity, &context); + bool started = runtime_test_fixture_start_application(&fixture, "application-token-reservation", + &identity, &context); cbm_daemon_runtime_connect_result_t result = {0}; cbm_daemon_runtime_client_t *client = NULL; cbm_daemon_runtime_application_token_t first_token = @@ -2917,15 +2900,13 @@ TEST(daemon_runtime_allows_only_one_unstarted_application_token) { bool exited = false; if (started) { - client = cbm_daemon_runtime_client_connect( - fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &result); + client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &result); } if (client) { - first_reserved = cbm_daemon_runtime_client_application_token_reserve( - client, &first_token); + first_reserved = cbm_daemon_runtime_client_application_token_reserve(client, &first_token); duplicate_reservation_rejected = - !cbm_daemon_runtime_client_application_token_reserve( - client, &rejected_token) && + !cbm_daemon_runtime_client_application_token_reserve(client, &rejected_token) && rejected_token == CBM_DAEMON_RUNTIME_APPLICATION_TOKEN_INVALID; } if (first_reserved && duplicate_reservation_rejected) { @@ -2933,41 +2914,35 @@ TEST(daemon_runtime_allows_only_one_unstarted_application_token) { uint32_t response_length = 0; cbm_daemon_runtime_application_status_t status = cbm_daemon_runtime_client_application_request_tagged( - client, first_token, first_request, - (uint32_t)sizeof(first_request), &response, &response_length, - RUNTIME_TEST_TIMEOUT_MS); + client, first_token, first_request, (uint32_t)sizeof(first_request), &response, + &response_length, RUNTIME_TEST_TIMEOUT_MS); first_exact = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response_length == sizeof(first_request) && response && - memcmp(response, first_request, sizeof(first_request)) == - 0; + memcmp(response, first_request, sizeof(first_request)) == 0; free(response); } if (first_exact) { - second_reserved = cbm_daemon_runtime_client_application_token_reserve( - client, &second_token); + second_reserved = + cbm_daemon_runtime_client_application_token_reserve(client, &second_token); } if (second_reserved) { uint8_t *response = NULL; uint32_t response_length = 0; cbm_daemon_runtime_application_status_t status = cbm_daemon_runtime_client_application_request_tagged( - client, second_token, second_request, - (uint32_t)sizeof(second_request), &response, &response_length, - RUNTIME_TEST_TIMEOUT_MS); + client, second_token, second_request, (uint32_t)sizeof(second_request), &response, + &response_length, RUNTIME_TEST_TIMEOUT_MS); second_exact = status == CBM_DAEMON_RUNTIME_APPLICATION_OK && response_length == sizeof(second_request) && response && - memcmp(response, second_request, - sizeof(second_request)) == 0; + memcmp(response, second_request, sizeof(second_request)) == 0; free(response); } if (client) { - closed = cbm_daemon_runtime_client_close( - client, RUNTIME_TEST_TIMEOUT_MS); + closed = cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); client = NULL; } if (started) { - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } runtime_test_fixture_finish(&fixture); @@ -2993,8 +2968,8 @@ TEST(daemon_runtime_consumes_busy_application_token_before_response) { runtime_application_context_t context; runtime_application_context_init(&context, true); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_application( - &fixture, "application-token-replay", &identity, &context); + bool started = runtime_test_fixture_start_application(&fixture, "application-token-replay", + &identity, &context); cbm_daemon_ipc_connection_t *raw = NULL; bool first_sent = false; bool first_started = false; @@ -3010,48 +2985,41 @@ TEST(daemon_runtime_consumes_busy_application_token_before_response) { raw = runtime_test_raw_client_connect(fixture.endpoint, &identity); } if (raw) { - first_sent = runtime_test_raw_application_send_token( - raw, FIRST_TOKEN, blocking_request, - (uint32_t)sizeof(blocking_request), - (uint32_t)sizeof(blocking_request)); - first_started = first_sent && runtime_test_wait_atomic_bool( - &context.first_request_started, - RUNTIME_TEST_TIMEOUT_MS); + first_sent = runtime_test_raw_application_send_token(raw, FIRST_TOKEN, blocking_request, + (uint32_t)sizeof(blocking_request), + (uint32_t)sizeof(blocking_request)); + first_started = first_sent && runtime_test_wait_atomic_bool(&context.first_request_started, + RUNTIME_TEST_TIMEOUT_MS); } if (first_started) { - busy_sent = runtime_test_raw_application_send_token( - raw, BUSY_TOKEN, busy_request, (uint32_t)sizeof(busy_request), - (uint32_t)sizeof(busy_request)); - busy_received = - busy_sent && runtime_test_raw_application_receive_status_token( - raw, BUSY_TOKEN, - CBM_DAEMON_RUNTIME_APPLICATION_BUSY); + busy_sent = runtime_test_raw_application_send_token(raw, BUSY_TOKEN, busy_request, + (uint32_t)sizeof(busy_request), + (uint32_t)sizeof(busy_request)); + busy_received = busy_sent && runtime_test_raw_application_receive_status_token( + raw, BUSY_TOKEN, CBM_DAEMON_RUNTIME_APPLICATION_BUSY); } if (busy_received) { cancel_sent = runtime_test_raw_application_cancel(raw, FIRST_TOKEN); cancellation_received = cancel_sent && runtime_test_raw_application_receive_status_token( - raw, FIRST_TOKEN, - CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); + raw, FIRST_TOKEN, CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED); } if (cancellation_received) { - replay_sent = runtime_test_raw_application_send_token( - raw, BUSY_TOKEN, busy_request, (uint32_t)sizeof(busy_request), - (uint32_t)sizeof(busy_request)); + replay_sent = runtime_test_raw_application_send_token(raw, BUSY_TOKEN, busy_request, + (uint32_t)sizeof(busy_request), + (uint32_t)sizeof(busy_request)); } if (replay_sent) { cbm_daemon_frame_t frame = {0}; uint8_t *payload = NULL; - int received = cbm_daemon_ipc_receive_frame( - raw, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); + int received = cbm_daemon_ipc_receive_frame(raw, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); replay_rejected = received != 1; free(payload); } cbm_daemon_ipc_connection_close(raw); raw = NULL; if (started) { - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } runtime_test_fixture_finish(&fixture); @@ -3077,50 +3045,45 @@ TEST(daemon_runtime_close_begin_retains_storage_and_rejects_late_exchange) { runtime_application_context_t context; runtime_application_context_init(&context, false); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_application( - &fixture, "application-close-gap", &identity, &context); + bool started = runtime_test_fixture_start_application(&fixture, "application-close-gap", + &identity, &context); cbm_daemon_runtime_connect_result_t result = {0}; cbm_daemon_runtime_client_t *client = NULL; uint8_t *response = NULL; uint32_t response_length = 0; - cbm_daemon_runtime_application_status_t status = - CBM_DAEMON_RUNTIME_APPLICATION_OK; + cbm_daemon_runtime_application_status_t status = CBM_DAEMON_RUNTIME_APPLICATION_OK; bool close_begun = false; bool duplicate_begin_rejected = false; bool close_acknowledged = false; bool exited = false; if (started) { - client = cbm_daemon_runtime_client_connect( - fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &result); + client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &result); } if (client) { close_begun = cbm_daemon_runtime_client_close_begin(client); - duplicate_begin_rejected = - close_begun && !cbm_daemon_runtime_client_close_begin(client); + duplicate_begin_rejected = close_begun && !cbm_daemon_runtime_client_close_begin(client); } if (close_begun) { /* Deterministically models the frontend boundary where close begins * after a worker claims an item but before it enters the runtime API. * The retained allocation must reject the call without touching IPC. */ status = cbm_daemon_runtime_client_application_request( - client, request, (uint32_t)sizeof(request), &response, - &response_length, RUNTIME_TEST_TIMEOUT_MS); - close_acknowledged = cbm_daemon_runtime_client_close_finish( - client, RUNTIME_TEST_TIMEOUT_MS); + client, request, (uint32_t)sizeof(request), &response, &response_length, + RUNTIME_TEST_TIMEOUT_MS); + close_acknowledged = + cbm_daemon_runtime_client_close_finish(client, RUNTIME_TEST_TIMEOUT_MS); client = NULL; - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } free(response); if (client) { if (close_begun) { - (void)cbm_daemon_runtime_client_close_finish( - client, RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close_finish(client, RUNTIME_TEST_TIMEOUT_MS); } else { - (void)cbm_daemon_runtime_client_close(client, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); } } runtime_test_fixture_finish(&fixture); @@ -3149,8 +3112,8 @@ TEST(daemon_runtime_disconnect_cancels_blocked_application_before_exit) { atomic_bool request_thread_completed; atomic_init(&request_thread_completed, false); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_application( - &fixture, "application-cancel", &identity, &context); + bool started = + runtime_test_fixture_start_application(&fixture, "application-cancel", &identity, &context); cbm_daemon_runtime_client_t *client = NULL; cbm_daemon_runtime_connect_result_t result = {0}; runtime_application_client_call_t call = { @@ -3173,23 +3136,19 @@ TEST(daemon_runtime_disconnect_cancels_blocked_application_before_exit) { if (started) { client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, - RUNTIME_TEST_TIMEOUT_MS, - &result); + RUNTIME_TEST_TIMEOUT_MS, &result); } if (client) { call.client = client; request_thread_create_rc = cbm_thread_create( - &request_thread, 128U * 1024U, - runtime_application_client_request_thread, &call); + &request_thread, 128U * 1024U, runtime_application_client_request_thread, &call); request_thread_started = request_thread_create_rc == 0; if (!request_thread_started) { - printf(" runtime helper thread create failed: rc=%d\n", - request_thread_create_rc); + printf(" runtime helper thread create failed: rc=%d\n", request_thread_create_rc); } - callback_started = request_thread_started && - runtime_test_wait_atomic_bool( - &context.first_request_started, - RUNTIME_TEST_TIMEOUT_MS); + callback_started = + request_thread_started && + runtime_test_wait_atomic_bool(&context.first_request_started, RUNTIME_TEST_TIMEOUT_MS); } if (callback_started) { close_begun = cbm_daemon_runtime_client_close_begin(client); @@ -3204,8 +3163,7 @@ TEST(daemon_runtime_disconnect_cancels_blocked_application_before_exit) { if (request_thread_joined) { request_thread_started = false; } else { - printf(" runtime helper thread join failed: rc=%d\n", - request_thread_join_rc); + printf(" runtime helper thread join failed: rc=%d\n", request_thread_join_rc); } } @@ -3214,8 +3172,7 @@ TEST(daemon_runtime_disconnect_cancels_blocked_application_before_exit) { * release-store before touching call/client/fixture state. This is * intentionally unbounded, matching the successful join path's * semantics while keeping an exceptional cleanup path free of UAF. */ - while (!atomic_load_explicit(&request_thread_completed, - memory_order_acquire)) { + while (!atomic_load_explicit(&request_thread_completed, memory_order_acquire)) { struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; (void)cbm_nanosleep(&pause, NULL); } @@ -3226,27 +3183,22 @@ TEST(daemon_runtime_disconnect_cancels_blocked_application_before_exit) { if (request_thread_joined) { request_thread_started = false; } else { - printf(" runtime helper thread join failed: rc=%d\n", - request_thread_join_rc); + printf(" runtime helper thread join failed: rc=%d\n", request_thread_join_rc); } } } if (client) { - close_acknowledged = close_begun - ? cbm_daemon_runtime_client_close_finish( - client, RUNTIME_TEST_TIMEOUT_MS) - : cbm_daemon_runtime_client_close( - client, RUNTIME_TEST_TIMEOUT_MS); + close_acknowledged = + close_begun ? cbm_daemon_runtime_client_close_finish(client, RUNTIME_TEST_TIMEOUT_MS) + : cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); client = NULL; } if (started) { - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } - request_interrupted = - call.status == CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR && - call.response == NULL && call.response_length == 0; + request_interrupted = call.status == CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR && + call.response == NULL && call.response_length == 0; free(call.response); runtime_test_fixture_finish(&fixture); @@ -3270,6 +3222,236 @@ TEST(daemon_runtime_disconnect_cancels_blocked_application_before_exit) { PASS(); } +TEST(daemon_runtime_disconnect_cancels_blocked_non_index_child_and_preserves_other_session) { +#if !defined(_WIN32) && !defined(__APPLE__) && !defined(__linux__) + SKIP_PLATFORM("requires a queryable copied process image"); +#else + enum { + CHILD_READY_BOUND_MS = 5000, + CHILD_CANCEL_BOUND_MS = 3000, + CHILD_CLEANUP_BOUND_MS = 5000, + REQUEST_TIMEOUT_MS = 15000, + }; + const char *old_cache = getenv("CBM_CACHE_DIR"); + const char *old_path = getenv("PATH"); + const char *old_marker = getenv(RUNTIME_TEST_BLOCKING_GIT_MARKER_ENV); + bool had_cache = old_cache != NULL; + bool had_path = old_path != NULL; + bool had_marker = old_marker != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + char *saved_path = old_path ? cbm_strdup(old_path) : NULL; + char *saved_marker = old_marker ? cbm_strdup(old_marker) : NULL; + bool snapshots_ok = + (!had_cache || saved_cache) && (!had_path || saved_path) && (!had_marker || saved_marker); + + char work[RUNTIME_TEST_PATH_CAP] = {0}; + char root[RUNTIME_TEST_PATH_CAP] = {0}; + char cache[RUNTIME_TEST_PATH_CAP] = {0}; + char bin[RUNTIME_TEST_PATH_CAP] = {0}; + char fake_git[RUNTIME_TEST_PATH_CAP] = {0}; + char marker[RUNTIME_TEST_PATH_CAP] = {0}; + (void)snprintf(work, sizeof(work), "%s/cbm-runtime-non-index-XXXXXX", cbm_tmpdir()); + bool work_ready = snapshots_ok && cbm_mkdtemp(work) != NULL; + int root_written = work_ready ? snprintf(root, sizeof(root), "%s/root", work) : -1; + int cache_written = work_ready ? snprintf(cache, sizeof(cache), "%s/cache", work) : -1; + int bin_written = work_ready ? snprintf(bin, sizeof(bin), "%s/bin", work) : -1; +#ifdef _WIN32 + int git_written = work_ready ? snprintf(fake_git, sizeof(fake_git), "%s/git.exe", bin) : -1; +#else + int git_written = work_ready ? snprintf(fake_git, sizeof(fake_git), "%s/git", bin) : -1; +#endif + int marker_written = + work_ready ? snprintf(marker, sizeof(marker), "%s/blocking-git.pid", work) : -1; + bool paths_ready = work_ready && root_written > 0 && root_written < (int)sizeof(root) && + cache_written > 0 && cache_written < (int)sizeof(cache) && bin_written > 0 && + bin_written < (int)sizeof(bin) && git_written > 0 && + git_written < (int)sizeof(fake_git) && marker_written > 0 && + marker_written < (int)sizeof(marker) && cbm_mkdir_p(root, 0700) && + cbm_mkdir_p(cache, 0700) && cbm_mkdir_p(bin, 0700) && + runtime_test_copy_self_image(fake_git); + + char *project = paths_ready ? cbm_project_name_from_path(root) : NULL; + char db_path[RUNTIME_TEST_PATH_CAP] = {0}; + int db_written = project ? snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project) : -1; + cbm_store_t *seed = + db_written > 0 && db_written < (int)sizeof(db_path) ? cbm_store_open_path(db_path) : NULL; + bool seeded = seed && cbm_store_upsert_project(seed, project, root) == CBM_STORE_OK; + cbm_store_close(seed); + + size_t path_length = strlen(bin) + 2U + (saved_path ? strlen(saved_path) : 0U); + char *test_path = seeded ? malloc(path_length) : NULL; + if (test_path) { +#ifdef _WIN32 + (void)snprintf(test_path, path_length, "%s;%s", bin, saved_path ? saved_path : ""); +#else + (void)snprintf(test_path, path_length, "%s:%s", bin, saved_path ? saved_path : ""); +#endif + } + bool environment_ready = test_path && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 && + cbm_setenv("PATH", test_path, 1) == 0 && + cbm_setenv(RUNTIME_TEST_BLOCKING_GIT_MARKER_ENV, marker, 1) == 0; + + cbm_daemon_application_t *application = + environment_ready ? cbm_daemon_application_new(NULL) : NULL; + cbm_daemon_runtime_application_callbacks_t callbacks = + cbm_daemon_application_runtime_callbacks(application); + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t fixture = {0}; + bool started = + application && runtime_test_fixture_start_configured(&fixture, "non-index-child-cancel", + &identity, 8, 5000, &callbacks); + cbm_daemon_runtime_connect_result_t first_result = {0}; + cbm_daemon_runtime_connect_result_t second_result = {0}; + cbm_daemon_runtime_client_t *first = + started ? cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &first_result) + : NULL; + cbm_daemon_runtime_client_t *second = + first ? cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &second_result) + : NULL; + bool contexts_set = + first && second && + cbm_daemon_application_client_set_context(first, root, root, CBM_MCP_TOOL_PROFILE_ALL, NULL, + NULL, RUNTIME_TEST_TIMEOUT_MS) == + CBM_DAEMON_RUNTIME_APPLICATION_OK && + cbm_daemon_application_client_set_context(second, root, root, CBM_MCP_TOOL_PROFILE_ALL, + NULL, NULL, RUNTIME_TEST_TIMEOUT_MS) == + CBM_DAEMON_RUNTIME_APPLICATION_OK; + bool second_usable_before = + contexts_set && runtime_real_application_ingest_probe(second) && + cbm_daemon_runtime_client_heartbeat(second, RUNTIME_TEST_TIMEOUT_MS); + + runtime_real_application_call_t call = { + .client = first, + .timeout_ms = REQUEST_TIMEOUT_MS, + .status = CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR, + }; + atomic_init(&call.completed, false); + int arguments_written = + project ? snprintf(call.arguments, sizeof(call.arguments), "{\"project\":\"%s\"}", project) + : -1; + cbm_thread_t request_thread; + int request_thread_create_rc = + second_usable_before && arguments_written > 0 && + arguments_written < (int)sizeof(call.arguments) + ? cbm_thread_create(&request_thread, 128U * 1024U, + runtime_real_application_detect_changes_thread, &call) + : -1; + bool request_thread_started = request_thread_create_rc == 0; + uint64_t child_process_id = 0; + bool marker_published = + request_thread_started && + runtime_test_wait_pid_marker(marker, CHILD_READY_BOUND_MS, &child_process_id); + if (request_thread_started && !marker_published) { + printf(" blocking git marker missing: completed=%d status=%d response=%.*s\n", + atomic_load_explicit(&call.completed, memory_order_acquire) ? 1 : 0, + (int)call.status, (int)call.response_length, + call.response ? (const char *)call.response : ""); + } + bool child_identity_exact = + marker_published && runtime_test_process_image_matches(child_process_id, fake_git); + + bool first_close_begun = child_identity_exact && cbm_daemon_runtime_client_close_begin(first); + bool only_second_admitted = + first_close_begun && + cbm_daemon_runtime_service_wait_for_clients(fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); + bool child_gone_without_backstop = + first_close_begun && + runtime_test_wait_process_image_gone(child_process_id, fake_git, CHILD_CANCEL_BOUND_MS); + bool cleanup_backstop_used = child_identity_exact && !child_gone_without_backstop; + bool child_cleanup_complete = child_gone_without_backstop || + (cleanup_backstop_used && runtime_test_force_terminate_verified( + child_process_id, fake_git)); + if (child_cleanup_complete && !child_gone_without_backstop) { + child_cleanup_complete = runtime_test_wait_process_image_gone(child_process_id, fake_git, + CHILD_CLEANUP_BOUND_MS); + } + + bool request_completed = request_thread_started && child_cleanup_complete && + runtime_test_wait_atomic_bool(&call.completed, CHILD_CLEANUP_BOUND_MS); + int request_thread_join_rc = request_completed ? cbm_thread_join(&request_thread) : -1; + if (request_thread_join_rc == 0) { + request_thread_started = false; + } + bool first_transport_interrupted = + request_completed && call.status == CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR && + call.response == NULL && call.response_length == 0; + bool first_close_finished = false; + if (first && !request_thread_started) { + first_close_finished = + first_close_begun + ? cbm_daemon_runtime_client_close_finish(first, RUNTIME_TEST_TIMEOUT_MS) + : cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS); + first = NULL; + } + + bool second_usable_after = second && child_cleanup_complete && + runtime_real_application_ingest_probe(second) && + cbm_daemon_runtime_client_heartbeat(second, RUNTIME_TEST_TIMEOUT_MS); + bool second_closed = false; + if (second) { + second_closed = cbm_daemon_runtime_client_close(second, RUNTIME_TEST_TIMEOUT_MS); + second = NULL; + } + bool exited = + started && cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); + if (first && !request_thread_started) { + (void)cbm_daemon_runtime_client_close(first, RUNTIME_TEST_TIMEOUT_MS); + first = NULL; + } + runtime_test_fixture_finish(&fixture); + bool application_stopped = + application && cbm_daemon_application_shutdown(application, RUNTIME_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + + runtime_test_restore_environment(RUNTIME_TEST_BLOCKING_GIT_MARKER_ENV, saved_marker, + had_marker); + runtime_test_restore_environment("PATH", saved_path, had_path); + runtime_test_restore_environment("CBM_CACHE_DIR", saved_cache, had_cache); + free(call.response); + free(test_path); + free(project); + free(saved_marker); + free(saved_path); + free(saved_cache); + bool files_removed = !work_ready || th_rmtree(work) == 0; + + ASSERT_TRUE(snapshots_ok); + ASSERT_TRUE(paths_ready); + ASSERT_TRUE(seeded); + ASSERT_TRUE(environment_ready); + ASSERT_TRUE(started); + ASSERT_EQ(first_result.status, CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED); + ASSERT_EQ(second_result.status, CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED); + ASSERT_TRUE(contexts_set); + ASSERT_TRUE(second_usable_before); + ASSERT_EQ(request_thread_create_rc, 0); + ASSERT_TRUE(marker_published); + ASSERT_TRUE(child_identity_exact); + ASSERT_TRUE(first_close_begun); + ASSERT_TRUE(only_second_admitted); + /* The contained non-index child belongs to the first request and must be + * gone before the independently owned second session is exercised again. */ + ASSERT_TRUE(child_gone_without_backstop); + ASSERT_FALSE(cleanup_backstop_used); + ASSERT_TRUE(child_cleanup_complete); + ASSERT_TRUE(request_completed); + ASSERT_EQ(request_thread_join_rc, 0); + ASSERT_TRUE(first_transport_interrupted); + /* close_begin interrupted an active exchange, so there is intentionally no + * DISCONNECT acknowledgement even though server-side teardown completes. */ + ASSERT_FALSE(first_close_finished); + ASSERT_TRUE(second_usable_after); + ASSERT_TRUE(second_closed); + ASSERT_TRUE(exited); + ASSERT_TRUE(application_stopped); + ASSERT_TRUE(files_removed); + PASS(); +#endif +} + TEST(daemon_runtime_noncooperative_callback_does_not_detach_or_unbound_stop) { static const uint8_t request[] = {'i', 'g', 'n', 'o', 'r', 'e'}; enum { STOP_BOUND_MS = 50, STOP_OBSERVED_MAX_MS = 500 }; @@ -3277,13 +3459,12 @@ TEST(daemon_runtime_noncooperative_callback_does_not_detach_or_unbound_stop) { runtime_test_identity("2.4.0", runtime_test_self_build()); runtime_application_context_t context; runtime_application_context_init(&context, true); - atomic_store_explicit(&context.ignore_first_request_cancel, true, - memory_order_release); + atomic_store_explicit(&context.ignore_first_request_cancel, true, memory_order_release); atomic_bool request_thread_completed; atomic_init(&request_thread_completed, false); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_application( - &fixture, "application-noncooperative", &identity, &context); + bool started = runtime_test_fixture_start_application(&fixture, "application-noncooperative", + &identity, &context); cbm_daemon_runtime_connect_result_t result = {0}; cbm_daemon_runtime_client_t *client = NULL; runtime_application_client_call_t call = { @@ -3305,36 +3486,31 @@ TEST(daemon_runtime_noncooperative_callback_does_not_detach_or_unbound_stop) { bool exited_after_release = false; if (started) { - client = cbm_daemon_runtime_client_connect( - fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, &result); + client = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &result); } if (client) { call.client = client; request_thread_create_rc = cbm_thread_create( - &request_thread, 128U * 1024U, - runtime_application_client_request_thread, &call); + &request_thread, 128U * 1024U, runtime_application_client_request_thread, &call); request_thread_started = request_thread_create_rc == 0; - callback_started = request_thread_started && - runtime_test_wait_atomic_bool( - &context.first_request_started, - RUNTIME_TEST_TIMEOUT_MS); + callback_started = + request_thread_started && + runtime_test_wait_atomic_bool(&context.first_request_started, RUNTIME_TEST_TIMEOUT_MS); } if (callback_started) { close_begun = cbm_daemon_runtime_client_close_begin(client); uint64_t stop_started_ms = cbm_now_ms(); - stop_returned = cbm_daemon_runtime_service_stop( - fixture.service, STOP_BOUND_MS); + stop_returned = cbm_daemon_runtime_service_stop(fixture.service, STOP_BOUND_MS); stop_elapsed_ms = cbm_now_ms() - stop_started_ms; exited_before_release = - cbm_daemon_runtime_service_state(fixture.service) == - CBM_DAEMON_RUNTIME_SERVICE_EXITED; + cbm_daemon_runtime_service_state(fixture.service) == CBM_DAEMON_RUNTIME_SERVICE_EXITED; } /* A bounded stop failure retains every callback/session allocation. The * test supplies eventual cooperation so the runner can prove clean join * and teardown after observing the production host's force boundary. */ - atomic_store_explicit(&context.release_first_request, true, - memory_order_release); + atomic_store_explicit(&context.release_first_request, true, memory_order_release); if (request_thread_started) { request_thread_join_rc = cbm_thread_join(&request_thread); if (request_thread_join_rc == 0) { @@ -3342,23 +3518,20 @@ TEST(daemon_runtime_noncooperative_callback_does_not_detach_or_unbound_stop) { } } if (request_thread_started) { - while (!atomic_load_explicit(&request_thread_completed, - memory_order_acquire)) { + while (!atomic_load_explicit(&request_thread_completed, memory_order_acquire)) { struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; (void)cbm_nanosleep(&pause, NULL); } } if (client) { - close_finished = close_begun - ? cbm_daemon_runtime_client_close_finish( - client, RUNTIME_TEST_TIMEOUT_MS) - : cbm_daemon_runtime_client_close( - client, RUNTIME_TEST_TIMEOUT_MS); + close_finished = + close_begun ? cbm_daemon_runtime_client_close_finish(client, RUNTIME_TEST_TIMEOUT_MS) + : cbm_daemon_runtime_client_close(client, RUNTIME_TEST_TIMEOUT_MS); client = NULL; } if (started) { - exited_after_release = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited_after_release = + cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } free(call.response); runtime_test_fixture_finish(&fixture); @@ -3392,8 +3565,8 @@ TEST(daemon_runtime_application_busy_cap_and_malformed_are_isolated) { runtime_application_context_t context; runtime_application_context_init(&context, true); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_application( - &fixture, "application-isolation", &identity, &context); + bool started = runtime_test_fixture_start_application(&fixture, "application-isolation", + &identity, &context); cbm_daemon_runtime_client_t *owner = NULL; cbm_daemon_runtime_connect_result_t owner_result = {0}; cbm_daemon_ipc_connection_t *raw = NULL; @@ -3406,8 +3579,7 @@ TEST(daemon_runtime_application_busy_cap_and_malformed_are_isolated) { uint8_t sentinel = 0x5a; uint8_t *oversize_response = (uint8_t *)&sentinel; uint32_t oversize_response_length = UINT32_MAX; - cbm_daemon_runtime_application_status_t oversize_status = - CBM_DAEMON_RUNTIME_APPLICATION_OK; + cbm_daemon_runtime_application_status_t oversize_status = CBM_DAEMON_RUNTIME_APPLICATION_OK; uint8_t *valid_response = NULL; uint32_t valid_response_length = 0; cbm_daemon_runtime_application_status_t valid_status = @@ -3417,25 +3589,22 @@ TEST(daemon_runtime_application_busy_cap_and_malformed_are_isolated) { if (started) { owner = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, - RUNTIME_TEST_TIMEOUT_MS, - &owner_result); + RUNTIME_TEST_TIMEOUT_MS, &owner_result); raw = runtime_test_raw_client_connect(fixture.endpoint, &identity); } if (owner && raw) { - first_sent = runtime_test_raw_application_send( - raw, blocking_request, (uint32_t)sizeof(blocking_request), - (uint32_t)sizeof(blocking_request)); - callback_started = first_sent && runtime_test_wait_atomic_bool( - &context.first_request_started, - RUNTIME_TEST_TIMEOUT_MS); + first_sent = runtime_test_raw_application_send(raw, blocking_request, + (uint32_t)sizeof(blocking_request), + (uint32_t)sizeof(blocking_request)); + callback_started = + first_sent && + runtime_test_wait_atomic_bool(&context.first_request_started, RUNTIME_TEST_TIMEOUT_MS); } if (callback_started) { busy_sent = runtime_test_raw_application_send( - raw, busy_request, (uint32_t)sizeof(busy_request), - (uint32_t)sizeof(busy_request)); - busy_rejected = busy_sent && - runtime_test_raw_application_receive_status( - raw, CBM_DAEMON_RUNTIME_APPLICATION_BUSY); + raw, busy_request, (uint32_t)sizeof(busy_request), (uint32_t)sizeof(busy_request)); + busy_rejected = busy_sent && runtime_test_raw_application_receive_status( + raw, CBM_DAEMON_RUNTIME_APPLICATION_BUSY); } if (busy_rejected) { malformed_sent = runtime_test_raw_application_send( @@ -3445,41 +3614,32 @@ TEST(daemon_runtime_application_busy_cap_and_malformed_are_isolated) { cbm_daemon_ipc_connection_close(raw); raw = NULL; if (malformed_sent) { - bad_peer_released = cbm_daemon_runtime_service_wait_for_clients( - fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); + bad_peer_released = cbm_daemon_runtime_service_wait_for_clients(fixture.service, 1, + RUNTIME_TEST_TIMEOUT_MS); } if (owner && bad_peer_released) { oversize_status = cbm_daemon_runtime_client_application_request( - owner, &sentinel, - CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX + 1U, - &oversize_response, &oversize_response_length, - RUNTIME_TEST_TIMEOUT_MS); + owner, &sentinel, CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX + 1U, &oversize_response, + &oversize_response_length, RUNTIME_TEST_TIMEOUT_MS); valid_status = cbm_daemon_runtime_client_application_request( - owner, valid_request, (uint32_t)sizeof(valid_request), - &valid_response, &valid_response_length, - RUNTIME_TEST_TIMEOUT_MS); - owner_survived = - oversize_status == - CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR && - oversize_response == NULL && oversize_response_length == 0 && - valid_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && - valid_response_length == sizeof(valid_request) && valid_response && - memcmp(valid_response, valid_request, sizeof(valid_request)) == 0 && - cbm_daemon_runtime_client_heartbeat(owner, - RUNTIME_TEST_TIMEOUT_MS); + owner, valid_request, (uint32_t)sizeof(valid_request), &valid_response, + &valid_response_length, RUNTIME_TEST_TIMEOUT_MS); + owner_survived = oversize_status == CBM_DAEMON_RUNTIME_APPLICATION_TRANSPORT_ERROR && + oversize_response == NULL && oversize_response_length == 0 && + valid_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + valid_response_length == sizeof(valid_request) && valid_response && + memcmp(valid_response, valid_request, sizeof(valid_request)) == 0 && + cbm_daemon_runtime_client_heartbeat(owner, RUNTIME_TEST_TIMEOUT_MS); free(valid_response); valid_response = NULL; - (void)cbm_daemon_runtime_client_close(owner, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); owner = NULL; - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } free(valid_response); if (owner) { - (void)cbm_daemon_runtime_client_close(owner, - RUNTIME_TEST_TIMEOUT_MS); + (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); } cbm_daemon_ipc_connection_close(raw); runtime_test_fixture_finish(&fixture); @@ -3509,8 +3669,8 @@ TEST(daemon_runtime_malformed_and_zero_cancel_close_only_offending_connections) runtime_application_context_t context; runtime_application_context_init(&context, false); runtime_test_fixture_t fixture; - bool started = runtime_test_fixture_start_application( - &fixture, "application-cancel-isolation", &identity, &context); + bool started = runtime_test_fixture_start_application(&fixture, "application-cancel-isolation", + &identity, &context); cbm_daemon_runtime_connect_result_t owner_result = {0}; cbm_daemon_runtime_client_t *owner = NULL; cbm_daemon_ipc_connection_t *raw = NULL; @@ -3531,9 +3691,8 @@ TEST(daemon_runtime_malformed_and_zero_cancel_close_only_offending_connections) bool exited = false; if (started) { - owner = cbm_daemon_runtime_client_connect( - fixture.endpoint, &identity, RUNTIME_TEST_TIMEOUT_MS, - &owner_result); + owner = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &owner_result); } if (owner) { raw = runtime_test_raw_client_connect(fixture.endpoint, &identity); @@ -3541,19 +3700,17 @@ TEST(daemon_runtime_malformed_and_zero_cancel_close_only_offending_connections) } if (raw) { malformed_sent = cbm_daemon_ipc_send_frame( - raw, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, malformed_cancel, - (uint32_t)sizeof(malformed_cancel)); + raw, CBM_DAEMON_FRAME_REQUEST, CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, + malformed_cancel, (uint32_t)sizeof(malformed_cancel)); } if (malformed_sent) { - malformed_released = cbm_daemon_runtime_service_wait_for_clients( - fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); + malformed_released = cbm_daemon_runtime_service_wait_for_clients(fixture.service, 1, + RUNTIME_TEST_TIMEOUT_MS); } if (malformed_released) { cbm_daemon_frame_t frame = {0}; uint8_t *payload = NULL; - int received = cbm_daemon_ipc_receive_frame( - raw, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); + int received = cbm_daemon_ipc_receive_frame(raw, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); malformed_closed = received != 1; free(payload); } @@ -3566,19 +3723,17 @@ TEST(daemon_runtime_malformed_and_zero_cancel_close_only_offending_connections) } if (raw) { zero_sent = cbm_daemon_ipc_send_frame( - raw, CBM_DAEMON_FRAME_REQUEST, - CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, zero_token_cancel, - (uint32_t)sizeof(zero_token_cancel)); + raw, CBM_DAEMON_FRAME_REQUEST, CBM_DAEMON_RUNTIME_OP_APPLICATION_CANCEL, + zero_token_cancel, (uint32_t)sizeof(zero_token_cancel)); } if (zero_sent) { - zero_released = cbm_daemon_runtime_service_wait_for_clients( - fixture.service, 1, RUNTIME_TEST_TIMEOUT_MS); + zero_released = cbm_daemon_runtime_service_wait_for_clients(fixture.service, 1, + RUNTIME_TEST_TIMEOUT_MS); } if (zero_released) { cbm_daemon_frame_t frame = {0}; uint8_t *payload = NULL; - int received = cbm_daemon_ipc_receive_frame( - raw, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); + int received = cbm_daemon_ipc_receive_frame(raw, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); zero_closed = received != 1; free(payload); } @@ -3587,25 +3742,20 @@ TEST(daemon_runtime_malformed_and_zero_cancel_close_only_offending_connections) if (owner && zero_closed) { valid_status = cbm_daemon_runtime_client_application_request( - owner, valid_request, (uint32_t)sizeof(valid_request), - &valid_response, &valid_response_length, - RUNTIME_TEST_TIMEOUT_MS); - owner_survived = - valid_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && - valid_response_length == sizeof(valid_request) && valid_response && - memcmp(valid_response, valid_request, sizeof(valid_request)) == 0 && - cbm_daemon_runtime_client_heartbeat(owner, - RUNTIME_TEST_TIMEOUT_MS); + owner, valid_request, (uint32_t)sizeof(valid_request), &valid_response, + &valid_response_length, RUNTIME_TEST_TIMEOUT_MS); + owner_survived = valid_status == CBM_DAEMON_RUNTIME_APPLICATION_OK && + valid_response_length == sizeof(valid_request) && valid_response && + memcmp(valid_response, valid_request, sizeof(valid_request)) == 0 && + cbm_daemon_runtime_client_heartbeat(owner, RUNTIME_TEST_TIMEOUT_MS); } free(valid_response); if (owner) { - owner_closed = cbm_daemon_runtime_client_close( - owner, RUNTIME_TEST_TIMEOUT_MS); + owner_closed = cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); owner = NULL; } if (started) { - exited = cbm_daemon_runtime_service_wait_exited( - fixture.service, RUNTIME_TEST_TIMEOUT_MS); + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); } cbm_daemon_ipc_connection_close(raw); runtime_test_fixture_finish(&fixture); @@ -3634,12 +3784,10 @@ TEST(daemon_runtime_kernel_process_fingerprint_is_stable_and_fail_closed) { char first[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; char repeated[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; char invalid[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; - bool first_ok = cbm_daemon_runtime_process_build_fingerprint( - runtime_test_process_id(), first); - bool repeated_ok = cbm_daemon_runtime_process_build_fingerprint( - runtime_test_process_id(), repeated); - bool invalid_rejected = !cbm_daemon_runtime_process_build_fingerprint( - UINT64_MAX, invalid); + bool first_ok = cbm_daemon_runtime_process_build_fingerprint(runtime_test_process_id(), first); + bool repeated_ok = + cbm_daemon_runtime_process_build_fingerprint(runtime_test_process_id(), repeated); + bool invalid_rejected = !cbm_daemon_runtime_process_build_fingerprint(UINT64_MAX, invalid); ASSERT_TRUE(first_ok); ASSERT_TRUE(repeated_ok); @@ -3662,51 +3810,40 @@ TEST(daemon_runtime_mac_fast_path_rejects_foreign_main_image_mapping_active) { char active_image[RUNTIME_TEST_PATH_CAP] = {0}; char foreign_image[RUNTIME_TEST_PATH_CAP] = {0}; char foreign_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; - int directory_written = snprintf( - directory, sizeof(directory), "%s/cbm-runtime-mapped-image-XXXXXX", - cbm_tmpdir()); - bool directory_created = - directory_written > 0 && directory_written < (int)sizeof(directory) && - cbm_mkdtemp(directory) != NULL; - int foreign_written = - directory_created - ? snprintf(foreign_image, sizeof(foreign_image), "%s/foreign-client", - directory) - : -1; + int directory_written = + snprintf(directory, sizeof(directory), "%s/cbm-runtime-mapped-image-XXXXXX", cbm_tmpdir()); + bool directory_created = directory_written > 0 && directory_written < (int)sizeof(directory) && + cbm_mkdtemp(directory) != NULL; + int foreign_written = directory_created ? snprintf(foreign_image, sizeof(foreign_image), + "%s/foreign-client", directory) + : -1; bool active_resolved = runtime_test_self_image_path(active_image); - bool foreign_copied = - active_resolved && foreign_written > 0 && - foreign_written < (int)sizeof(foreign_image) && - runtime_test_copy_executable(active_image, foreign_image); + bool foreign_copied = active_resolved && foreign_written > 0 && + foreign_written < (int)sizeof(foreign_image) && + runtime_test_copy_executable(active_image, foreign_image); /* A distinct signing identifier changes the Mach-O signature bytes while * keeping the copied executable valid under macOS strict validation. */ bool foreign_changed = foreign_copied; - bool foreign_signed = - foreign_changed && runtime_test_mac_ad_hoc_sign(foreign_image); + bool foreign_signed = foreign_changed && runtime_test_mac_ad_hoc_sign(foreign_image); bool foreign_fingerprinted = - foreign_signed && cbm_daemon_build_fingerprint_file( - foreign_image, foreign_fingerprint); + foreign_signed && cbm_daemon_build_fingerprint_file(foreign_image, foreign_fingerprint); bool fingerprint_differs = - foreign_fingerprinted && - strcmp(foreign_fingerprint, identity.build_fingerprint) != 0; + foreign_fingerprinted && strcmp(foreign_fingerprint, identity.build_fingerprint) != 0; runtime_test_fixture_t fixture; memset(&fixture, 0, sizeof(fixture)); - bool fixture_attempted = directory_created && active_resolved && - fingerprint_differs; - bool started = fixture_attempted && runtime_test_fixture_start( - &fixture, "mapped-foreign", &identity); + bool fixture_attempted = directory_created && active_resolved && fingerprint_differs; + bool started = + fixture_attempted && runtime_test_fixture_start(&fixture, "mapped-foreign", &identity); int foreign_exit = -1; bool foreign_ran = - started && runtime_test_run_mapped_hello_image( - foreign_image, active_image, &fixture, &identity, - &foreign_exit); + started && runtime_test_run_mapped_hello_image(foreign_image, active_image, &fixture, + &identity, &foreign_exit); if (fixture_attempted) { runtime_test_fixture_finish(&fixture); } - if (foreign_written > 0 && - foreign_written < (int)sizeof(foreign_image)) { + if (foreign_written > 0 && foreign_written < (int)sizeof(foreign_image)) { (void)cbm_unlink(foreign_image); } if (directory_created) { @@ -3731,63 +3868,55 @@ TEST(daemon_runtime_copied_image_fallback_accepts_identical_and_rejects_changed) cbm_daemon_build_identity_t identity = runtime_test_identity("2.4.0", runtime_test_self_build()); runtime_test_fixture_t identical_fixture; - bool identical_started = runtime_test_fixture_start( - &identical_fixture, "copied-identical", &identity); + bool identical_started = + runtime_test_fixture_start(&identical_fixture, "copied-identical", &identity); char identical_path[RUNTIME_TEST_PATH_CAP] = {0}; - int identical_path_written = - identical_started + int identical_path_written = identical_started #ifdef _WIN32 - ? snprintf(identical_path, sizeof(identical_path), - "%s/client-copy.exe", identical_fixture.parent) + ? snprintf(identical_path, sizeof(identical_path), + "%s/client-copy.exe", identical_fixture.parent) #else - ? snprintf(identical_path, sizeof(identical_path), "%s/client-copy", - identical_fixture.parent) + ? snprintf(identical_path, sizeof(identical_path), + "%s/client-copy", identical_fixture.parent) #endif - : -1; + : -1; bool identical_copied = identical_path_written > 0 && identical_path_written < (int)sizeof(identical_path) && runtime_test_copy_self_image(identical_path); char identical_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; bool identical_bytes = - identical_copied && cbm_daemon_build_fingerprint_file( - identical_path, identical_fingerprint) && + identical_copied && + cbm_daemon_build_fingerprint_file(identical_path, identical_fingerprint) && strcmp(identical_fingerprint, identity.build_fingerprint) == 0; int identical_exit = -1; - bool identical_ran = identical_bytes && runtime_test_run_hello_image( - identical_path, - &identical_fixture, &identity, - &identical_exit); + bool identical_ran = + identical_bytes && runtime_test_run_hello_image(identical_path, &identical_fixture, + &identity, &identical_exit); bool identical_exited = identical_ran && identical_exit == 0 && - cbm_daemon_runtime_service_wait_exited( - identical_fixture.service, RUNTIME_TEST_TIMEOUT_MS); + cbm_daemon_runtime_service_wait_exited(identical_fixture.service, RUNTIME_TEST_TIMEOUT_MS); (void)cbm_unlink(identical_path); runtime_test_fixture_finish(&identical_fixture); #ifdef __linux__ runtime_test_fixture_t changed_fixture; - bool changed_started = runtime_test_fixture_start( - &changed_fixture, "copied-changed", &identity); + bool changed_started = + runtime_test_fixture_start(&changed_fixture, "copied-changed", &identity); char changed_path[RUNTIME_TEST_PATH_CAP] = {0}; - int changed_path_written = - changed_started - ? snprintf(changed_path, sizeof(changed_path), "%s/client-copy", - changed_fixture.parent) - : -1; - bool changed_copied = - changed_path_written > 0 && - changed_path_written < (int)sizeof(changed_path) && - runtime_test_copy_self_image(changed_path) && - runtime_test_append_image_marker(changed_path); + int changed_path_written = changed_started ? snprintf(changed_path, sizeof(changed_path), + "%s/client-copy", changed_fixture.parent) + : -1; + bool changed_copied = changed_path_written > 0 && + changed_path_written < (int)sizeof(changed_path) && + runtime_test_copy_self_image(changed_path) && + runtime_test_append_image_marker(changed_path); char changed_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; - bool changed_bytes = - changed_copied && cbm_daemon_build_fingerprint_file( - changed_path, changed_fingerprint) && - strcmp(changed_fingerprint, identity.build_fingerprint) != 0; + bool changed_bytes = changed_copied && + cbm_daemon_build_fingerprint_file(changed_path, changed_fingerprint) && + strcmp(changed_fingerprint, identity.build_fingerprint) != 0; int changed_exit = -1; - bool changed_ran = changed_bytes && runtime_test_run_hello_image( - changed_path, &changed_fixture, - &identity, &changed_exit); + bool changed_ran = changed_bytes && runtime_test_run_hello_image(changed_path, &changed_fixture, + &identity, &changed_exit); (void)cbm_unlink(changed_path); runtime_test_fixture_finish(&changed_fixture); #endif @@ -3814,38 +3943,28 @@ TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path) { char image_path[RUNTIME_TEST_PATH_CAP] = {0}; char replacement_path[RUNTIME_TEST_PATH_CAP] = {0}; char event_name[128] = {0}; - int directory_written = snprintf(directory, sizeof(directory), - "%s/cbm-runtime-image-XXXXXX", - cbm_tmpdir()); - bool setup = directory_written > 0 && - directory_written < (int)sizeof(directory) && + int directory_written = + snprintf(directory, sizeof(directory), "%s/cbm-runtime-image-XXXXXX", cbm_tmpdir()); + bool setup = directory_written > 0 && directory_written < (int)sizeof(directory) && cbm_mkdtemp(directory) != NULL; int image_written = - setup ? snprintf(image_path, sizeof(image_path), "%s/image.exe", - directory) - : -1; - int replacement_written = - setup ? snprintf(replacement_path, sizeof(replacement_path), - "%s/replacement.exe", directory) - : -1; - int event_written = snprintf( - event_name, sizeof(event_name), "Local\\cbm-runtime-image-%lu-%llu", - (unsigned long)GetCurrentProcessId(), - (unsigned long long)GetTickCount64()); - setup = setup && image_written > 0 && - image_written < (int)sizeof(image_path) && - replacement_written > 0 && - replacement_written < (int)sizeof(replacement_path) && + setup ? snprintf(image_path, sizeof(image_path), "%s/image.exe", directory) : -1; + int replacement_written = setup ? snprintf(replacement_path, sizeof(replacement_path), + "%s/replacement.exe", directory) + : -1; + int event_written = + snprintf(event_name, sizeof(event_name), "Local\\cbm-runtime-image-%lu-%llu", + (unsigned long)GetCurrentProcessId(), (unsigned long long)GetTickCount64()); + setup = setup && image_written > 0 && image_written < (int)sizeof(image_path) && + replacement_written > 0 && replacement_written < (int)sizeof(replacement_path) && event_written > 0 && event_written < (int)sizeof(event_name) && runtime_test_windows_copy_self(image_path); FILE *replacement_file = setup ? cbm_fopen(replacement_path, "wb") : NULL; bool replacement_written_ok = - replacement_file && - fputs("cbm-windows-replacement-image", replacement_file) >= 0; + replacement_file && fputs("cbm-windows-replacement-image", replacement_file) >= 0; if (replacement_file) { - replacement_written_ok = fclose(replacement_file) == 0 && - replacement_written_ok; + replacement_written_ok = fclose(replacement_file) == 0 && replacement_written_ok; } setup = setup && replacement_written_ok; @@ -3856,33 +3975,27 @@ TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path) { cbm_daemon_build_fingerprint_file(replacement_path, replacement) && strcmp(original, replacement) != 0; - HANDLE ready_event = - setup ? CreateEventA(NULL, TRUE, FALSE, event_name) : NULL; + HANDLE ready_event = setup ? CreateEventA(NULL, TRUE, FALSE, event_name) : NULL; bool event_private = ready_event && GetLastError() != ERROR_ALREADY_EXISTS; PROCESS_INFORMATION process; memset(&process, 0, sizeof(process)); - bool spawned = event_private && runtime_test_windows_spawn_image_holder( - image_path, event_name, &process); - bool ready = spawned && - WaitForSingleObject(ready_event, 5000) == WAIT_OBJECT_0; - bool replaced = ready && runtime_test_windows_posix_replace( - replacement_path, image_path); - bool fingerprinted = - ready && cbm_daemon_runtime_process_build_fingerprint( - (uint64_t)process.dwProcessId, observed); - bool replacement_safe = - replaced ? (!fingerprinted || strcmp(observed, original) == 0) - : (fingerprinted && strcmp(observed, original) == 0); + bool spawned = + event_private && runtime_test_windows_spawn_image_holder(image_path, event_name, &process); + bool ready = spawned && WaitForSingleObject(ready_event, 5000) == WAIT_OBJECT_0; + bool replaced = ready && runtime_test_windows_posix_replace(replacement_path, image_path); + bool fingerprinted = ready && cbm_daemon_runtime_process_build_fingerprint( + (uint64_t)process.dwProcessId, observed); + bool replacement_safe = replaced ? (!fingerprinted || strcmp(observed, original) == 0) + : (fingerprinted && strcmp(observed, original) == 0); bool stopped = !spawned; if (spawned) { DWORD exit_code = 0; - bool running = GetExitCodeProcess(process.hProcess, &exit_code) != 0 && - exit_code == STILL_ACTIVE; - bool termination_requested = - !running || TerminateProcess(process.hProcess, 26) != 0; - stopped = termination_requested && - WaitForSingleObject(process.hProcess, 5000) == WAIT_OBJECT_0; + bool running = + GetExitCodeProcess(process.hProcess, &exit_code) != 0 && exit_code == STILL_ACTIVE; + bool termination_requested = !running || TerminateProcess(process.hProcess, 26) != 0; + stopped = + termination_requested && WaitForSingleObject(process.hProcess, 5000) == WAIT_OBJECT_0; (void)CloseHandle(process.hProcess); } if (ready_event) { @@ -3906,25 +4019,17 @@ TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path) { char directory[RUNTIME_TEST_PATH_CAP] = {0}; char image_path[RUNTIME_TEST_PATH_CAP] = {0}; char replacement_path[RUNTIME_TEST_PATH_CAP] = {0}; - int directory_written = snprintf(directory, sizeof(directory), - "%s/cbm-runtime-image-XXXXXX", - cbm_tmpdir()); - bool setup = directory_written > 0 && - directory_written < (int)sizeof(directory) && + int directory_written = + snprintf(directory, sizeof(directory), "%s/cbm-runtime-image-XXXXXX", cbm_tmpdir()); + bool setup = directory_written > 0 && directory_written < (int)sizeof(directory) && cbm_mkdtemp(directory) != NULL; - int image_written = setup - ? snprintf(image_path, sizeof(image_path), "%s/image", - directory) - : -1; - int replacement_written = setup - ? snprintf(replacement_path, - sizeof(replacement_path), - "%s/replacement", directory) - : -1; - setup = setup && image_written > 0 && - image_written < (int)sizeof(image_path) && - replacement_written > 0 && - replacement_written < (int)sizeof(replacement_path) && + int image_written = + setup ? snprintf(image_path, sizeof(image_path), "%s/image", directory) : -1; + int replacement_written = + setup ? snprintf(replacement_path, sizeof(replacement_path), "%s/replacement", directory) + : -1; + setup = setup && image_written > 0 && image_written < (int)sizeof(image_path) && + replacement_written > 0 && replacement_written < (int)sizeof(replacement_path) && runtime_test_copy_executable("/bin/cat", image_path) && runtime_test_copy_executable("/bin/echo", replacement_path); @@ -3933,16 +4038,12 @@ TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path) { char observed[CBM_DAEMON_BUILD_FINGERPRINT_SIZE] = {0}; setup = setup && cbm_daemon_build_fingerprint_file(image_path, original); int release_fd = -1; - pid_t child = setup - ? runtime_test_spawn_blocked_executable(image_path, - &release_fd) - : -1; + pid_t child = setup ? runtime_test_spawn_blocked_executable(image_path, &release_fd) : -1; setup = setup && child > 0 && rename(replacement_path, image_path) == 0 && cbm_daemon_build_fingerprint_file(image_path, replacement) && strcmp(original, replacement) != 0; bool fingerprinted = - setup && cbm_daemon_runtime_process_build_fingerprint((uint64_t)child, - observed); + setup && cbm_daemon_runtime_process_build_fingerprint((uint64_t)child, observed); runtime_test_stop_blocked_executable(child, release_fd); (void)unlink(replacement_path); @@ -3967,6 +4068,9 @@ TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path) { #endif SUITE(daemon_runtime) { + RUN_TEST(daemon_host_refuses_unopenable_runtime_config_database); + RUN_TEST(daemon_host_http_reconcile_rate_limits_and_retries_transient_failures); + RUN_TEST(daemon_host_http_retry_backoff_is_bounded); #ifndef _WIN32 RUN_TEST(daemon_host_failed_listener_reservation_starts_no_background_work); RUN_TEST(daemon_host_persistent_cleanup_release_failure_is_process_bounded); @@ -4005,6 +4109,7 @@ SUITE(daemon_runtime) { RUN_TEST(daemon_runtime_consumes_busy_application_token_before_response); RUN_TEST(daemon_runtime_close_begin_retains_storage_and_rejects_late_exchange); RUN_TEST(daemon_runtime_disconnect_cancels_blocked_application_before_exit); + RUN_TEST(daemon_runtime_disconnect_cancels_blocked_non_index_child_and_preserves_other_session); RUN_TEST(daemon_runtime_noncooperative_callback_does_not_detach_or_unbound_stop); RUN_TEST(daemon_runtime_application_busy_cap_and_malformed_are_isolated); RUN_TEST(daemon_runtime_malformed_and_zero_cancel_close_only_offending_connections); diff --git a/tests/test_daemon_smoke.py b/tests/test_daemon_smoke.py index 20ba4635a..ebde8a778 100644 --- a/tests/test_daemon_smoke.py +++ b/tests/test_daemon_smoke.py @@ -742,11 +742,26 @@ def main(): tmpdir = Path(raw_tmpdir) home = tmpdir / "home" cache = tmpdir / "cache" + cache_alias = tmpdir / "cache-alias" + mismatched_cache = tmpdir / "mismatched-cache" + retargeted_cache = tmpdir / "retargeted-cache" repo = tmpdir / "repo" success_repo = tmpdir / "success-repo" target_dir = home / ".local/bin" - for directory in (home, cache, repo, success_repo, target_dir): + for directory in ( + home, + cache, + mismatched_cache, + retargeted_cache, + repo, + success_repo, + target_dir, + ): directory.mkdir(parents=True, exist_ok=True) + # Exercise startup hardening of an existing user-selected root, not + # merely secure creation of a previously absent directory. + cache.chmod(0o755) + cache_alias.symlink_to(cache, target_is_directory=True) (repo / "hang_me.py").write_text("def smoke():\n return 1\n", encoding="utf-8") (success_repo / "tiny.py").write_text( "def daemon_index_smoke():\n return 1\n", encoding="utf-8" @@ -764,7 +779,7 @@ def main(): env.update( { "HOME": str(home), - "CBM_CACHE_DIR": str(cache), + "CBM_CACHE_DIR": str(cache_alias), "CBM_LOG_LEVEL": "info", "CBM_LOG_FORMAT": "json", "CBM_TEST_HANG_ON": "hang_me", @@ -898,9 +913,12 @@ def main(): "daemon endpoint and lifetime reservation", ) runtime_status = os.stat(runtime_dir) + cache_status = os.stat(cache) socket_status = os.lstat(socket_path) check(runtime_status.st_uid == os.geteuid(), "runtime directory has wrong owner") check(stat.S_IMODE(runtime_status.st_mode) == 0o700, "runtime directory is not 0700") + check(cache_status.st_uid == os.geteuid(), "cache root has wrong owner") + check(stat.S_IMODE(cache_status.st_mode) == 0o700, "cache root is not 0700") check(stat.S_ISSOCK(socket_status.st_mode), "daemon endpoint is not a Unix socket") check(socket_status.st_uid == os.geteuid(), "daemon endpoint has wrong owner") wait_until( @@ -913,6 +931,86 @@ def main(): "two simultaneous clients spawned more than one daemon", ) + # One account daemon owns one canonical cache root. An exact-build + # process configured with another root must fail before it joins + # the daemon generation, while the active sessions remain healthy. + mismatched_env = env.copy() + mismatched_env["CBM_CACHE_DIR"] = str(mismatched_cache) + mismatched_log = mismatched_cache / "logs/daemon-conflicts.ndjson" + active_cache_fingerprint = hashlib.sha256( + str(cache.resolve()).encode("utf-8") + ).hexdigest() + requested_cache_fingerprint = hashlib.sha256( + str(mismatched_cache.resolve()).encode("utf-8") + ).hexdigest() + mismatched_result = subprocess.run( + [str(binary)], + input=json.dumps( + { + "jsonrpc": "2.0", + "id": 203, + "method": "initialize", + "params": initialize_params, + }, + separators=(",", ":"), + ) + + "\n", + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=mismatched_env, + timeout=15, + check=False, + ) + check( + mismatched_result.returncode != 0, + "mismatched CBM_CACHE_DIR unexpectedly joined the account daemon", + ) + check( + "active account daemon uses a different cache directory" + in mismatched_result.stderr + and "CBM_CACHE_DIR" in mismatched_result.stderr, + "cache-root conflict did not emit visible remediation guidance: " + + repr(mismatched_result.stderr), + ) + wait_until( + lambda: any( + item.get("reason") == "cache_root" + and item.get("active_cache") == active_cache_fingerprint + and item.get("requested_cache") + == requested_cache_fingerprint + for item in json_events( + mismatched_log, "daemon.version_conflict" + ) + ), + 10, + "cache-root durable conflict log", + ) + mismatched_log_text = mismatched_log.read_text( + encoding="utf-8", errors="replace" + ) + check( + str(cache) not in mismatched_log_text + and str(mismatched_cache) not in mismatched_log_text, + "cache-root conflict log exposed a raw cache path", + ) + c1.send({"jsonrpc": "2.0", "id": 105, "method": "ping", "params": {}}) + assert_rpc_success( + c1.wait_response(105), "active session after cache-root rejection" + ) + check( + c2.process.poll() is None + and len(json_events(daemon_log, "daemon.start")) == 1, + "cache-root conflict disturbed the active daemon generation", + ) + + # A process must keep using the canonical cache root it admitted, + # even if the original CBM_CACHE_DIR symlink is retargeted later. + # Otherwise a daemon worker can silently move storage while the + # cohort still advertises the old root fingerprint. + cache_alias.unlink() + cache_alias.symlink_to(retargeted_cache, target_is_directory=True) + # A real daemon-backed index must cross the physical-worker # boundary and finish. This is the regression for the former # self-deadlock where the daemon parent held the same project lock @@ -936,6 +1034,16 @@ def main(): c2.wait_response(204, timeout=OPERATION_TIMEOUT), "daemon-backed tiny index", ) + check( + (cache / "smoke-daemon-success.db").is_file(), + "daemon worker did not write through the admitted canonical cache root", + ) + check( + not any(retargeted_cache.iterdir()), + "retargeting the original cache symlink moved daemon storage", + ) + cache_alias.unlink() + cache_alias.symlink_to(cache, target_is_directory=True) wait_until( descendant_pid_path.exists, OPERATION_TIMEOUT, @@ -1269,7 +1377,10 @@ def main(): "session-close worker exited before EOF", ) c1.close_input() - check(c1.wait(timeout=15) == 0, "EOF-cancelled frontend exited nonzero") + check( + c1.wait(timeout=15) == 0, + "EOF-cancelled frontend exited nonzero", + ) wait_until( lambda: process_gone_or_zombie(close_descendant_pid), 20, diff --git a/tests/test_daemon_version.c b/tests/test_daemon_version.c index fbdb96286..89982546a 100644 --- a/tests/test_daemon_version.c +++ b/tests/test_daemon_version.c @@ -31,12 +31,9 @@ enum { VERSION_TEST_FILE_CAP = 8192, }; -static const char BUILD_A[] = - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -static const char BUILD_B[] = - "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; -static const char BUILD_C[] = - "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +static const char BUILD_A[] = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +static const char BUILD_B[] = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +static const char BUILD_C[] = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; static cbm_daemon_build_identity_t version_test_identity(const char *version, const char *build) { cbm_daemon_build_identity_t identity = { @@ -50,8 +47,8 @@ static cbm_daemon_build_identity_t version_test_identity(const char *version, co } static bool version_test_temp_dir(char out[VERSION_TEST_PATH_CAP], const char *tag) { - int written = snprintf(out, VERSION_TEST_PATH_CAP, "%s/cbm-daemon-%s-XXXXXX", cbm_tmpdir(), - tag); + int written = + snprintf(out, VERSION_TEST_PATH_CAP, "%s/cbm-daemon-%s-XXXXXX", cbm_tmpdir(), tag); return written > 0 && written < VERSION_TEST_PATH_CAP && cbm_mkdtemp(out) != NULL; } @@ -158,39 +155,34 @@ static bool version_test_wait_atomic_bool(atomic_bool *value) { return atomic_load_explicit(value, memory_order_acquire); } -static void version_test_rotation_race_hook( - void *opaque, cbm_daemon_conflict_log_test_stage_t stage) { +static void version_test_rotation_race_hook(void *opaque, + cbm_daemon_conflict_log_test_stage_t stage) { version_test_rotation_race_t *race = opaque; if (stage == CBM_DAEMON_CONFLICT_LOG_BEFORE_SERIALIZATION_LOCK) { - (void)atomic_fetch_add_explicit(&race->lock_attempts, 1, - memory_order_acq_rel); + (void)atomic_fetch_add_explicit(&race->lock_attempts, 1, memory_order_acq_rel); return; } if (stage != CBM_DAEMON_CONFLICT_LOG_AFTER_SERIALIZATION_LOCK || - atomic_exchange_explicit(&race->first_lock_acquired, true, - memory_order_acq_rel)) { + atomic_exchange_explicit(&race->first_lock_acquired, true, memory_order_acq_rel)) { return; } struct timespec pause = {.tv_sec = 0, .tv_nsec = 1000000}; - for (int i = 0; i < 2000 && - atomic_load_explicit(&race->lock_attempts, - memory_order_acquire) < 2; - i++) { + for (int i = 0; + i < 2000 && atomic_load_explicit(&race->lock_attempts, memory_order_acquire) < 2; i++) { (void)cbm_nanosleep(&pause, NULL); } } static void *version_test_append_thread(void *opaque) { version_test_append_call_t *call = opaque; - call->result = cbm_daemon_conflict_log_append( - call->path, call->conflict, call->cap_bytes); + call->result = cbm_daemon_conflict_log_append(call->path, call->conflict, call->cap_bytes); return NULL; } #endif -static cbm_daemon_hello_status_t version_test_compare( - const cbm_daemon_build_identity_t *active, const cbm_daemon_build_identity_t *requested, - cbm_daemon_conflict_t *conflict) { +static cbm_daemon_hello_status_t version_test_compare(const cbm_daemon_build_identity_t *active, + const cbm_daemon_build_identity_t *requested, + cbm_daemon_conflict_t *conflict) { memset(conflict, 0, sizeof(*conflict)); return cbm_daemon_hello_compare(active, requested, conflict); } @@ -259,8 +251,7 @@ TEST(daemon_hello_accepts_only_the_exact_active_build_identity) { ASSERT_EQ(version_test_compare(&active, &exact, &conflict), CBM_DAEMON_HELLO_COMPATIBLE); cbm_daemon_build_identity_t rebuilt = version_test_identity("2.4.0", BUILD_B); - ASSERT_EQ(version_test_compare(&active, &rebuilt, &conflict), - CBM_DAEMON_HELLO_BUILD_CONFLICT); + ASSERT_EQ(version_test_compare(&active, &rebuilt, &conflict), CBM_DAEMON_HELLO_BUILD_CONFLICT); ASSERT_TRUE( version_test_conflict_identity_matches(&conflict, "2.4.0", BUILD_A, "2.4.0", BUILD_B)); PASS(); @@ -432,15 +423,12 @@ TEST(daemon_conflict_log_rotation_serializes_on_stable_sidecar) { second_call.cap_bytes = ROTATION_CAP; cbm_daemon_conflict_log_set_test_hook(version_test_rotation_race_hook, &race); - first_started = cbm_thread_create(&first_thread, 0, - version_test_append_thread, - &first_call) == 0; - bool first_locked = first_started && - version_test_wait_atomic_bool(&race.first_lock_acquired); + first_started = + cbm_thread_create(&first_thread, 0, version_test_append_thread, &first_call) == 0; + bool first_locked = first_started && version_test_wait_atomic_bool(&race.first_lock_acquired); if (first_locked) { - second_started = cbm_thread_create(&second_thread, 0, - version_test_append_thread, - &second_call) == 0; + second_started = + cbm_thread_create(&second_thread, 0, version_test_append_thread, &second_call) == 0; } if (first_started) { (void)cbm_thread_join(&first_thread); @@ -555,8 +543,7 @@ static void *version_test_windows_append_thread(void *opaque) { while (!atomic_load_explicit(call->start, memory_order_acquire)) { (void)cbm_nanosleep(&pause, NULL); } - call->result = cbm_daemon_conflict_log_append( - call->path, call->conflict, 64U * 1024U); + call->result = cbm_daemon_conflict_log_append(call->path, call->conflict, 64U * 1024U); return NULL; } @@ -574,11 +561,11 @@ TEST(daemon_conflict_log_windows_concurrent_appends_are_not_dropped) { atomic_bool start = false; size_t started = 0; - bool setup_ok = version_test_temp_dir(dir, "log-windows-concurrent") && - version_test_child_path(path, dir, "daemon.log") && - version_test_child_path(rotated, dir, "daemon.log.1") && - version_test_compare(&active, &requested, &conflict) == - CBM_DAEMON_HELLO_VERSION_CONFLICT; + bool setup_ok = + version_test_temp_dir(dir, "log-windows-concurrent") && + version_test_child_path(path, dir, "daemon.log") && + version_test_child_path(rotated, dir, "daemon.log.1") && + version_test_compare(&active, &requested, &conflict) == CBM_DAEMON_HELLO_VERSION_CONFLICT; if (!setup_ok) { version_test_cleanup(dir, path, rotated, NULL); FAIL("could not create Windows concurrent-log fixture"); @@ -589,8 +576,7 @@ TEST(daemon_conflict_log_windows_concurrent_appends_are_not_dropped) { calls[started].path = path; calls[started].conflict = &conflict; calls[started].start = &start; - if (cbm_thread_create(&threads[started], 0, - version_test_windows_append_thread, + if (cbm_thread_create(&threads[started], 0, version_test_windows_append_thread, &calls[started]) != 0) { break; } diff --git a/tests/test_discover.c b/tests/test_discover.c index 1663b7f38..35c28f150 100644 --- a/tests/test_discover.c +++ b/tests/test_discover.c @@ -6,6 +6,7 @@ #include "test_framework.h" #include "test_helpers.h" #include "discover/discover.h" +#include "foundation/platform.h" typedef struct { char *home; @@ -327,6 +328,45 @@ TEST(discover_simple) { PASS(); } +TEST(discover_bounded_count_is_allocation_free_and_limit_exact) { + char *base = th_mktempdir("cbm_disc_bounded"); + ASSERT(base != NULL); + th_write_file(TH_PATH(base, "src/first.c"), "int first;\n"); + th_write_file(TH_PATH(base, "src/second.py"), "second = 2\n"); + th_write_file(TH_PATH(base, "src/ignored.png"), "not source\n"); + + cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL}; + int limited_count = -1; + cbm_discover_status_t limited = cbm_discover_count_bounded( + base, &opts, 1, cbm_now_ms() + 2000, &limited_count); + int exact_count = -1; + cbm_discover_status_t exact = cbm_discover_count_bounded( + base, &opts, 2, cbm_now_ms() + 2000, &exact_count); + + th_cleanup(base); + ASSERT_EQ(limited, CBM_DISCOVER_LIMIT_EXCEEDED); + ASSERT_EQ(limited_count, 1); + ASSERT_EQ(exact, CBM_DISCOVER_OK); + ASSERT_EQ(exact_count, 2); + PASS(); +} + +TEST(discover_bounded_count_fails_closed_after_deadline) { + char *base = th_mktempdir("cbm_disc_deadline"); + ASSERT(base != NULL); + th_write_file(TH_PATH(base, "source.c"), "int source;\n"); + + cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL}; + int count = 99; + cbm_discover_status_t status = + cbm_discover_count_bounded(base, &opts, 100, 1, &count); + + th_cleanup(base); + ASSERT_EQ(status, CBM_DISCOVER_ERROR); + ASSERT_EQ(count, -1); + PASS(); +} + TEST(discover_skips_git_dir) { char *base = th_mktempdir("cbm_disc_git"); ASSERT(base != NULL); @@ -1241,6 +1281,55 @@ TEST(discover_nested_gitignore_stacks_with_root) { PASS(); } +/* A repository may have one nested .gitignore per package. Ownership is + * cumulative across the walk, not bounded by the traversal depth, so more than + * 64 sibling matchers must remain valid for both full discovery and the + * allocation-free bounded count used by daemon auto-index admission. */ +TEST(discover_many_nested_gitignores_do_not_exhaust_matcher_ownership) { + enum { PACKAGE_COUNT = 65 }; + char *base = th_mktempdir("cbm_disc_many_ngi"); + ASSERT(base != NULL); + + bool fixture_ready = true; + for (int i = 0; i < PACKAGE_COUNT; i++) { + char ignore_path[1024]; + char source_path[1024]; + int ignore_written = snprintf(ignore_path, sizeof(ignore_path), + "%s/package-%02d/.gitignore", base, i); + int source_written = snprintf(source_path, sizeof(source_path), + "%s/package-%02d/source.go", base, i); + fixture_ready = + fixture_ready && ignore_written > 0 && + (size_t)ignore_written < sizeof(ignore_path) && + source_written > 0 && + (size_t)source_written < sizeof(source_path) && + th_write_file(ignore_path, "ignored.tmp\n") == 0 && + th_write_file(source_path, "package fixture\n") == 0; + } + + cbm_discover_opts_t opts = {0}; + cbm_file_info_t *files = NULL; + int count = 0; + int discover_rc = + fixture_ready ? cbm_discover(base, &opts, &files, &count) : -1; + int bounded_count = -1; + cbm_discover_status_t bounded_status = + fixture_ready + ? cbm_discover_count_bounded(base, &opts, PACKAGE_COUNT + 1, 0, + &bounded_count) + : CBM_DISCOVER_ERROR; + + cbm_discover_free(files, count); + th_cleanup(base); + + ASSERT_TRUE(fixture_ready); + ASSERT_EQ(discover_rc, 0); + ASSERT_EQ(count, PACKAGE_COUNT); + ASSERT_EQ(bounded_status, CBM_DISCOVER_OK); + ASSERT_EQ(bounded_count, PACKAGE_COUNT); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ SUITE(discover) { @@ -1314,6 +1403,8 @@ SUITE(discover) { /* Integration tests (cross-platform) */ RUN_TEST(discover_simple); + RUN_TEST(discover_bounded_count_is_allocation_free_and_limit_exact); + RUN_TEST(discover_bounded_count_fails_closed_after_deadline); RUN_TEST(discover_skips_git_dir); RUN_TEST(discover_with_gitignore); RUN_TEST(discover_with_global_xdg_ignore); @@ -1356,4 +1447,5 @@ SUITE(discover) { /* Nested .gitignore tests (issue #178) */ RUN_TEST(discover_nested_gitignore); RUN_TEST(discover_nested_gitignore_stacks_with_root); + RUN_TEST(discover_many_nested_gitignores_do_not_exhaust_matcher_ownership); } diff --git a/tests/test_httpd.c b/tests/test_httpd.c index 5067231ce..8544273af 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -438,8 +438,7 @@ typedef struct { char project_name[256]; } th_ui_index_executor_t; -static int th_ui_index_executor(void *opaque, const char *root_path, - const char *project_name) { +static int th_ui_index_executor(void *opaque, const char *root_path, const char *project_name) { th_ui_index_executor_t *executor = opaque; snprintf(executor->root_path, sizeof(executor->root_path), "%s", root_path); snprintf(executor->project_name, sizeof(executor->project_name), "%s", @@ -476,16 +475,14 @@ static void th_ui_mutation_guard_init(th_ui_mutation_guard_t *guard, bool allow) static bool th_ui_mutation_begin(void *opaque, const char *project) { th_ui_mutation_guard_t *guard = opaque; - snprintf(guard->begin_project, sizeof(guard->begin_project), "%s", - project ? project : ""); + snprintf(guard->begin_project, sizeof(guard->begin_project), "%s", project ? project : ""); atomic_fetch_add(&guard->begin_calls, 1); return guard->allow; } static void th_ui_mutation_end(void *opaque, const char *project) { th_ui_mutation_guard_t *guard = opaque; - snprintf(guard->end_project, sizeof(guard->end_project), "%s", - project ? project : ""); + snprintf(guard->end_project, sizeof(guard->end_project), "%s", project ? project : ""); atomic_fetch_add(&guard->end_calls, 1); } @@ -496,8 +493,8 @@ static int th_server_start_with_mutation_guard(th_server_t *ts, cbm_watcher_t *w return -1; if (watcher) cbm_http_server_set_watcher(ts->srv, watcher); - cbm_http_server_set_project_mutation_guard(ts->srv, th_ui_mutation_begin, - th_ui_mutation_end, guard); + cbm_http_server_set_project_mutation_guard(ts->srv, th_ui_mutation_begin, th_ui_mutation_end, + guard); if (cbm_thread_create(&ts->tid, 0, th_server_thread, ts->srv) != 0) { cbm_http_server_free(ts->srv); return -1; @@ -584,9 +581,8 @@ static int ui_delete_request(th_server_t *ts, const char *target, char *resp, si static int ui_adr_post_request(th_server_t *ts, const char *project, const char *content, char *resp, size_t respsz) { char body[2048]; - int body_len = snprintf(body, sizeof(body), - "{\"project\":\"%s\",\"content\":\"%s\"}", project, - content); + int body_len = + snprintf(body, sizeof(body), "{\"project\":\"%s\",\"content\":\"%s\"}", project, content); if (body_len < 0 || (size_t)body_len >= sizeof(body)) return 0; @@ -601,18 +597,15 @@ static int ui_adr_post_request(th_server_t *ts, const char *project, const char return th_http(cbm_http_server_port(ts->srv), req, resp, respsz); } -static int ui_adr_get_request(th_server_t *ts, const char *project, char *resp, - size_t respsz) { +static int ui_adr_get_request(th_server_t *ts, const char *project, char *resp, size_t respsz) { char req[512]; - int req_len = snprintf(req, sizeof(req), - "GET /api/adr?project=%s HTTP/1.1\r\n\r\n", project); + int req_len = snprintf(req, sizeof(req), "GET /api/adr?project=%s HTTP/1.1\r\n\r\n", project); if (req_len < 0 || (size_t)req_len >= sizeof(req)) return 0; return th_http(cbm_http_server_port(ts->srv), req, resp, respsz); } -static int ui_adr_seed(const ui_delete_fixture_t *fx, const char *project, - const char *content) { +static int ui_adr_seed(const ui_delete_fixture_t *fx, const char *project, const char *content) { char db_path[1024]; ui_delete_db_path(fx, project, db_path, sizeof(db_path)); cbm_store_t *store = cbm_store_open_path(db_path); @@ -696,16 +689,15 @@ TEST(ui_server_routes_indexing_through_joinable_daemon_executor) { ASSERT_EQ(cbm_thread_create(&ts.tid, 0, th_server_thread, ts.srv), 0); char body[1024]; - snprintf(body, sizeof(body), - "{\"root_path\":\"%s\",\"project_name\":\"ui-project\"}", root); + snprintf(body, sizeof(body), "{\"root_path\":\"%s\",\"project_name\":\"ui-project\"}", root); char request[1400]; snprintf(request, sizeof(request), "POST /api/index HTTP/1.1\r\nContent-Type: application/json\r\n" "Content-Length: %zu\r\n\r\n%s", strlen(body), body); char response[4096]; - int response_length = th_http(cbm_http_server_port(ts.srv), request, - response, sizeof(response)); + int response_length = + th_http(cbm_http_server_port(ts.srv), request, response, sizeof(response)); bool called = th_wait_atomic_int(&executor.calls, 1, 2000); th_server_stop(&ts); @@ -857,8 +849,7 @@ TEST(ui_server_adr_mutation_guard_busy_preserves_existing_adr) { ASSERT_EQ(th_server_start_with_mutation_guard(&ts, NULL, &guard), 0); char resp[4096]; - int n = ui_adr_post_request(&ts, project, "replacement architecture", resp, - sizeof(resp)); + int n = ui_adr_post_request(&ts, project, "replacement architecture", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 423); ASSERT_NOT_NULL(strstr(resp, "project is busy; retry after indexing")); @@ -924,8 +915,7 @@ TEST(ui_server_delete_mutation_guard_busy_preserves_project) { ASSERT_EQ(th_server_start_with_mutation_guard(&ts, fx.watcher, &guard), 0); char resp[4096]; - int n = ui_delete_request(&ts, "/api/project?name=ui-guard-delete-busy", resp, - sizeof(resp)); + int n = ui_delete_request(&ts, "/api/project?name=ui-guard-delete-busy", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 423); ASSERT_NOT_NULL(strstr(resp, "project is busy; retry after indexing")); @@ -961,8 +951,7 @@ TEST(ui_server_delete_mutation_guard_balances_success) { ASSERT_EQ(th_server_start_with_mutation_guard(&ts, fx.watcher, &guard), 0); char resp[4096]; - int n = ui_delete_request(&ts, "/api/project?name=ui-guard-delete-success", resp, - sizeof(resp)); + int n = ui_delete_request(&ts, "/api/project?name=ui-guard-delete-success", resp, sizeof(resp)); ASSERT_GT(n, 0); ASSERT_EQ(th_status(resp), 200); ASSERT_NOT_NULL(strstr(resp, "{\"deleted\":true}")); diff --git a/tests/test_incremental.c b/tests/test_incremental.c index 00c2ea53a..efe31c73e 100644 --- a/tests/test_incremental.c +++ b/tests/test_incremental.c @@ -18,6 +18,7 @@ #include #include #include +#include #include #include @@ -152,7 +153,7 @@ static int count_in_response(const char *resp, const char *key) { /* ── Direct store queries (more reliable than MCP for tests) ────── */ static cbm_store_t *open_store(void) { - return cbm_store_open_path(g_dbpath); + return cbm_store_open_path_existing(g_dbpath); } static int get_node_count(void) { @@ -233,14 +234,13 @@ static int incremental_setup(void) { if (!g_project) return -1; - const char *home = getenv("HOME"); - if (!home) - home = "/tmp"; - snprintf(g_dbpath, sizeof(g_dbpath), "%s/.cache/codebase-memory-mcp/%s.db", home, g_project); - - char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); - cbm_mkdir(cache_dir); + const char *cache_dir = cbm_resolve_cache_dir(); + int dbpath_length = + cache_dir ? snprintf(g_dbpath, sizeof(g_dbpath), "%s/%s.db", cache_dir, g_project) : -1; + if (dbpath_length <= 0 || (size_t)dbpath_length >= sizeof(g_dbpath) || + !cbm_mkdir_p(cache_dir, 0700)) { + return -1; + } unlink(g_dbpath); diff --git a/tests/test_index_supervisor.c b/tests/test_index_supervisor.c index 7614dca8a..e0adbd05f 100644 --- a/tests/test_index_supervisor.c +++ b/tests/test_index_supervisor.c @@ -102,13 +102,11 @@ static bool index_supervisor_test_append_terminal_backlog(const char *path) { } bool written = fputs("level=info msg=pipeline.discover files=7\n", file) >= 0 && - fputs("{\"level\":\"info\",\"event\":\"pass.start\",\"pass\":\"structure\"}\n", - file) >= 0; + fputs("{\"level\":\"info\",\"event\":\"pass.start\",\"pass\":\"structure\"}\n", file) >= 0; for (int i = 0; written && i < INDEX_SUPERVISOR_TEST_BACKLOG_LINES; i++) { written = fprintf(file, "terminal-backlog-%04d\n", i) > 0; } - written = written && fputs("terminal-backlog-sentinel\n", file) >= 0 && - fflush(file) == 0; + written = written && fputs("terminal-backlog-sentinel\n", file) >= 0 && fflush(file) == 0; return fclose(file) == 0 && written; } @@ -172,8 +170,7 @@ static void index_supervisor_test_capture_log(const char *line, void *context) { if (strcmp(line, "level=info msg=pipeline.discover files=7") == 0) { capture->saw_structured_text = true; } - if (strcmp(line, - "{\"level\":\"info\",\"event\":\"pass.start\",\"pass\":\"structure\"}") == 0) { + if (strcmp(line, "{\"level\":\"info\",\"event\":\"pass.start\",\"pass\":\"structure\"}") == 0) { capture->saw_structured_json = true; } if (strncmp(line, "terminal-backlog-", strlen("terminal-backlog-")) == 0) { @@ -226,29 +223,69 @@ TEST(index_supervisor_worker_argv_requires_exact_build_bound_grammar) { ASSERT_STR_EQ(invocation.marker_file, valid[13]); ASSERT_STR_EQ(invocation.quarantine_file, valid[15]); - char *missing_build[] = {"test-runner", "cli", "--index-worker", "index_repository", - "{}", "--response-out", "/tmp/r", NULL}; - char *wrong_build[] = {"test-runner", "cli", "--index-worker", - CBM_INDEX_WORKER_BUILD_ARG, mismatched, "index_repository", - "{}", "--response-out", "/tmp/r", NULL}; - char *reordered[] = {"test-runner", "cli", "--index-worker", - CBM_INDEX_WORKER_BUILD_ARG, (char *)captured, "index_repository", - "{}", "--response-out", "/tmp/r", - CBM_INDEX_WORKER_QUARANTINE_ARG, "/tmp/q", - CBM_INDEX_WORKER_MARKER_ARG, "/tmp/m", NULL}; - char *trailing[] = {"test-runner", "cli", "--index-worker", - CBM_INDEX_WORKER_BUILD_ARG, (char *)captured, "index_repository", - "{}", "--response-out", "/tmp/r", "unexpected", NULL}; - char *zero_budget[] = {"test-runner", "cli", "--index-worker", - CBM_INDEX_WORKER_BUILD_ARG, (char *)captured, "index_repository", - "{}", "--response-out", "/tmp/r", - CBM_INDEX_WORKER_MEMORY_BUDGET_ARG, "0", NULL}; - char *overflow_budget[] = { - "test-runner", "cli", "--index-worker", CBM_INDEX_WORKER_BUILD_ARG, - (char *)captured, "index_repository", "{}", "--response-out", "/tmp/r", - CBM_INDEX_WORKER_MEMORY_BUDGET_ARG, "184467440737095516160", NULL}; - char *user_value[] = {"test-runner", "cli", "search_code", "--query", - "--index-worker", NULL}; + char *missing_build[] = {"test-runner", "cli", "--index-worker", + "index_repository", "{}", "--response-out", + "/tmp/r", NULL}; + char *wrong_build[] = {"test-runner", + "cli", + "--index-worker", + CBM_INDEX_WORKER_BUILD_ARG, + mismatched, + "index_repository", + "{}", + "--response-out", + "/tmp/r", + NULL}; + char *reordered[] = {"test-runner", + "cli", + "--index-worker", + CBM_INDEX_WORKER_BUILD_ARG, + (char *)captured, + "index_repository", + "{}", + "--response-out", + "/tmp/r", + CBM_INDEX_WORKER_QUARANTINE_ARG, + "/tmp/q", + CBM_INDEX_WORKER_MARKER_ARG, + "/tmp/m", + NULL}; + char *trailing[] = {"test-runner", + "cli", + "--index-worker", + CBM_INDEX_WORKER_BUILD_ARG, + (char *)captured, + "index_repository", + "{}", + "--response-out", + "/tmp/r", + "unexpected", + NULL}; + char *zero_budget[] = {"test-runner", + "cli", + "--index-worker", + CBM_INDEX_WORKER_BUILD_ARG, + (char *)captured, + "index_repository", + "{}", + "--response-out", + "/tmp/r", + CBM_INDEX_WORKER_MEMORY_BUDGET_ARG, + "0", + NULL}; + char *overflow_budget[] = {"test-runner", + "cli", + "--index-worker", + CBM_INDEX_WORKER_BUILD_ARG, + (char *)captured, + "index_repository", + "{}", + "--response-out", + "/tmp/r", + CBM_INDEX_WORKER_MEMORY_BUDGET_ARG, + "184467440737095516160", + NULL}; + char *user_value[] = {"test-runner", "cli", "search_code", "--query", "--index-worker", NULL}; ASSERT_EQ(cbm_index_worker_parse_process_argv(7, missing_build, &invocation), CBM_INDEX_WORKER_ARGV_INVALID); ASSERT_EQ(cbm_daemon_process_role(7, missing_build), CBM_DAEMON_PROCESS_INVALID); @@ -323,12 +360,12 @@ TEST(index_supervisor_async_jobs_are_isolated_cancellable_and_terminal_cached) { index_supervisor_log_capture_t first_capture = {0}; index_supervisor_log_capture_t second_capture = {0}; uint64_t start_before = cbm_now_ms(); - int first_rc = cbm_index_worker_start_with_log( - args, 4096, true, marker_a, quarantine_a, index_supervisor_test_capture_log, - &first_capture, &first); - int second_rc = cbm_index_worker_start_with_log( - args, 8192, true, marker_b, quarantine_b, index_supervisor_test_capture_log, - &second_capture, &second); + int first_rc = + cbm_index_worker_start_with_log(args, 4096, true, marker_a, quarantine_a, + index_supervisor_test_capture_log, &first_capture, &first); + int second_rc = cbm_index_worker_start_with_log(args, 8192, true, marker_b, quarantine_b, + index_supervisor_test_capture_log, + &second_capture, &second); uint64_t start_elapsed = cbm_now_ms() - start_before; char response_a[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0}; @@ -366,10 +403,9 @@ TEST(index_supervisor_async_jobs_are_isolated_cancellable_and_terminal_cached) { strcmp(getenv("CBM_INDEX_MARKER_FILE"), "parent-marker") == 0 && strcmp(getenv("CBM_INDEX_QUARANTINE_FILE"), "parent-quarantine") == 0; - bool worker_logs_ready = index_supervisor_test_wait_file_text( - log_a, "async worker hang-tree probe", 3000) && - index_supervisor_test_wait_file_text( - log_b, "async worker hang-tree probe", 3000); + bool worker_logs_ready = + index_supervisor_test_wait_file_text(log_a, "async worker hang-tree probe", 3000) && + index_supervisor_test_wait_file_text(log_b, "async worker hang-tree probe", 3000); bool callback_lines_injected = worker_logs_ready && index_supervisor_test_append_log(log_a, "request-a-only\n") && index_supervisor_test_append_log(log_b, "request-b-only\n"); @@ -461,8 +497,7 @@ TEST(index_supervisor_async_jobs_are_isolated_cancellable_and_terminal_cached) { TEST(index_supervisor_sync_wrapper_forwards_cancel_and_drains_tree) { char cache[INDEX_SUPERVISOR_TEST_PATH_CAP]; - (void)snprintf(cache, sizeof(cache), - "%s/cbm-index-sync-cancel-XXXXXX", cbm_tmpdir()); + (void)snprintf(cache, sizeof(cache), "%s/cbm-index-sync-cancel-XXXXXX", cbm_tmpdir()); ASSERT_NOT_NULL(cbm_mkdtemp(cache)); const char *old_cache = getenv("CBM_CACHE_DIR"); char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; @@ -471,12 +506,11 @@ TEST(index_supervisor_sync_wrapper_forwards_cancel_and_drains_tree) { atomic_int cancel_requested; atomic_init(&cancel_requested, 1); cbm_index_worker_result_t result = {0}; - int run_status = cbm_index_spawn_worker_with_log_cancel( - "{\"__cbm_test_worker\":\"hang-tree\"}", false, NULL, NULL, - NULL, NULL, &cancel_requested, &result); - bool contained = run_status == 0 && result.cancellation_requested && - result.tree_quiesced && !result.supervision_failed && - result.response == NULL; + int run_status = + cbm_index_spawn_worker_with_log_cancel("{\"__cbm_test_worker\":\"hang-tree\"}", false, NULL, + NULL, NULL, NULL, &cancel_requested, &result); + bool contained = run_status == 0 && result.cancellation_requested && result.tree_quiesced && + !result.supervision_failed && result.response == NULL; cbm_index_worker_result_free(&result); index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache); (void)th_rmtree(cache); @@ -586,39 +620,36 @@ TEST(index_supervisor_drains_terminal_backlog_into_request_progress_callback) { index_supervisor_log_capture_t capture = {.render_progress = true}; cbm_progress_sink_init(progress); cbm_index_worker_handle_t *handle = NULL; - int start_rc = cbm_index_worker_start_with_log( - "{\"__cbm_test_worker\":\"clean\"}", 0, false, NULL, NULL, - index_supervisor_test_capture_log, &capture, &handle); + int start_rc = + cbm_index_worker_start_with_log("{\"__cbm_test_worker\":\"clean\"}", 0, false, NULL, NULL, + index_supervisor_test_capture_log, &capture, &handle); char log_path[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0}; if (handle) { (void)snprintf(log_path, sizeof(log_path), "%s", cbm_index_worker_log_path(handle)); } bool worker_logged = log_path[0] && index_supervisor_test_wait_file_text( - log_path, "async worker clean probe", 3000); + log_path, "async worker clean probe", 3000); if (worker_logged) { /* Seeing the flushed probe places the child immediately before _Exit. * Give it time to become waitable without polling: the regression is a * backlog already present when the first terminal poll occurs. */ cbm_usleep(250000); } - bool backlog_written = worker_logged && - index_supervisor_test_append_terminal_backlog(log_path); + bool backlog_written = worker_logged && index_supervisor_test_append_terminal_backlog(log_path); const cbm_index_worker_result_t *result = NULL; bool terminal = handle && index_supervisor_test_poll_terminal( handle, INDEX_SUPERVISOR_TEST_TERMINAL_MS, &result); cbm_proc_outcome_t outcome = result ? result->outcome : CBM_PROC_SPAWN_FAILED; int callbacks_at_terminal = capture.delivered; const cbm_index_worker_result_t *cached = NULL; - bool terminal_cached = terminal && - cbm_index_worker_poll(handle, &cached) == - CBM_INDEX_WORKER_POLL_TERMINAL && - cached == result && capture.delivered == callbacks_at_terminal; + bool terminal_cached = + terminal && cbm_index_worker_poll(handle, &cached) == CBM_INDEX_WORKER_POLL_TERMINAL && + cached == result && capture.delivered == callbacks_at_terminal; cbm_progress_sink_fini(); bool progress_rewound = fseek(progress, 0, SEEK_SET) == 0; char rendered[1024] = {0}; - size_t rendered_size = progress_rewound - ? fread(rendered, 1, sizeof(rendered) - 1, progress) - : 0; + size_t rendered_size = + progress_rewound ? fread(rendered, 1, sizeof(rendered) - 1, progress) : 0; (void)fclose(progress); if (terminal) { cbm_index_worker_destroy(handle); @@ -656,8 +687,8 @@ TEST(index_supervisor_oversized_response_is_contained_and_log_is_retained) { (void)cbm_setenv("CBM_CACHE_DIR", cache, 1); cbm_index_worker_handle_t *handle = NULL; - int start_rc = cbm_index_worker_start("{\"__cbm_test_worker\":\"oversize\"}", 0, false, - NULL, NULL, &handle); + int start_rc = cbm_index_worker_start("{\"__cbm_test_worker\":\"oversize\"}", 0, false, NULL, + NULL, &handle); char log_path[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0}; char response_path[INDEX_SUPERVISOR_TEST_PATH_CAP] = {0}; if (handle) { diff --git a/tests/test_integration.c b/tests/test_integration.c index b81ac378b..92a356ad6 100644 --- a/tests/test_integration.c +++ b/tests/test_integration.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include @@ -106,15 +107,13 @@ static int integration_setup(void) { return -1; /* Build db path for direct store queries (pipeline writes here) */ - const char *home = getenv("HOME"); - if (!home) - home = "/tmp"; - snprintf(g_dbpath, sizeof(g_dbpath), "%s/.cache/codebase-memory-mcp/%s.db", home, g_project); - - /* Ensure cache dir exists */ - char cache_dir[512]; - snprintf(cache_dir, sizeof(cache_dir), "%s/.cache/codebase-memory-mcp", home); - cbm_mkdir(cache_dir); + const char *cache_dir = cbm_resolve_cache_dir(); + int dbpath_length = + cache_dir ? snprintf(g_dbpath, sizeof(g_dbpath), "%s/%s.db", cache_dir, g_project) : -1; + if (dbpath_length <= 0 || (size_t)dbpath_length >= sizeof(g_dbpath) || + !cbm_mkdir_p(cache_dir, 0700)) { + return -1; + } /* Remove stale db from previous test runs */ unlink(g_dbpath); @@ -175,7 +174,7 @@ static char *call_tool(const char *tool, const char *args) { TEST(integ_index_has_nodes) { /* Open the indexed db directly and check node counts */ - cbm_store_t *store = cbm_store_open_path(g_dbpath); + cbm_store_t *store = cbm_store_open_path_existing(g_dbpath); ASSERT_NOT_NULL(store); int nodes = cbm_store_count_nodes(store, g_project); @@ -188,7 +187,7 @@ TEST(integ_index_has_nodes) { } TEST(integ_index_has_edges) { - cbm_store_t *store = cbm_store_open_path(g_dbpath); + cbm_store_t *store = cbm_store_open_path_existing(g_dbpath); ASSERT_NOT_NULL(store); int edges = cbm_store_count_edges(store, g_project); @@ -200,7 +199,7 @@ TEST(integ_index_has_edges) { } TEST(integ_index_has_functions) { - cbm_store_t *store = cbm_store_open_path(g_dbpath); + cbm_store_t *store = cbm_store_open_path_existing(g_dbpath); ASSERT_NOT_NULL(store); cbm_node_t *funcs = NULL; @@ -230,7 +229,7 @@ TEST(integ_index_has_functions) { } TEST(integ_index_has_files) { - cbm_store_t *store = cbm_store_open_path(g_dbpath); + cbm_store_t *store = cbm_store_open_path_existing(g_dbpath); ASSERT_NOT_NULL(store); cbm_node_t *files = NULL; @@ -258,7 +257,7 @@ TEST(integ_index_has_files) { } TEST(integ_index_has_calls) { - cbm_store_t *store = cbm_store_open_path(g_dbpath); + cbm_store_t *store = cbm_store_open_path_existing(g_dbpath); ASSERT_NOT_NULL(store); int call_count = cbm_store_count_edges_by_type(store, g_project, "CALLS"); @@ -392,7 +391,7 @@ TEST(integ_mcp_trace_path) { * committed — exactly what a new MCP session sees after a cross-repo pass writes * CROSS_* edges (g_srv's cached connection predates this write). */ TEST(integ_mcp_trace_path_cross_service) { - cbm_store_t *store = cbm_store_open_path(g_dbpath); + cbm_store_t *store = cbm_store_open_path_existing(g_dbpath); ASSERT_NOT_NULL(store); cbm_node_t *src = NULL; @@ -518,7 +517,7 @@ TEST(integ_pipeline_cancel) { * ══════════════════════════════════════════════════════════════════ */ TEST(integ_store_search_by_degree) { - cbm_store_t *store = cbm_store_open_path(g_dbpath); + cbm_store_t *store = cbm_store_open_path_existing(g_dbpath); ASSERT_NOT_NULL(store); /* Find functions with at least 1 outbound call */ @@ -541,7 +540,7 @@ TEST(integ_store_search_by_degree) { } TEST(integ_store_find_by_file) { - cbm_store_t *store = cbm_store_open_path(g_dbpath); + cbm_store_t *store = cbm_store_open_path_existing(g_dbpath); ASSERT_NOT_NULL(store); cbm_node_t *nodes = NULL; @@ -557,7 +556,7 @@ TEST(integ_store_find_by_file) { } TEST(integ_store_bfs_traversal) { - cbm_store_t *store = cbm_store_open_path(g_dbpath); + cbm_store_t *store = cbm_store_open_path_existing(g_dbpath); ASSERT_NOT_NULL(store); /* Find a function node to start BFS from */ diff --git a/tests/test_lock_registry.c b/tests/test_lock_registry.c index 298f12cfd..4c9c29752 100644 --- a/tests/test_lock_registry.c +++ b/tests/test_lock_registry.c @@ -122,10 +122,9 @@ typedef struct { static void *lock_registry_waiter_run(void *opaque) { lock_registry_waiter_t *waiter = opaque; - waiter->status = - cbm_lock_registry_acquire(waiter->registry, waiter->resource_key, waiter->mode, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, - &waiter->cancel_token, &waiter->lease); + waiter->status = cbm_lock_registry_acquire(waiter->registry, waiter->resource_key, waiter->mode, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, + &waiter->cancel_token, &waiter->lease); atomic_store_explicit(&waiter->finished, true, memory_order_release); return NULL; } @@ -152,10 +151,11 @@ TEST(lock_registry_cancelled_wait_rolls_back_and_does_not_barge) { bool started = lock_registry_fixture_start(&fixture); cbm_lock_lease_t *holder = NULL; cbm_private_file_lock_status_t holder_status = - started ? cbm_lock_registry_acquire( - fixture.registry, "cancelled-waiter", CBM_PRIVATE_FILE_LOCK_SH, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &holder) - : CBM_PRIVATE_FILE_LOCK_IO; + started + ? cbm_lock_registry_acquire(fixture.registry, "cancelled-waiter", + CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &holder) + : CBM_PRIVATE_FILE_LOCK_IO; lock_registry_waiter_t waiter = {.registry = fixture.registry, .resource_key = "cancelled-waiter", @@ -209,10 +209,11 @@ TEST(lock_registry_cancelled_wait_rolls_back_and_does_not_barge) { holder ? cbm_lock_lease_release(&holder) : CBM_PRIVATE_FILE_LOCK_IO; cbm_lock_lease_t *after = NULL; cbm_private_file_lock_status_t after_status = - started ? cbm_lock_registry_acquire( - fixture.registry, "cancelled-waiter", CBM_PRIVATE_FILE_LOCK_SH, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) - : CBM_PRIVATE_FILE_LOCK_IO; + started + ? cbm_lock_registry_acquire(fixture.registry, "cancelled-waiter", + CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) + : CBM_PRIVATE_FILE_LOCK_IO; if (after) { (void)cbm_lock_lease_release(&after); } @@ -249,10 +250,9 @@ TEST(lock_registry_failed_rollback_returns_cleanup_only_lease) { CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK); cbm_lock_lease_t *cleanup = NULL; cbm_private_file_lock_status_t status = - fault_set ? cbm_lock_registry_acquire(fixture.registry, "rollback-cleanup", - CBM_PRIVATE_FILE_LOCK_EX, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, - &fault.cancel_token, &cleanup) + fault_set ? cbm_lock_registry_acquire( + fixture.registry, "rollback-cleanup", CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, &fault.cancel_token, &cleanup) : CBM_PRIVATE_FILE_LOCK_IO; bool reached_native_ready = atomic_load_explicit(&fault.native_ready, memory_order_acquire); bool cleanup_retained = cleanup != NULL; @@ -424,10 +424,11 @@ TEST(lock_registry_terminal_close_error_finishes_pending_accounting) { bool started = lock_registry_fixture_start(&fixture); cbm_lock_lease_t *reader = NULL; cbm_private_file_lock_status_t acquired = - started ? cbm_lock_registry_acquire( - fixture.registry, "terminal-close-accounting", CBM_PRIVATE_FILE_LOCK_SH, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &reader) - : CBM_PRIVATE_FILE_LOCK_IO; + started + ? cbm_lock_registry_acquire(fixture.registry, "terminal-close-accounting", + CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &reader) + : CBM_PRIVATE_FILE_LOCK_IO; bool retryable_close_set = cbm_lock_lease_fail_next_release_step_for_test( reader, CBM_LOCK_REGISTRY_RELEASE_RW, CBM_PRIVATE_FILE_LOCK_RELEASE_CLOSE); cbm_private_file_lock_status_t first_release = @@ -443,10 +444,11 @@ TEST(lock_registry_terminal_close_error_finishes_pending_accounting) { size_t cleanup_finished = cbm_lock_registry_pending_cleanup_count_for_test(fixture.registry); cbm_lock_lease_t *after = NULL; cbm_private_file_lock_status_t after_status = - started ? cbm_lock_registry_acquire( - fixture.registry, "terminal-close-accounting", CBM_PRIVATE_FILE_LOCK_EX, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) - : CBM_PRIVATE_FILE_LOCK_IO; + started + ? cbm_lock_registry_acquire(fixture.registry, "terminal-close-accounting", + CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) + : CBM_PRIVATE_FILE_LOCK_IO; cbm_private_file_lock_status_t after_release = after ? cbm_lock_lease_release(&after) : CBM_PRIVATE_FILE_LOCK_IO; cbm_private_file_lock_status_t final_free = @@ -565,10 +567,11 @@ TEST(lock_registry_reader_close_failure_retains_lease_and_accounting) { bool started = lock_registry_fixture_start(&fixture); cbm_lock_lease_t *reader = NULL; cbm_private_file_lock_status_t acquired = - started ? cbm_lock_registry_acquire( - fixture.registry, "retry-reader-close", CBM_PRIVATE_FILE_LOCK_SH, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &reader) - : CBM_PRIVATE_FILE_LOCK_IO; + started + ? cbm_lock_registry_acquire(fixture.registry, "retry-reader-close", + CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &reader) + : CBM_PRIVATE_FILE_LOCK_IO; bool fault_set = cbm_lock_lease_fail_next_release_step_for_test( reader, CBM_LOCK_REGISTRY_RELEASE_RW, CBM_PRIVATE_FILE_LOCK_RELEASE_CLOSE); cbm_private_file_lock_status_t first_release = @@ -597,10 +600,11 @@ TEST(lock_registry_reader_close_failure_retains_lease_and_accounting) { size_t active_after_retry = cbm_lock_registry_active_lease_count_for_test(fixture.registry); cbm_lock_lease_t *after = NULL; cbm_private_file_lock_status_t after_status = - started ? cbm_lock_registry_acquire( - fixture.registry, "retry-reader-close", CBM_PRIVATE_FILE_LOCK_EX, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) - : CBM_PRIVATE_FILE_LOCK_IO; + started + ? cbm_lock_registry_acquire(fixture.registry, "retry-reader-close", + CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) + : CBM_PRIVATE_FILE_LOCK_IO; cbm_private_file_lock_status_t after_release = after ? cbm_lock_lease_release(&after) : CBM_PRIVATE_FILE_LOCK_IO; lock_registry_fixture_finish(&fixture); @@ -633,10 +637,11 @@ TEST(lock_registry_writer_partial_release_retries_rw_then_turn) { bool started = lock_registry_fixture_start(&fixture); cbm_lock_lease_t *writer = NULL; cbm_private_file_lock_status_t acquired = - started ? cbm_lock_registry_acquire( - fixture.registry, "retry-writer-turn", CBM_PRIVATE_FILE_LOCK_EX, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &writer) - : CBM_PRIVATE_FILE_LOCK_IO; + started + ? cbm_lock_registry_acquire(fixture.registry, "retry-writer-turn", + CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &writer) + : CBM_PRIVATE_FILE_LOCK_IO; bool fault_set = cbm_lock_lease_fail_next_release_step_for_test( writer, CBM_LOCK_REGISTRY_RELEASE_TURN, CBM_PRIVATE_FILE_LOCK_RELEASE_UNLOCK); cbm_private_file_lock_status_t first_release = @@ -687,10 +692,11 @@ TEST(lock_registry_writer_partial_release_retries_rw_then_turn) { size_t active_after_retry = cbm_lock_registry_active_lease_count_for_test(fixture.registry); cbm_lock_lease_t *after = NULL; cbm_private_file_lock_status_t after_status = - started ? cbm_lock_registry_acquire( - fixture.registry, "retry-writer-turn", CBM_PRIVATE_FILE_LOCK_SH, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) - : CBM_PRIVATE_FILE_LOCK_IO; + started + ? cbm_lock_registry_acquire(fixture.registry, "retry-writer-turn", + CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &after) + : CBM_PRIVATE_FILE_LOCK_IO; cbm_private_file_lock_status_t after_release = after ? cbm_lock_lease_release(&after) : CBM_PRIVATE_FILE_LOCK_IO; lock_registry_fixture_finish(&fixture); @@ -821,17 +827,18 @@ TEST(lock_registry_large_queue_parks_non_head_waiters) { bool started = lock_registry_fixture_start(&fixture); cbm_lock_lease_t *holder = NULL; cbm_private_file_lock_status_t holder_status = - started ? cbm_lock_registry_acquire( - fixture.registry, "large-queue-parking", CBM_PRIVATE_FILE_LOCK_SH, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &holder) - : CBM_PRIVATE_FILE_LOCK_IO; + started + ? cbm_lock_registry_acquire(fixture.registry, "large-queue-parking", + CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &holder) + : CBM_PRIVATE_FILE_LOCK_IO; lock_registry_waiter_t waiters[LOCK_REGISTRY_PARKING_WAITERS]; cbm_thread_t threads[LOCK_REGISTRY_PARKING_WAITERS]; size_t thread_count = 0; memset(waiters, 0, sizeof(waiters)); - for (; holder_status == CBM_PRIVATE_FILE_LOCK_OK && - thread_count < LOCK_REGISTRY_PARKING_WAITERS; + for (; + holder_status == CBM_PRIVATE_FILE_LOCK_OK && thread_count < LOCK_REGISTRY_PARKING_WAITERS; thread_count++) { waiters[thread_count] = (lock_registry_waiter_t){ .registry = fixture.registry, @@ -850,10 +857,9 @@ TEST(lock_registry_large_queue_parks_non_head_waiters) { bool all_queued = false; bool tails_parked = false; uint64_t observe_deadline = cbm_now_ms() + 500; - while (thread_count == LOCK_REGISTRY_PARKING_WAITERS && - cbm_now_ms() < observe_deadline) { - all_queued = cbm_lock_registry_waiter_count(fixture.registry) == - LOCK_REGISTRY_PARKING_WAITERS; + while (thread_count == LOCK_REGISTRY_PARKING_WAITERS && cbm_now_ms() < observe_deadline) { + all_queued = + cbm_lock_registry_waiter_count(fixture.registry) == LOCK_REGISTRY_PARKING_WAITERS; tails_parked = cbm_lock_registry_condition_waiter_count_for_test(fixture.registry) >= LOCK_REGISTRY_PARKING_WAITERS - 1; if (all_queued && tails_parked) { @@ -862,11 +868,9 @@ TEST(lock_registry_large_queue_parks_non_head_waiters) { lock_registry_test_yield(); } size_t attempting = cbm_lock_registry_attempting_waiter_count_for_test(fixture.registry); - uint64_t waits_before = - cbm_lock_registry_condition_wait_call_count_for_test(fixture.registry); + uint64_t waits_before = cbm_lock_registry_condition_wait_call_count_for_test(fixture.registry); cbm_usleep(25000); - uint64_t waits_after = - cbm_lock_registry_condition_wait_call_count_for_test(fixture.registry); + uint64_t waits_after = cbm_lock_registry_condition_wait_call_count_for_test(fixture.registry); for (size_t index = 0; index < thread_count; index++) { (void)cbm_lock_registry_request_cancel(fixture.registry, &waiters[index].cancel_token); @@ -905,10 +909,11 @@ TEST(lock_registry_cancel_request_wakes_parked_tail) { bool started = lock_registry_fixture_start(&fixture); cbm_lock_lease_t *holder = NULL; cbm_private_file_lock_status_t holder_status = - started ? cbm_lock_registry_acquire( - fixture.registry, "cancel-parked-tail", CBM_PRIVATE_FILE_LOCK_SH, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &holder) - : CBM_PRIVATE_FILE_LOCK_IO; + started + ? cbm_lock_registry_acquire(fixture.registry, "cancel-parked-tail", + CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &holder) + : CBM_PRIVATE_FILE_LOCK_IO; lock_registry_waiter_t head = {.registry = fixture.registry, .resource_key = "cancel-parked-tail", @@ -937,8 +942,8 @@ TEST(lock_registry_cancel_request_wakes_parked_tail) { atomic_init(&tail.cancel_token, false); atomic_init(&tail.finished, false); cbm_thread_t tail_thread; - bool tail_started = head_attempting && - cbm_thread_create(&tail_thread, 0, lock_registry_waiter_run, &tail) == 0; + bool tail_started = + head_attempting && cbm_thread_create(&tail_thread, 0, lock_registry_waiter_run, &tail) == 0; bool tail_parked = false; uint64_t tail_deadline = cbm_now_ms() + 500; while (tail_started && cbm_now_ms() < tail_deadline) { @@ -963,8 +968,8 @@ TEST(lock_registry_cancel_request_wakes_parked_tail) { } lock_registry_test_yield(); } - bool head_still_waiting = head_started && - !atomic_load_explicit(&head.finished, memory_order_acquire); + bool head_still_waiting = + head_started && !atomic_load_explicit(&head.finished, memory_order_acquire); if (head_started) { (void)cbm_lock_registry_request_cancel(fixture.registry, &head.cancel_token); @@ -1024,9 +1029,9 @@ static void *lock_registry_deadline_waiter_run(void *opaque) { while (!atomic_load_explicit(&waiter->go, memory_order_acquire)) { lock_registry_test_yield(); } - waiter->status = cbm_lock_registry_acquire( - waiter->registry, "absolute-deadline", CBM_PRIVATE_FILE_LOCK_EX, waiter->deadline_ms, - &waiter->cancel_token, &waiter->lease); + waiter->status = + cbm_lock_registry_acquire(waiter->registry, "absolute-deadline", CBM_PRIVATE_FILE_LOCK_EX, + waiter->deadline_ms, &waiter->cancel_token, &waiter->lease); waiter->returned_ms = cbm_now_ms(); atomic_store_explicit(&waiter->finished, true, memory_order_release); return NULL; @@ -1040,10 +1045,11 @@ TEST(lock_registry_absolute_deadline_survives_repeated_wakes) { bool started = lock_registry_fixture_start(&fixture); cbm_lock_lease_t *holder = NULL; cbm_private_file_lock_status_t holder_status = - started ? cbm_lock_registry_acquire( - fixture.registry, "absolute-deadline", CBM_PRIVATE_FILE_LOCK_SH, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &holder) - : CBM_PRIVATE_FILE_LOCK_IO; + started + ? cbm_lock_registry_acquire(fixture.registry, "absolute-deadline", + CBM_PRIVATE_FILE_LOCK_SH, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &holder) + : CBM_PRIVATE_FILE_LOCK_IO; lock_registry_waiter_t head = {.registry = fixture.registry, .resource_key = "absolute-deadline", @@ -1072,9 +1078,9 @@ TEST(lock_registry_absolute_deadline_survives_repeated_wakes) { atomic_init(&tail.go, false); atomic_init(&tail.finished, false); cbm_thread_t tail_thread; - bool tail_started = head_attempting && - cbm_thread_create(&tail_thread, 0, lock_registry_deadline_waiter_run, - &tail) == 0; + bool tail_started = + head_attempting && + cbm_thread_create(&tail_thread, 0, lock_registry_deadline_waiter_run, &tail) == 0; uint64_t ready_deadline = cbm_now_ms() + 500; while (tail_started && !atomic_load_explicit(&tail.ready, memory_order_acquire) && cbm_now_ms() < ready_deadline) { @@ -1174,9 +1180,9 @@ static void *lock_registry_stress_run(void *opaque) { ? CBM_PRIVATE_FILE_LOCK_EX : CBM_PRIVATE_FILE_LOCK_SH; cbm_lock_lease_t *lease = NULL; - cbm_private_file_lock_status_t status = cbm_lock_registry_acquire( - worker->registry, "stress", mode, cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, - &lease); + cbm_private_file_lock_status_t status = + cbm_lock_registry_acquire(worker->registry, "stress", mode, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &lease); if (status != CBM_PRIVATE_FILE_LOCK_OK || !lease) { (void)atomic_fetch_add_explicit(worker->violations, 1, memory_order_relaxed); return NULL; @@ -1315,10 +1321,10 @@ static int lock_registry_writer_child(const char *root, int started_fd, int acqu bool started = registry && lock_registry_pipe_write(started_fd, 'S'); cbm_lock_lease_t *lease = NULL; cbm_private_file_lock_status_t status = - started ? cbm_lock_registry_acquire(registry, "writer-preference", CBM_PRIVATE_FILE_LOCK_EX, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, - &lease) - : CBM_PRIVATE_FILE_LOCK_IO; + started + ? cbm_lock_registry_acquire(registry, "writer-preference", CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &lease) + : CBM_PRIVATE_FILE_LOCK_IO; bool reported = lock_registry_pipe_write(acquired_fd, status == CBM_PRIVATE_FILE_LOCK_OK ? 'W' : 'E'); char command = 0; @@ -1371,9 +1377,9 @@ static int lock_registry_reader_child(const char *root, int acquired_fd, int att static int lock_registry_inherited_child(cbm_lock_registry_t *registry, cbm_lock_lease_t *inherited_lease, int report_fd) { cbm_lock_lease_t *new_lease = NULL; - cbm_private_file_lock_status_t acquire_status = cbm_lock_registry_acquire( - registry, "fork-registry", CBM_PRIVATE_FILE_LOCK_EX, - cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &new_lease); + cbm_private_file_lock_status_t acquire_status = + cbm_lock_registry_acquire(registry, "fork-registry", CBM_PRIVATE_FILE_LOCK_EX, + cbm_now_ms() + LOCK_REGISTRY_TEST_TIMEOUT_MS, NULL, &new_lease); cbm_private_file_lock_status_t release_status = cbm_lock_lease_release(&inherited_lease); cbm_private_file_lock_status_t free_status = cbm_lock_registry_free(®istry); bool reported = lock_registry_pipe_write(report_fd, (char)acquire_status) && diff --git a/tests/test_main.c b/tests/test_main.c index d410b02f0..bb1cb291c 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -10,14 +10,15 @@ int tf_skip_count = 0; #include "test_framework.h" #include "test_helpers.h" -#include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ -#include "foundation/compat_fs.h" /* cbm_fopen — worker response file */ -#include "foundation/mem.h" /* cbm_mem_init — worker budget */ -#include "daemon/runtime.h" /* bounded worker response probe */ -#include "daemon/ipc.h" /* Windows private-lock re-exec probe */ +#include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ +#include "foundation/compat_fs.h" /* cbm_fopen — worker response file */ +#include "foundation/mem.h" /* cbm_mem_init — worker budget */ +#include "foundation/platform.h" /* cbm_file_exists — blocking-git marker */ +#include "daemon/runtime.h" /* bounded worker response probe */ +#include "daemon/ipc.h" /* Windows private-lock re-exec probe */ #include "daemon/version_cohort.h" /* Windows crash-turnover re-exec probe */ -#include "mcp/index_supervisor.h" /* cbm_index_set_worker_role */ -#include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */ +#include "mcp/index_supervisor.h" /* cbm_index_set_worker_role */ +#include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */ #include #include #include @@ -35,6 +36,67 @@ int tf_skip_count = 0; #endif #endif +/* daemon_runtime places an exact copy of this runner at a private PATH entry + * named git/git.exe. Only that copied basename plus this private marker opt in + * to the probe, so an inherited environment value cannot turn the ordinary + * test runner into a blocking process. The first invocation publishes its PID + * with create-exclusive semantics and ignores graceful termination; later git + * invocations see the marker and exit cleanly, allowing the parent shell to + * unwind after either production containment or the test's verified backstop. */ +#define TF_BLOCKING_GIT_MARKER_ENV "CBM_TEST_RUNTIME_BLOCKING_GIT_PID_FILE" + +static bool tf_invoked_as_blocking_git(const char *argv0) { + if (!argv0 || !argv0[0] || !getenv(TF_BLOCKING_GIT_MARKER_ENV)) { + return false; + } + const char *base = argv0; + for (const char *cursor = argv0; *cursor; cursor++) { + if (*cursor == '/' || *cursor == '\\') { + base = cursor + 1; + } + } + return strcmp(base, "git") == 0 || strcmp(base, "git.exe") == 0 || strcmp(base, "GIT.EXE") == 0; +} + +#ifdef _WIN32 +static BOOL WINAPI tf_blocking_git_control_handler(DWORD event) { + return event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT; +} +#endif + +static int tf_maybe_run_blocking_git_probe(int argc, char **argv) { + (void)argc; + if (!argv || !tf_invoked_as_blocking_git(argv[0])) { + return -1; + } + const char *marker_path = getenv(TF_BLOCKING_GIT_MARKER_ENV); + FILE *marker = cbm_fopen(marker_path, "wbx"); + if (!marker) { + /* The first invocation already owns the blocking role. detect_changes + * runs two more git commands after it terminates; those must not block. */ + return cbm_file_exists(marker_path) ? 0 : 30; + } +#ifdef _WIN32 + unsigned long long process_id = (unsigned long long)GetCurrentProcessId(); +#else + unsigned long long process_id = (unsigned long long)getpid(); +#endif + bool published = + fprintf(marker, "%llu\n", process_id) > 0 && fflush(marker) == 0 && fclose(marker) == 0; + if (!published) { + return 31; + } +#ifdef _WIN32 + (void)SetConsoleCtrlHandler(tf_blocking_git_control_handler, TRUE); +#else + (void)signal(SIGTERM, SIG_IGN); + (void)signal(SIGINT, SIG_IGN); +#endif + for (;;) { + cbm_usleep(100000); + } +} + /* Test handlers that exercise the production index_repository flow must never * inherit the user's real cache. Individual tests may temporarily override * this sentinel and restore it; atexit removes anything left behind by an @@ -86,9 +148,9 @@ static void tf_index_worker_probe(const char *args_json, const char *response_ou FILE *response = response_out ? cbm_fopen(response_out, "wb") : NULL; bool written = false; if (response) { - written = fseek(response, (long)CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX, - SEEK_SET) == 0 && - fputc('x', response) != EOF; + written = + fseek(response, (long)CBM_DAEMON_RUNTIME_APPLICATION_PAYLOAD_MAX, SEEK_SET) == 0 && + fputc('x', response) != EOF; written = fclose(response) == 0 && written; } (void)fprintf(stderr, "async worker oversized response probe\n"); @@ -221,8 +283,7 @@ static int tf_maybe_run_daemon_ipc_lock_probe(int argc, char **argv) { if (argc != 5 || strcmp(argv[1], "__cbm_daemon_ipc_lock_probe") != 0) { return -1; } - cbm_daemon_ipc_endpoint_t *endpoint = - cbm_daemon_ipc_endpoint_new(argv[3], argv[4]); + cbm_daemon_ipc_endpoint_t *endpoint = cbm_daemon_ipc_endpoint_new(argv[3], argv[4]); if (!endpoint) { return 21; } @@ -235,8 +296,7 @@ static int tf_maybe_run_daemon_ipc_lock_probe(int argc, char **argv) { } } else if (strcmp(argv[2], "lifetime") == 0) { cbm_daemon_ipc_lifetime_reservation_t *reservation = NULL; - result = cbm_daemon_ipc_lifetime_reservation_try_acquire( - endpoint, &reservation); + result = cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &reservation); cbm_daemon_ipc_lifetime_reservation_release(reservation); } cbm_daemon_ipc_endpoint_free(endpoint); @@ -250,18 +310,16 @@ static int tf_maybe_run_daemon_ipc_lock_probe(int argc, char **argv) { static int tf_maybe_run_version_cohort_crash_holder(int argc, char **argv) { #ifdef _WIN32 - if (argc != 5 || - strcmp(argv[1], "__cbm_version_cohort_crash_holder") != 0) { + if (argc != 5 || strcmp(argv[1], "__cbm_version_cohort_crash_holder") != 0) { return -1; } - cbm_daemon_ipc_endpoint_t *endpoint = - cbm_daemon_ipc_endpoint_new(argv[2], argv[3]); + cbm_daemon_ipc_endpoint_t *endpoint = cbm_daemon_ipc_endpoint_new(argv[2], argv[3]); cbm_version_cohort_manager_t *manager = endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL; cbm_daemon_build_identity_t identity = { .semantic_version = "2.4.0", - .build_fingerprint = - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + .build_fingerprint = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + .cache_fingerprint = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", .protocol_abi = 3, .store_abi = 11, .feature_abi = 7, @@ -269,12 +327,9 @@ static int tf_maybe_run_version_cohort_crash_holder(int argc, char **argv) { cbm_version_cohort_lease_t *lease = NULL; cbm_daemon_conflict_t conflict; cbm_version_cohort_status_t status = - manager ? cbm_version_cohort_acquire(manager, &identity, UINT64_MAX, - &lease, &conflict) + manager ? cbm_version_cohort_acquire(manager, &identity, UINT64_MAX, &lease, &conflict) : CBM_VERSION_COHORT_IO; - FILE *ready = status == CBM_VERSION_COHORT_OK - ? cbm_fopen(argv[4], "wb") - : NULL; + FILE *ready = status == CBM_VERSION_COHORT_OK ? cbm_fopen(argv[4], "wb") : NULL; bool announced = false; if (ready) { bool written = fputc('R', ready) != EOF; @@ -284,12 +339,10 @@ static int tf_maybe_run_version_cohort_crash_holder(int argc, char **argv) { Sleep(INFINITE); return 23; } - while (lease && cbm_version_cohort_lease_release(&lease) != - CBM_PRIVATE_FILE_LOCK_OK) { + while (lease && cbm_version_cohort_lease_release(&lease) != CBM_PRIVATE_FILE_LOCK_OK) { cbm_usleep(1000); } - while (manager && cbm_version_cohort_manager_free(&manager) != - CBM_PRIVATE_FILE_LOCK_OK) { + while (manager && cbm_version_cohort_manager_free(&manager) != CBM_PRIVATE_FILE_LOCK_OK) { cbm_usleep(1000); } cbm_daemon_ipc_endpoint_free(endpoint); @@ -303,8 +356,7 @@ static int tf_maybe_run_version_cohort_crash_holder(int argc, char **argv) { static int tf_maybe_run_runtime_image_holder(int argc, char **argv) { #ifdef _WIN32 - if (argc != 3 || - strcmp(argv[1], "__cbm_runtime_image_holder") != 0) { + if (argc != 3 || strcmp(argv[1], "__cbm_runtime_image_holder") != 0) { return -1; } HANDLE ready = OpenEventA(EVENT_MODIFY_STATE, FALSE, argv[2]); @@ -331,8 +383,7 @@ static int tf_maybe_run_runtime_hello_client(int argc, char **argv) { if (argc != 6 || strcmp(argv[1], "__cbm_runtime_hello_client") != 0) { return -1; } - cbm_daemon_ipc_endpoint_t *endpoint = - cbm_daemon_ipc_endpoint_new(argv[3], argv[2]); + cbm_daemon_ipc_endpoint_t *endpoint = cbm_daemon_ipc_endpoint_new(argv[3], argv[2]); cbm_daemon_build_identity_t identity = { .semantic_version = argv[4], .build_fingerprint = argv[5], @@ -340,11 +391,8 @@ static int tf_maybe_run_runtime_hello_client(int argc, char **argv) { cbm_daemon_runtime_connect_result_t result; memset(&result, 0, sizeof(result)); cbm_daemon_runtime_client_t *client = - endpoint ? cbm_daemon_runtime_client_connect(endpoint, &identity, 5000, - &result) - : NULL; - bool accepted = client && - result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED && + endpoint ? cbm_daemon_runtime_client_connect(endpoint, &identity, 5000, &result) : NULL; + bool accepted = client && result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED && result.hello_status == CBM_DAEMON_HELLO_COMPATIBLE; bool closed = !client || cbm_daemon_runtime_client_close(client, 5000); cbm_daemon_ipc_endpoint_free(endpoint); @@ -358,17 +406,14 @@ static int tf_maybe_run_runtime_hello_client(int argc, char **argv) { * executing copy so the daemon must authenticate that peer image rather than * require the active generation's build fingerprint. */ static int tf_maybe_run_runtime_activation_client(int argc, char **argv) { - if (argc != 7 || - strcmp(argv[1], "__cbm_runtime_activation_client") != 0) { + if (argc != 7 || strcmp(argv[1], "__cbm_runtime_activation_client") != 0) { return -1; } char *action_end = NULL; unsigned long action_value = strtoul(argv[6], &action_end, 10); bool action_valid = action_end != argv[6] && *action_end == '\0' && - action_value >= (unsigned long) - CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL && - action_value <= (unsigned long) - CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL; + action_value >= (unsigned long)CBM_DAEMON_RUNTIME_ACTIVATION_INSTALL && + action_value <= (unsigned long)CBM_DAEMON_RUNTIME_ACTIVATION_UNINSTALL; cbm_daemon_ipc_endpoint_t *endpoint = action_valid ? cbm_daemon_ipc_endpoint_new(argv[3], argv[2]) : NULL; cbm_daemon_build_identity_t identity = { @@ -377,11 +422,10 @@ static int tf_maybe_run_runtime_activation_client(int argc, char **argv) { }; cbm_daemon_runtime_activation_result_t result; memset(&result, 0, sizeof(result)); - bool exchanged = endpoint && - cbm_daemon_runtime_request_activation_shutdown( - endpoint, &identity, - (cbm_daemon_runtime_activation_action_t)action_value, - 5000, &result); + bool exchanged = + endpoint && cbm_daemon_runtime_request_activation_shutdown( + endpoint, &identity, (cbm_daemon_runtime_activation_action_t)action_value, + 5000, &result); cbm_daemon_ipc_endpoint_free(endpoint); return exchanged && result.accepted ? 0 : 29; } @@ -391,15 +435,12 @@ static int tf_maybe_run_runtime_activation_client(int argc, char **argv) { * main image, not merely find its own vnode in an arbitrary executable map. */ static int tf_maybe_run_runtime_mapped_hello_client(int argc, char **argv) { #ifdef __APPLE__ - if (argc != 7 || - strcmp(argv[1], "__cbm_runtime_mapped_hello_client") != 0) { + if (argc != 7 || strcmp(argv[1], "__cbm_runtime_mapped_hello_client") != 0) { return -1; } int image_fd = open(argv[2], O_RDONLY | O_CLOEXEC | O_NOFOLLOW); - void *mapping = image_fd >= 0 - ? mmap(NULL, 1, PROT_READ | PROT_EXEC, MAP_PRIVATE, - image_fd, 0) - : MAP_FAILED; + void *mapping = + image_fd >= 0 ? mmap(NULL, 1, PROT_READ | PROT_EXEC, MAP_PRIVATE, image_fd, 0) : MAP_FAILED; bool mapped = mapping != MAP_FAILED; if (image_fd >= 0 && close(image_fd) != 0) { mapped = false; @@ -411,8 +452,8 @@ static int tf_maybe_run_runtime_mapped_hello_client(int argc, char **argv) { return 28; } - char *hello_argv[] = {argv[0], "__cbm_runtime_hello_client", argv[3], - argv[4], argv[5], argv[6]}; + char *hello_argv[] = {argv[0], "__cbm_runtime_hello_client", argv[3], argv[4], argv[5], + argv[6]}; int result = tf_maybe_run_runtime_hello_client(6, hello_argv); if (munmap(mapping, 1) != 0) { return 28; @@ -427,12 +468,11 @@ static int tf_maybe_run_runtime_mapped_hello_client(int argc, char **argv) { static int tf_maybe_run_mcp_idxfailclosed_probe(int argc, char **argv) { #ifndef _WIN32 - if (argc != 4 || - strcmp(argv[1], "__cbm_mcp_idxfailclosed_probe") != 0) { + if (argc != 4 || strcmp(argv[1], "__cbm_mcp_idxfailclosed_probe") != 0) { return -1; } - extern int mcp_test_idxfailclosed_supervisor_start_check( - const char *repo_dir, const char *cache_dir); + extern int mcp_test_idxfailclosed_supervisor_start_check(const char *repo_dir, + const char *cache_dir); alarm(20); return mcp_test_idxfailclosed_supervisor_start_check(argv[2], argv[3]); #else @@ -552,6 +592,7 @@ extern void suite_config_toml_edit(void); extern void suite_config_yaml_edit(void); extern void suite_config_text_edit(void); extern void suite_activation_transaction(void); +extern void suite_windows_launcher_state(void); extern void suite_system_info(void); extern void suite_worker_pool(void); extern void suite_parallel(void); @@ -592,44 +633,41 @@ extern void suite_dump_verify_io(void); extern void cbm_kind_in_set_free_cache(void); int main(int argc, char **argv) { + int blocking_git_rc = tf_maybe_run_blocking_git_probe(argc, argv); + if (blocking_git_rc >= 0) { + return blocking_git_rc; + } /* Installation tests use this executable as a structurally real candidate. * Mirror the production binary's minimal verification contract. */ if (argc == 2 && strcmp(argv[1], "--version") == 0) { (void)puts("codebase-memory-mcp test-runner"); return 0; } - int mcp_idxfailclosed_rc = - tf_maybe_run_mcp_idxfailclosed_probe(argc, argv); + int mcp_idxfailclosed_rc = tf_maybe_run_mcp_idxfailclosed_probe(argc, argv); if (mcp_idxfailclosed_rc >= 0) { return mcp_idxfailclosed_rc; } - int runtime_image_rc = - tf_maybe_run_runtime_image_holder(argc, argv); + int runtime_image_rc = tf_maybe_run_runtime_image_holder(argc, argv); if (runtime_image_rc >= 0) { return runtime_image_rc; } - int runtime_hello_rc = - tf_maybe_run_runtime_hello_client(argc, argv); + int runtime_hello_rc = tf_maybe_run_runtime_hello_client(argc, argv); if (runtime_hello_rc >= 0) { return runtime_hello_rc; } - int runtime_activation_rc = - tf_maybe_run_runtime_activation_client(argc, argv); + int runtime_activation_rc = tf_maybe_run_runtime_activation_client(argc, argv); if (runtime_activation_rc >= 0) { return runtime_activation_rc; } - int runtime_mapped_hello_rc = - tf_maybe_run_runtime_mapped_hello_client(argc, argv); + int runtime_mapped_hello_rc = tf_maybe_run_runtime_mapped_hello_client(argc, argv); if (runtime_mapped_hello_rc >= 0) { return runtime_mapped_hello_rc; } - int cohort_crash_rc = - tf_maybe_run_version_cohort_crash_holder(argc, argv); + int cohort_crash_rc = tf_maybe_run_version_cohort_crash_holder(argc, argv); if (cohort_crash_rc >= 0) { return cohort_crash_rc; } - int daemon_ipc_probe_rc = - tf_maybe_run_daemon_ipc_lock_probe(argc, argv); + int daemon_ipc_probe_rc = tf_maybe_run_daemon_ipc_lock_probe(argc, argv); if (daemon_ipc_probe_rc >= 0) { return daemon_ipc_probe_rc; } @@ -798,6 +836,7 @@ int main(int argc, char **argv) { RUN_SELECTED_SUITE(config_yaml_edit); RUN_SELECTED_SUITE(config_text_edit); RUN_SELECTED_SUITE(activation_transaction); + RUN_SELECTED_SUITE(windows_launcher_state); /* System info + worker pool (parallelism) */ RUN_SELECTED_SUITE(system_info); diff --git a/tests/test_makefile_ts_runtime_dependencies.sh b/tests/test_makefile_ts_runtime_dependencies.sh new file mode 100644 index 000000000..9d1980786 --- /dev/null +++ b/tests/test_makefile_ts_runtime_dependencies.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regression guard for the ts_runtime unity build. ts_runtime.c includes +# vendored/ts_runtime/src/lib.c, which in turn includes the rest of the runtime. +# A change below that wrapper must invalidate every build variant. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +BUILD_DIR="$WORKDIR/build" +mkdir -p "$BUILD_DIR" + +DEPENDENCIES=( + internal/cbm/vendored/ts_runtime/src/stack.c + internal/cbm/vendored/ts_runtime/src/stack.h + internal/cbm/vendored/ts_runtime/src/unicode/utf8.h + internal/cbm/vendored/ts_runtime/include/tree_sitter/api.h +) +TARGETS=( + "$BUILD_DIR/ts_runtime.o" + "$BUILD_DIR/tsan_ts_runtime.o" + "$BUILD_DIR/prod_ts_runtime.o" +) + +cd "$ROOT" + +for dependency in "${DEPENDENCIES[@]}"; do + if [[ ! -f "$dependency" ]]; then + echo "FAIL: expected tree-sitter runtime dependency is missing: $dependency" + exit 1 + fi + + for target in "${TARGETS[@]}"; do + # Make the object newer than its currently-declared prerequisites. -W + # then asks make whether changing the unity-included file would rebuild + # it, without compiling or modifying the source tree. + touch "$target" + + status=0 + make -f Makefile.cbm -q -W "$dependency" \ + BUILD_DIR="$BUILD_DIR" "$target" || status=$? + + if [[ $status -eq 0 ]]; then + echo "FAIL: $target would stay stale after changing $dependency" + exit 1 + fi + if [[ $status -ne 1 ]]; then + echo "FAIL: make dependency probe failed for $target and $dependency (exit $status)" + exit 1 + fi + done +done + +echo "PASS: all tree-sitter runtime unity objects track included sources and headers" diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 906c38593..a0fd45430 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -128,7 +128,7 @@ static void cleanup_project_db(const char *cache, const char *project) { #define MCP_MUTATION_GUARD_MAX_EVENTS 16 typedef struct { - int deny_begin_call; /* one-based; zero allows every acquisition */ + int deny_begin_call; /* one-based; zero allows every acquisition */ int cancel_on_begin_call; /* one-based; zero never requests cancellation */ int begin_count; int end_count; @@ -174,8 +174,7 @@ static bool mcp_mutation_guard_probe_begin(void *context, const char *project) { snprintf(probe->begin_projects[event], sizeof(probe->begin_projects[event]), "%s", project ? project : ""); } - if (probe->cancel_on_begin_call > 0 && - probe->begin_count == probe->cancel_on_begin_call) { + if (probe->cancel_on_begin_call > 0 && probe->begin_count == probe->cancel_on_begin_call) { probe->cancel_attempted = true; probe->cancel_accepted = cbm_mcp_server_cancel_active(probe->cancel_server); } @@ -234,12 +233,12 @@ static cbm_store_t *mcp_open_corrupt_project_store_with_wal(const char *cache, return NULL; } - bool ready = cbm_store_exec(store, "PRAGMA wal_autocheckpoint=0;") == CBM_STORE_OK && - cbm_store_upsert_project(store, project, "826") == CBM_STORE_OK && - cbm_store_exec(store, - "CREATE TABLE IF NOT EXISTS guard_wal_sentinel(value TEXT);" - "INSERT INTO guard_wal_sentinel(value) VALUES('committed');") == - CBM_STORE_OK; + bool ready = + cbm_store_exec(store, "PRAGMA wal_autocheckpoint=0;") == CBM_STORE_OK && + cbm_store_upsert_project(store, project, "826") == CBM_STORE_OK && + cbm_store_exec(store, "CREATE TABLE IF NOT EXISTS guard_wal_sentinel(value TEXT);" + "INSERT INTO guard_wal_sentinel(value) VALUES('committed');") == + CBM_STORE_OK; if (!ready) { cbm_store_close(store); return NULL; @@ -310,8 +309,7 @@ static bool mcp_is_corrupt_backup_main_name(const char *name, const char *prefix return true; } const char *suffix = name + prefix_len; - if (strncmp(name, prefix, prefix_len) != 0 || suffix[0] != '.' || - strlen(suffix + 1) != 16) { + if (strncmp(name, prefix, prefix_len) != 0 || suffix[0] != '.' || strlen(suffix + 1) != 16) { return false; } for (const char *cursor = suffix + 1; *cursor; cursor++) { @@ -402,8 +400,7 @@ typedef struct { static bool mcp_replacing_mutation_guard_begin(void *context, const char *project) { mcp_replacing_mutation_guard_t *replacement = context; - if (!replacement || - !mcp_mutation_guard_probe_begin(&replacement->guard, project)) { + if (!replacement || !mcp_mutation_guard_probe_begin(&replacement->guard, project)) { return false; } replacement->replacement_attempted = true; @@ -2249,19 +2246,18 @@ TEST(tool_delete_project_mutation_guard_blocks_then_releases) { snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); cbm_store_t *setup = cbm_store_open_path(db_path); ASSERT_NOT_NULL(setup); - ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/guard-delete-project"), - CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/guard-delete-project"), CBM_STORE_OK); cbm_store_close(setup); ASSERT_TRUE(cbm_file_exists(db_path)); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); - char *resp = cbm_mcp_handle_tool( - srv, "delete_project", "{\"project\":\"guard-delete-project\"}"); + char *resp = + cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"guard-delete-project\"}"); ASSERT_NOT_NULL(resp); ASSERT_NOT_NULL(strstr(resp, "blocked")); ASSERT_EQ(probe.begin_count, 1); @@ -2271,8 +2267,7 @@ TEST(tool_delete_project_mutation_guard_blocks_then_releases) { free(resp); probe.deny_begin_call = 0; - resp = cbm_mcp_handle_tool( - srv, "delete_project", "{\"project\":\"guard-delete-project\"}"); + resp = cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"guard-delete-project\"}"); ASSERT_NOT_NULL(resp); ASSERT_NOT_NULL(strstr(resp, "deleted")); ASSERT_EQ(probe.begin_count, 2); @@ -2298,13 +2293,14 @@ TEST(tool_index_repository_mutation_guard_blocks_before_local_worker) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); char args[CBM_SZ_2K]; (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"GuardedIndex\"," - "\"mode\":\"fast\"}", root); + "\"mode\":\"fast\"}", + root); int spawn_before = cbm_index_supervisor_spawn_count(); char *response = cbm_mcp_handle_tool(srv, "index_repository", args); int spawn_after = cbm_index_supervisor_spawn_count(); @@ -3428,18 +3424,16 @@ TEST(tool_manage_adr_mutation_guard_balances_success) { ASSERT_NOT_NULL(srv); cbm_store_t *store = cbm_mcp_server_store(srv); ASSERT_NOT_NULL(store); - ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/guard-adr-success"), - CBM_STORE_OK); + ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/guard-adr-success"), CBM_STORE_OK); cbm_mcp_server_set_project(srv, project); mcp_mutation_guard_probe_t probe = {0}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); - char *resp = cbm_mcp_handle_tool( - srv, "manage_adr", - "{\"project\":\"guard-adr-success\",\"mode\":\"update\"," - "\"content\":\"## PURPOSE\\nGuarded ADR.\\n\"}"); + char *resp = cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"guard-adr-success\",\"mode\":\"update\"," + "\"content\":\"## PURPOSE\\nGuarded ADR.\\n\"}"); ASSERT_NOT_NULL(resp); ASSERT_NOT_NULL(strstr(resp, "updated")); ASSERT_EQ(probe.begin_count, 1); @@ -3467,11 +3461,11 @@ TEST(tool_manage_adr_mutation_guard_releases_on_missing_store) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); mcp_mutation_guard_probe_t probe = {0}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); - char *resp = cbm_mcp_handle_tool( - srv, "manage_adr", "{\"project\":\"guard-adr-missing\",\"mode\":\"get\"}"); + char *resp = cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"guard-adr-missing\",\"mode\":\"get\"}"); ASSERT_NOT_NULL(resp); ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed")); ASSERT_EQ(probe.begin_count, 1); @@ -3488,6 +3482,196 @@ TEST(tool_manage_adr_mutation_guard_releases_on_missing_store) { PASS(); } +/* A raw cbm_mcp_handle_tool() call is still one request lifetime. Cancellation + * published from inside a non-pipeline handler must therefore be accepted, + * observed before the write, and retired at completion so the next raw request + * on the same server is not poisoned. */ +TEST(tool_raw_dispatch_cancel_is_scoped_non_mutating_and_next_request_clean) { + const char *project = "raw-cancel-adr"; + char root[256]; + snprintf(root, sizeof(root), "%s/cbm-mcp-raw-adr-XXXXXX", cbm_tmpdir()); + ASSERT_NOT_NULL(cbm_mkdtemp(root)); + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_upsert_project(store, project, root), CBM_STORE_OK); + cbm_mcp_server_set_project(srv, project); + + mcp_mutation_guard_probe_t probe = { + .cancel_on_begin_call = 1, + .cancel_server = srv, + }; + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); + + char *cancelled_response = + cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"raw-cancel-adr\",\"mode\":\"update\"," + "\"content\":\"## PURPOSE\\nMUST NOT COMMIT.\\n\"}"); + bool cancellation_reported = cancelled_response && strstr(cancelled_response, "cancelled") && + strstr(cancelled_response, "\"isError\":true"); + + cbm_adr_t cancelled_adr = {0}; + int cancelled_lookup = cbm_store_adr_get(store, project, &cancelled_adr); + if (cancelled_lookup == CBM_STORE_OK) { + cbm_store_adr_free(&cancelled_adr); + } + + char *next_response = + cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"raw-cancel-adr\",\"mode\":\"update\"," + "\"content\":\"## PURPOSE\\nClean next request.\\n\"}"); + bool next_response_clean = next_response && strstr(next_response, "updated") && + !strstr(next_response, "cancelled") && + !strstr(next_response, "\"isError\":true"); + cbm_adr_t next_adr = {0}; + int next_lookup = cbm_store_adr_get(store, project, &next_adr); + bool next_write_committed = next_lookup == CBM_STORE_OK && next_adr.content && + strstr(next_adr.content, "Clean next request") && + !strstr(next_adr.content, "MUST NOT COMMIT"); + if (next_lookup == CBM_STORE_OK) { + cbm_store_adr_free(&next_adr); + } + + free(cancelled_response); + free(next_response); + cbm_mcp_server_free(srv); + (void)cbm_rmdir(root); + + ASSERT_TRUE(probe.cancel_attempted); + ASSERT_TRUE(probe.cancel_accepted); + ASSERT_TRUE(cancellation_reported); + ASSERT_EQ(cancelled_lookup, CBM_STORE_NOT_FOUND); + ASSERT_TRUE(next_response_clean); + ASSERT_TRUE(next_write_committed); + ASSERT_EQ(probe.begin_count, 2); + ASSERT_EQ(probe.end_count, 2); + ASSERT_STR_EQ(probe.begin_projects[0], project); + ASSERT_STR_EQ(probe.end_projects[0], project); + ASSERT_STR_EQ(probe.begin_projects[1], project); + ASSERT_STR_EQ(probe.end_projects[1], project); + PASS(); +} + +/* The daemon publishes its transport request before entering MCP dispatch. A + * disconnect in that narrow interval must remain latched through the nested + * raw tool scope instead of being erased at dispatch entry. */ +TEST(tool_outer_request_scope_preserves_predispatch_cancel) { + const char *project = "outer-scope-cancel-adr"; + char root[256]; + (void)snprintf(root, sizeof(root), "%s/cbm-mcp-outer-cancel-XXXXXX", cbm_tmpdir()); + bool root_created = cbm_mkdtemp(root) != NULL; + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + cbm_store_t *store = cbm_mcp_server_store(srv); + bool project_ready = + root_created && store && cbm_store_upsert_project(store, project, root) == CBM_STORE_OK; + cbm_mcp_server_set_project(srv, project); + bool outer_scope = project_ready && cbm_mcp_server_request_scope_begin(srv); + bool cancel_accepted = outer_scope && cbm_mcp_server_cancel_active(srv); + char *cancelled_response = + cancel_accepted + ? cbm_mcp_handle_tool(srv, "manage_adr", + "{\"project\":\"outer-scope-cancel-adr\"," + "\"mode\":\"update\",\"content\":\"MUST NOT COMMIT\"}") + : NULL; + bool cancellation_reported = cancelled_response && strstr(cancelled_response, "cancelled") && + strstr(cancelled_response, "\"isError\":true"); + cbm_mcp_server_request_scope_end(srv); + + char *next_response = srv ? cbm_mcp_handle_tool(srv, "ingest_traces", "{\"traces\":[]}") : NULL; + bool next_response_clean = next_response && strstr(next_response, "accepted") && + !strstr(next_response, "cancelled") && + !strstr(next_response, "\"isError\":true"); + + free(cancelled_response); + free(next_response); + cbm_mcp_server_free(srv); + (void)cbm_rmdir(root); + + ASSERT_TRUE(root_created); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(project_ready); + ASSERT_TRUE(outer_scope); + ASSERT_TRUE(cancel_accepted); + ASSERT_TRUE(cancellation_reported); + ASSERT_TRUE(next_response_clean); + PASS(); +} + +/* Publish cancellation from the local index mutation guard: the request scope + * must already be active, and the cancellation must either stop before + * pipeline admission or remain set through pipeline binding. No project DB may + * be published, and the following request must start with a clean token. */ +TEST(tool_index_repository_early_raw_cancel_survives_index_entry) { + char cache[256]; + char repo[256]; + snprintf(cache, sizeof(cache), "%s/cbm-mcp-raw-index-cache-XXXXXX", cbm_tmpdir()); + snprintf(repo, sizeof(repo), "%s/cbm-mcp-raw-index-repo-XXXXXX", cbm_tmpdir()); + bool cache_created = cbm_mkdtemp(cache) != NULL; + bool repo_created = cbm_mkdtemp(repo) != NULL; + + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + if (cache_created) { + cbm_setenv("CBM_CACHE_DIR", cache, 1); + } + + char *project = repo_created ? cbm_project_name_from_path(repo) : NULL; + cbm_mcp_server_t *srv = + cache_created && repo_created && project ? cbm_mcp_server_new(NULL) : NULL; + mcp_mutation_guard_probe_t probe = { + .cancel_on_begin_call = 1, + .cancel_server = srv, + }; + if (srv) { + cbm_mcp_server_set_background_tasks(srv, false); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); + } + + char args[CBM_SZ_1K]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo); + char *cancelled_response = srv ? cbm_mcp_handle_tool(srv, "index_repository", args) : NULL; + bool cancellation_reported = cancelled_response && strstr(cancelled_response, "cancelled") && + strstr(cancelled_response, "\"isError\":true"); + + char db_path[CBM_SZ_1K]; + snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project ? project : "missing-project"); + bool no_project_published = !cbm_file_exists(db_path); + + char *next_response = srv ? cbm_mcp_handle_tool(srv, "ingest_traces", "{\"traces\":[]}") : NULL; + bool next_response_clean = next_response && strstr(next_response, "accepted") && + !strstr(next_response, "cancelled") && + !strstr(next_response, "\"isError\":true"); + + free(cancelled_response); + free(next_response); + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + if (cache_created) { + (void)cbm_rmdir(cache); + } + if (repo_created) { + (void)cbm_rmdir(repo); + } + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + free(project); + + ASSERT_TRUE(cache_created); + ASSERT_TRUE(repo_created); + ASSERT_NOT_NULL(srv); + ASSERT_TRUE(probe.cancel_attempted); + ASSERT_TRUE(probe.cancel_accepted); + ASSERT_TRUE(cancellation_reported); + ASSERT_EQ(probe.begin_count, 1); + ASSERT_EQ(probe.end_count, 1); + ASSERT_TRUE(no_project_published); + ASSERT_TRUE(next_response_clean); + PASS(); +} + static bool mcp_cross_repo_create_project_store(const char *cache, const char *project, const char *root_path) { char db_path[CBM_SZ_1K]; @@ -3542,10 +3726,8 @@ static bool mcp_cross_repo_seed_http_match(const char *cache, const char *source .source_id = caller_id, .target_id = local_route_id, .type = "HTTP_CALLS", - .properties_json = - "{\"url_path\":\"/dedupe\",\"method\":\"GET\"}"}; - ok = ok && caller_id > 0 && local_route_id > 0 && - cbm_store_insert_edge(source, &http_call) > 0; + .properties_json = "{\"url_path\":\"/dedupe\",\"method\":\"GET\"}"}; + ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0; cbm_node_t target_route = {.project = target_project, .label = "Route", @@ -3567,8 +3749,7 @@ static bool mcp_cross_repo_seed_http_match(const char *cache, const char *source .source_id = handler_id, .target_id = target_route_id, .type = "HANDLES"}; - ok = ok && target_route_id > 0 && handler_id > 0 && - cbm_store_insert_edge(target, &handles) > 0; + ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0; cbm_store_close(source); cbm_store_close(target); @@ -3587,8 +3768,8 @@ TEST(tool_cross_repo_mutation_guard_sorts_dedupes_and_unwinds) { ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, repo, NULL)); mcp_mutation_guard_probe_t probe = {.deny_begin_call = 3}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); char args[CBM_SZ_2K]; snprintf(args, sizeof(args), @@ -3657,8 +3838,8 @@ TEST(tool_cross_repo_mutation_guard_casefolds_aliases_and_order) { ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, repo, NULL)); mcp_mutation_guard_probe_t first = {0}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &first); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &first); char first_args[CBM_SZ_2K]; snprintf(first_args, sizeof(first_args), "{\"repo_path\":\"%s\",\"name\":\"Zulu\"," @@ -3670,8 +3851,8 @@ TEST(tool_cross_repo_mutation_guard_casefolds_aliases_and_order) { free(first_resp); mcp_mutation_guard_probe_t second = {0}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &second); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &second); char second_args[CBM_SZ_2K]; snprintf(second_args, sizeof(second_args), "{\"repo_path\":\"%s\",\"name\":\"zULU\"," @@ -3687,12 +3868,12 @@ TEST(tool_cross_repo_mutation_guard_casefolds_aliases_and_order) { ASSERT_EQ(second.begin_count, 3); ASSERT_EQ(second.end_count, 3); for (int i = 0; i < 3; i++) { - ASSERT_TRUE(mcp_test_project_keys_equivalent(first.begin_projects[i], - second.begin_projects[i])); - ASSERT_TRUE(mcp_test_project_keys_equivalent( - first.end_projects[i], first.begin_projects[2 - i])); - ASSERT_TRUE(mcp_test_project_keys_equivalent( - second.end_projects[i], second.begin_projects[2 - i])); + ASSERT_TRUE( + mcp_test_project_keys_equivalent(first.begin_projects[i], second.begin_projects[i])); + ASSERT_TRUE( + mcp_test_project_keys_equivalent(first.end_projects[i], first.begin_projects[2 - i])); + ASSERT_TRUE( + mcp_test_project_keys_equivalent(second.end_projects[i], second.begin_projects[2 - i])); } ASSERT_STR_EQ(first.begin_projects[0], "Alpha"); ASSERT_STR_EQ(first.begin_projects[1], "Foo"); @@ -3726,8 +3907,8 @@ TEST(tool_cross_repo_rejects_wildcard_mixed_with_named_targets) { ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL)); mcp_mutation_guard_probe_t probe = {0}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); char args[CBM_SZ_2K]; snprintf(args, sizeof(args), @@ -3781,8 +3962,8 @@ TEST(tool_cross_repo_checks_cancellation_after_acquiring_leases) { .cancel_on_begin_call = 3, .cancel_server = srv, }; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); char args[CBM_SZ_2K]; snprintf(args, sizeof(args), @@ -3795,11 +3976,10 @@ TEST(tool_cross_repo_checks_cancellation_after_acquiring_leases) { bool cancel_accepted = probe.cancel_accepted; int begin_count = probe.begin_count; int end_count = probe.end_count; - bool reverse_unwind = - begin_count == 3 && end_count == 3 && - strcmp(probe.end_projects[0], probe.begin_projects[2]) == 0 && - strcmp(probe.end_projects[1], probe.begin_projects[1]) == 0 && - strcmp(probe.end_projects[2], probe.begin_projects[0]) == 0; + bool reverse_unwind = begin_count == 3 && end_count == 3 && + strcmp(probe.end_projects[0], probe.begin_projects[2]) == 0 && + strcmp(probe.end_projects[1], probe.begin_projects[1]) == 0 && + strcmp(probe.end_projects[2], probe.begin_projects[0]) == 0; free(resp); cbm_mcp_server_free(srv); @@ -3856,9 +4036,9 @@ TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases) { cache, existing_target); char *source_resp = cbm_mcp_handle_tool(srv, "index_repository", args); bool source_failed = source_resp && strstr(source_resp, "\"isError\":true"); - bool source_reported = source_resp && - (strstr(source_resp, "not indexed") || - strstr(source_resp, "not found") || strstr(source_resp, "missing")); + bool source_reported = + source_resp && (strstr(source_resp, "not indexed") || strstr(source_resp, "not found") || + strstr(source_resp, "missing")); bool source_ghost_created = cbm_file_exists(source_db_path); free(source_resp); @@ -3872,9 +4052,9 @@ TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases) { cache, missing_target); char *target_resp = cbm_mcp_handle_tool(srv, "index_repository", args); bool target_failed = target_resp && strstr(target_resp, "\"isError\":true"); - bool target_reported = target_resp && - (strstr(target_resp, "not indexed") || - strstr(target_resp, "not found") || strstr(target_resp, "missing")); + bool target_reported = + target_resp && (strstr(target_resp, "not indexed") || strstr(target_resp, "not found") || + strstr(target_resp, "missing")); bool target_ghost_created = cbm_file_exists(missing_target_db_path); free(target_resp); @@ -4022,20 +4202,19 @@ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { mcp_mutation_guard_probe_t query_probe = { .observed_db_path = db_path, }; - cbm_mcp_server_set_project_mutation_guard( - query_srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, - &query_probe); + cbm_mcp_server_set_project_mutation_guard(query_srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &query_probe); - char *resp = cbm_mcp_handle_tool( - query_srv, "search_graph", - "{\"project\":\"guard-corrupt-project\",\"name_pattern\":\".*\"}"); + char *resp = + cbm_mcp_handle_tool(query_srv, "search_graph", + "{\"project\":\"guard-corrupt-project\",\"name_pattern\":\".*\"}"); free(resp); cbm_mcp_server_free(query_srv); char query_backup_path[CBM_SZ_1K]; - int query_backup_count = mcp_find_corrupt_backups( - cache, project, query_backup_path, sizeof(query_backup_path)); - bool query_quarantined = !cbm_file_exists(db_path) && query_backup_count == 1 && - query_backup_path[0] != '\0'; + int query_backup_count = + mcp_find_corrupt_backups(cache, project, query_backup_path, sizeof(query_backup_path)); + bool query_quarantined = + !cbm_file_exists(db_path) && query_backup_count == 1 && query_backup_path[0] != '\0'; /* Replant the same deterministic corruption to exercise manage_adr's * already-held lease independently from the query server above. */ @@ -4046,18 +4225,17 @@ TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) { mcp_mutation_guard_probe_t adr_probe = { .observed_db_path = db_path, }; - cbm_mcp_server_set_project_mutation_guard( - adr_srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &adr_probe); - resp = cbm_mcp_handle_tool( - adr_srv, "manage_adr", - "{\"project\":\"guard-corrupt-project\",\"mode\":\"get\"}"); + cbm_mcp_server_set_project_mutation_guard(adr_srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &adr_probe); + resp = cbm_mcp_handle_tool(adr_srv, "manage_adr", + "{\"project\":\"guard-corrupt-project\",\"mode\":\"get\"}"); free(resp); cbm_mcp_server_free(adr_srv); char adr_backup_path[CBM_SZ_1K]; - int adr_backup_count = mcp_find_corrupt_backups( - cache, project, adr_backup_path, sizeof(adr_backup_path)); - bool adr_quarantined = !cbm_file_exists(db_path) && adr_backup_count == 1 && - adr_backup_path[0] != '\0'; + int adr_backup_count = + mcp_find_corrupt_backups(cache, project, adr_backup_path, sizeof(adr_backup_path)); + bool adr_quarantined = + !cbm_file_exists(db_path) && adr_backup_count == 1 && adr_backup_path[0] != '\0'; mcp_cleanup_corrupt_backups(cache, project); cleanup_project_db(cache, project); @@ -4116,11 +4294,10 @@ TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); char *resp = cbm_mcp_handle_tool( - srv, "search_graph", - "{\"project\":\"guard-corrupt-denied\",\"name_pattern\":\".*\"}"); + srv, "search_graph", "{\"project\":\"guard-corrupt-denied\",\"name_pattern\":\".*\"}"); bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); @@ -4130,8 +4307,7 @@ TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal) { int artifact_count = mcp_count_corrupt_artifacts(cache, project); int begin_count = probe.begin_count; int end_count = probe.end_count; - bool guarded_project = begin_count == 1 && - strcmp(probe.begin_projects[0], project) == 0; + bool guarded_project = begin_count == 1 && strcmp(probe.begin_projects[0], project) == 0; free(resp); cbm_mcp_server_free(srv); @@ -4172,11 +4348,9 @@ TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait) { char db_path[CBM_SZ_1K]; char replacement_path[CBM_SZ_1K]; snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project); - snprintf(replacement_path, sizeof(replacement_path), "%s/%s.replacement.db", cache, - project); + snprintf(replacement_path, sizeof(replacement_path), "%s/%s.replacement.db", cache, project); ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project)); - ASSERT_TRUE( - mcp_make_valid_project_store_at(replacement_path, project, replacement_root)); + ASSERT_TRUE(mcp_make_valid_project_store_at(replacement_path, project, replacement_root)); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); @@ -4184,12 +4358,10 @@ TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait) { .replacement_path = replacement_path, .live_path = db_path, }; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_replacing_mutation_guard_begin, mcp_replacing_mutation_guard_end, - &replacement); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_replacing_mutation_guard_begin, + mcp_replacing_mutation_guard_end, &replacement); char *resp = cbm_mcp_handle_tool( - srv, "search_graph", - "{\"project\":\"guard-corrupt-recheck\",\"name_pattern\":\".*\"}"); + srv, "search_graph", "{\"project\":\"guard-corrupt-recheck\",\"name_pattern\":\".*\"}"); bool response_used_replacement = resp && !response_contains_json_fragment(resp, "\"isError\":true"); free(resp); @@ -4210,10 +4382,9 @@ TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait) { bool replacement_consumed = !cbm_file_exists(replacement_path); int begin_count = replacement.guard.begin_count; int end_count = replacement.guard.end_count; - bool guarded_project = - begin_count == 1 && end_count == 1 && - strcmp(replacement.guard.begin_projects[0], project) == 0 && - strcmp(replacement.guard.end_projects[0], project) == 0; + bool guarded_project = begin_count == 1 && end_count == 1 && + strcmp(replacement.guard.begin_projects[0], project) == 0 && + strcmp(replacement.guard.end_projects[0], project) == 0; bool replacement_attempted = replacement.replacement_attempted; bool replacement_succeeded = replacement.replacement_succeeded; @@ -4257,18 +4428,16 @@ TEST(tool_corrupt_store_cleanup_preserves_existing_backup_and_uses_unique_name) ASSERT_EQ(th_write_file(existing_backup_path, "previous-backup-must-survive\n"), 0); long existing_len = 0; - unsigned char *existing_before = - mcp_read_file_bytes(existing_backup_path, &existing_len); + unsigned char *existing_before = mcp_read_file_bytes(existing_backup_path, &existing_len); ASSERT_NOT_NULL(existing_before); cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); mcp_mutation_guard_probe_t probe = {0}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &probe); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &probe); char *resp = cbm_mcp_handle_tool( - srv, "search_graph", - "{\"project\":\"guard-corrupt-unique\",\"name_pattern\":\".*\"}"); + srv, "search_graph", "{\"project\":\"guard-corrupt-unique\",\"name_pattern\":\".*\"}"); free(resp); cbm_mcp_server_free(srv); @@ -4276,21 +4445,18 @@ TEST(tool_corrupt_store_cleanup_preserves_existing_backup_and_uses_unique_name) mcp_file_matches_snapshot(existing_backup_path, existing_before, existing_len); free(existing_before); char unique_backup_path[CBM_SZ_1K]; - int backup_count = mcp_find_corrupt_backups(cache, project, unique_backup_path, - sizeof(unique_backup_path)); - cbm_store_t *quarantined = unique_backup_path[0] - ? cbm_store_open_path_query(unique_backup_path) - : NULL; - bool unique_backup_is_corrupt = - quarantined && !cbm_store_check_integrity(quarantined); + int backup_count = + mcp_find_corrupt_backups(cache, project, unique_backup_path, sizeof(unique_backup_path)); + cbm_store_t *quarantined = + unique_backup_path[0] ? cbm_store_open_path_query(unique_backup_path) : NULL; + bool unique_backup_is_corrupt = quarantined && !cbm_store_check_integrity(quarantined); cbm_store_close(quarantined); bool live_removed = !cbm_file_exists(db_path); int begin_count = probe.begin_count; int end_count = probe.end_count; - bool guarded_project = - begin_count == 1 && end_count == 1 && - strcmp(probe.begin_projects[0], project) == 0 && - strcmp(probe.end_projects[0], project) == 0; + bool guarded_project = begin_count == 1 && end_count == 1 && + strcmp(probe.begin_projects[0], project) == 0 && + strcmp(probe.end_projects[0], project) == 0; mcp_cleanup_corrupt_backups(cache, project); cleanup_project_db(cache, project); @@ -4340,13 +4506,13 @@ TEST(tool_corrupt_store_cleanup_publish_failure_preserves_db_and_wal) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); mcp_mutation_guard_probe_t guard = {0}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &guard); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &guard); mcp_quarantine_hook_probe_t hook = {.deny_step = "before_snapshot_publish"}; cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook); - char *resp = cbm_mcp_handle_tool( - srv, "search_graph", - "{\"project\":\"guard-corrupt-publish-fail\",\"name_pattern\":\".*\"}"); + char *resp = + cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"guard-corrupt-publish-fail\",\"name_pattern\":\".*\"}"); bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); @@ -4356,12 +4522,11 @@ TEST(tool_corrupt_store_cleanup_publish_failure_preserves_db_and_wal) { int artifact_count = mcp_count_corrupt_artifacts(cache, project); int begin_count = guard.begin_count; int end_count = guard.end_count; - bool guarded_project = - begin_count == 1 && end_count == 1 && - strcmp(guard.begin_projects[0], project) == 0 && - strcmp(guard.end_projects[0], project) == 0; - bool failed_at_publish = hook.call_count == 1 && - strcmp(hook.steps[0], "before_snapshot_publish") == 0; + bool guarded_project = begin_count == 1 && end_count == 1 && + strcmp(guard.begin_projects[0], project) == 0 && + strcmp(guard.end_projects[0], project) == 0; + bool failed_at_publish = + hook.call_count == 1 && strcmp(hook.steps[0], "before_snapshot_publish") == 0; free(resp); cbm_mcp_server_free(srv); @@ -4415,8 +4580,8 @@ TEST(tool_corrupt_store_cleanup_publishes_complete_wal_snapshot_before_delete) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); ASSERT_NOT_NULL(srv); mcp_mutation_guard_probe_t guard = {0}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &guard); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &guard); mcp_quarantine_hook_probe_t hook = {.deny_step = "after_snapshot_publish"}; cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook); char *resp = cbm_mcp_handle_tool( @@ -4426,8 +4591,7 @@ TEST(tool_corrupt_store_cleanup_publishes_complete_wal_snapshot_before_delete) { bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len); bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len); char backup_path[CBM_SZ_1K]; - int backup_count = - mcp_find_corrupt_backups(cache, project, backup_path, sizeof(backup_path)); + int backup_count = mcp_find_corrupt_backups(cache, project, backup_path, sizeof(backup_path)); int artifact_count = mcp_count_corrupt_artifacts(cache, project); cbm_store_t *snapshot = backup_path[0] ? cbm_store_open_path_query(backup_path) : NULL; cbm_project_t recovered = {0}; @@ -4440,8 +4604,7 @@ TEST(tool_corrupt_store_cleanup_publishes_complete_wal_snapshot_before_delete) { char backup_shm[CBM_SZ_2K]; snprintf(backup_wal, sizeof(backup_wal), "%s-wal", backup_path); snprintf(backup_shm, sizeof(backup_shm), "%s-shm", backup_path); - bool snapshot_self_contained = !cbm_file_exists(backup_wal) && - !cbm_file_exists(backup_shm); + bool snapshot_self_contained = !cbm_file_exists(backup_wal) && !cbm_file_exists(backup_shm); bool hook_order = hook.call_count == 2 && strcmp(hook.steps[0], "before_snapshot_publish") == 0 && strcmp(hook.steps[1], "after_snapshot_publish") == 0; @@ -6656,9 +6819,8 @@ static int idx823_supervised_name_override_check(const char *repo_dir, const cha * not acquire a lease before spawning. RED on the former ordering, which * returned "blocked" without ever starting the worker. */ mcp_mutation_guard_probe_t parent_guard = {.deny_begin_call = 1}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, - &parent_guard); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &parent_guard); char args[1024]; snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\",\"name\":\"%s\"}", @@ -6960,8 +7122,7 @@ enum { }; #ifndef _WIN32 -int mcp_test_idxfailclosed_supervisor_start_check(const char *repo_dir, - const char *cache_dir) { +int mcp_test_idxfailclosed_supervisor_start_check(const char *repo_dir, const char *cache_dir) { (void)cbm_setenv("CBM_CACHE_DIR", cache_dir, 1); cbm_index_supervisor_mark_host(); (void)cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1); @@ -6971,12 +7132,11 @@ int mcp_test_idxfailclosed_supervisor_start_check(const char *repo_dir, return IDXFAILCLOSED_NO_SERVER; } mcp_mutation_guard_probe_t parent_guard = {0}; - cbm_mcp_server_set_project_mutation_guard( - srv, mcp_mutation_guard_probe_begin, mcp_mutation_guard_probe_end, &parent_guard); + cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin, + mcp_mutation_guard_probe_end, &parent_guard); char args[CBM_SZ_4K]; - (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", - repo_dir); + (void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo_dir); char *response = cbm_mcp_handle_tool(srv, "index_repository", args); int result = IDXFAILCLOSED_OK; @@ -7024,8 +7184,7 @@ TEST(index_supervisor_start_failure_is_fail_closed_in_real_host) { #else char repo_dir[CBM_SZ_1K]; char cache_dir[CBM_SZ_1K]; - (void)snprintf(repo_dir, sizeof(repo_dir), "%s/cbm-idx-failclosed-repo-XXXXXX", - cbm_tmpdir()); + (void)snprintf(repo_dir, sizeof(repo_dir), "%s/cbm-idx-failclosed-repo-XXXXXX", cbm_tmpdir()); (void)snprintf(cache_dir, sizeof(cache_dir), "%s/cbm-idx-failclosed-cache-XXXXXX", cbm_tmpdir()); ASSERT_NOT_NULL(cbm_mkdtemp(repo_dir)); @@ -7046,16 +7205,11 @@ TEST(index_supervisor_start_failure_is_fail_closed_in_real_host) { char self_path[CBM_SZ_4K] = {0}; ASSERT_TRUE(idxfailclosed_self_path(self_path)); char *const child_argv[] = { - self_path, - "__cbm_mcp_idxfailclosed_probe", - repo_dir, - cache_dir, - NULL, + self_path, "__cbm_mcp_idxfailclosed_probe", repo_dir, cache_dir, NULL, }; (void)fflush(NULL); pid_t child = -1; - ASSERT_EQ(posix_spawn(&child, self_path, NULL, NULL, child_argv, environ), - 0); + ASSERT_EQ(posix_spawn(&child, self_path, NULL, NULL, child_argv, environ), 0); ASSERT_TRUE(child > 0); int status = 0; ASSERT_EQ(waitpid(child, &status, 0), child); @@ -8545,6 +8699,9 @@ SUITE(mcp_mutation_guard) { RUN_TEST(tool_index_repository_mutation_guard_blocks_before_local_worker); RUN_TEST(tool_manage_adr_mutation_guard_balances_success); RUN_TEST(tool_manage_adr_mutation_guard_releases_on_missing_store); + RUN_TEST(tool_raw_dispatch_cancel_is_scoped_non_mutating_and_next_request_clean); + RUN_TEST(tool_outer_request_scope_preserves_predispatch_cancel); + RUN_TEST(tool_index_repository_early_raw_cancel_survives_index_entry); RUN_TEST(tool_cross_repo_mutation_guard_sorts_dedupes_and_unwinds); RUN_TEST(tool_cross_repo_mutation_guard_casefolds_aliases_and_order); RUN_TEST(tool_cross_repo_rejects_wildcard_mixed_with_named_targets); diff --git a/tests/test_mem.c b/tests/test_mem.c index 7e67452f9..b94977c89 100644 --- a/tests/test_mem.c +++ b/tests/test_mem.c @@ -467,14 +467,12 @@ TEST(resolve_budget_override_when_total_unknown) { TEST(resolve_budget_worker_cap_preserves_lower_user_override) { size_t total = 8192 * CBM_TEST_MB; size_t worker_cap = 16 * CBM_TEST_MB; - cbm_mem_budget_t lower = - cbm_mem_resolve_budget_capped(total, 0.5, "8", worker_cap); + cbm_mem_budget_t lower = cbm_mem_resolve_budget_capped(total, 0.5, "8", worker_cap); ASSERT_EQ(lower.budget, 8 * CBM_TEST_MB); ASSERT_STR_EQ(lower.source, "CBM_MEM_BUDGET_MB"); ASSERT_FALSE(lower.hard_capped); - cbm_mem_budget_t capped = - cbm_mem_resolve_budget_capped(total, 0.5, "64", worker_cap); + cbm_mem_budget_t capped = cbm_mem_resolve_budget_capped(total, 0.5, "64", worker_cap); ASSERT_EQ(capped.budget, worker_cap); ASSERT_STR_EQ(capped.source, "daemon_worker_cap"); ASSERT_TRUE(capped.hard_capped); diff --git a/tests/test_parent_watchdog.sh b/tests/test_parent_watchdog.sh index 51181a14b..55632f0a7 100755 --- a/tests/test_parent_watchdog.sh +++ b/tests/test_parent_watchdog.sh @@ -44,7 +44,7 @@ cat >"${tmpdir}/wrapper.sh" <<'SH' #!/usr/bin/env bash set -euo pipefail exec 3<>"${FIFO}" -"${CBM_BINARY}" <&3 >/dev/null 2>"${TMPDIR_PATH}/child.err" & +"${CBM_BINARY}" <&3 >"${TMPDIR_PATH}/child.out" 2>"${TMPDIR_PATH}/child.err" & echo "$!" >"${TMPDIR_PATH}/child.pid" wait SH @@ -73,19 +73,24 @@ if ! kill -0 "${child_pid}" 2>/dev/null; then exit 3 fi -# Wait until the child has reached startup far enough to have installed the -# parent watchdog. The PID file is written immediately after fork; killing the -# wrapper before the child runs main() is an untestable early-reparent race where -# the child never observed the original parent PID. -for _ in {1..50}; do - if [[ -s "${tmpdir}/child.err" ]] && grep -q "mem.init" "${tmpdir}/child.err"; then +# Complete one MCP request before killing the parent. A response proves that +# the frontend reached its stdio loop after installing the parent watchdog. +# The old mem.init log sync point belonged to the pre-daemon architecture: the +# shared daemon now owns memory initialization, so a frontend need not emit it. +printf '%s\n' \ + '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"parent-watchdog-test","version":"1.0"}}}' \ + >"${tmpdir}/stdin" +for _ in {1..150}; do + if [[ -s "${tmpdir}/child.out" ]] && + grep -Eq '"id"[[:space:]]*:[[:space:]]*1' "${tmpdir}/child.out"; then break fi sleep 0.1 done -if ! grep -q "mem.init" "${tmpdir}/child.err" 2>/dev/null; then +if ! grep -Eq '"id"[[:space:]]*:[[:space:]]*1' "${tmpdir}/child.out" 2>/dev/null; then echo "child did not reach watchdog-ready startup point" >&2 [[ -s "${tmpdir}/child.err" ]] && cat "${tmpdir}/child.err" >&2 + [[ -s "${tmpdir}/child.out" ]] && cat "${tmpdir}/child.out" >&2 exit 3 fi diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 285a3cb4d..6cb2a6150 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5626,8 +5626,7 @@ static void cancel_at_publish_boundary(cbm_pipeline_t *p, const char *staging_pa cbm_store_t *staging = cbm_store_open_path(staging_path); if (staging) { ctx->staging_was_valid = cbm_store_check_integrity(staging); - ctx->staged_candidates = - count_nodes_named(staging, ctx->project, ctx->candidate); + ctx->staged_candidates = count_nodes_named(staging, ctx->project, ctx->candidate); cbm_store_close(staging); } } @@ -6122,8 +6121,7 @@ TEST(backup_failed_rename_failure_preserves_corrupt_main) { int rc = cbm_pipeline_run(p); cbm_pipeline_free(p); - bool final_preserved = - pipeline_fixture_file_equals(final_path, "corrupt-main-before-rename"); + bool final_preserved = pipeline_fixture_file_equals(final_path, "corrupt-main-before-rename"); (void)cbm_unlink(final_path); cleanup_incremental_repo(); diff --git a/tests/test_platform.c b/tests/test_platform.c index 3add7e33a..a1001579b 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -197,20 +197,20 @@ TEST(platform_path_helpers_use_per_thread_storage) { {.ready = &ready, .release = &release}, }; cbm_thread_t threads[2]; - bool started0 = cbm_thread_create(&threads[0], 0, platform_capture_path_buffers, - &results[0]) == 0; - bool started1 = cbm_thread_create(&threads[1], 0, platform_capture_path_buffers, - &results[1]) == 0; + bool started0 = + cbm_thread_create(&threads[0], 0, platform_capture_path_buffers, &results[0]) == 0; + bool started1 = + cbm_thread_create(&threads[1], 0, platform_capture_path_buffers, &results[1]) == 0; for (int spins = 0; started0 && started1 && spins < 5000 && atomic_load_explicit(&ready, memory_order_acquire) < 2; spins++) { cbm_usleep(1000); } bool both_ready = atomic_load_explicit(&ready, memory_order_acquire) == 2; - bool separate_home = both_ready && results[0].home && results[1].home && - results[0].home != results[1].home; - bool separate_cache = both_ready && results[0].cache && results[1].cache && - results[0].cache != results[1].cache; + bool separate_home = + both_ready && results[0].home && results[1].home && results[0].home != results[1].home; + bool separate_cache = + both_ready && results[0].cache && results[1].cache && results[0].cache != results[1].cache; atomic_store_explicit(&release, 1, memory_order_release); if (started0) { (void)cbm_thread_join(&threads[0]); @@ -240,6 +240,67 @@ TEST(platform_path_helpers_use_per_thread_storage) { PASS(); } +/* A configured cache root is an identity boundary. Truncating an oversized + * value and silently using the prefix would admit/log one root while placing + * data somewhere the user never selected. Reject it instead of falling back. */ +TEST(platform_cache_dir_rejects_truncated_override) { + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + size_t length = 5000U; + char *value = malloc(length + 1U); + ASSERT_NOT_NULL(value); + memcpy(value, "/tmp/", 5U); + memset(value + 5U, 'a', length - 5U); + value[length] = '\0'; + ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", value, 1), 0); + + const char *resolved = cbm_resolve_cache_dir(); + + if (saved_copy) { + (void)cbm_setenv("CBM_CACHE_DIR", saved_copy, 1); + } else { + (void)cbm_unsetenv("CBM_CACHE_DIR"); + } + free(saved_copy); + free(value); + ASSERT_NULL(resolved); + PASS(); +} + +#ifdef _WIN32 +/* cbm_safe_getenv reads Windows' wide environment as UTF-8. Its matching + * setter must update that same wide environment; _putenv_s alone interprets + * UTF-8 path bytes through the active ANSI code page. */ +TEST(platform_setenv_preserves_utf8_in_wide_environment) { + static const char utf8[] = "C:/cbm-cache-\xce\x94-\xe6\x97\xa5\xe6\x9c\xac"; + static const wchar_t wide[] = L"C:/cbm-cache-\u0394-\u65e5\u672c"; + ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", utf8, 1), 0); + wchar_t observed_wide[128]; + ASSERT_EQ(GetEnvironmentVariableW(L"CBM_CACHE_DIR", observed_wide, 128), (DWORD)(wcslen(wide))); + ASSERT_EQ(wcscmp(observed_wide, wide), 0); + char observed_utf8[128]; + ASSERT_NOT_NULL(cbm_safe_getenv("CBM_CACHE_DIR", observed_utf8, sizeof(observed_utf8), NULL)); + ASSERT_STR_EQ(observed_utf8, utf8); + (void)cbm_unsetenv("CBM_CACHE_DIR"); + PASS(); +} + +/* Empty and absent variables have different fallback semantics. In + * particular, an explicitly empty CBM_CACHE_DIR means "use the default"; it + * must not be misreported as a failed wide-environment read. Unset is also + * required to stay idempotent because test and process cleanup call it after + * partially initialized paths. */ +TEST(platform_windows_empty_environment_is_read_and_unset_idempotently) { + ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", "", 1), 0); + char observed[8] = "sentinel"; + ASSERT_NOT_NULL(cbm_safe_getenv("CBM_CACHE_DIR", observed, sizeof(observed), "fallback")); + ASSERT_STR_EQ(observed, ""); + ASSERT_EQ(cbm_unsetenv("CBM_CACHE_DIR"), 0); + ASSERT_EQ(cbm_unsetenv("CBM_CACHE_DIR"), 0); + PASS(); +} +#endif + /* * CBM_WORKERS env override for cbm_default_worker_count. * @@ -483,6 +544,11 @@ SUITE(platform) { RUN_TEST(platform_mmap); RUN_TEST(platform_mmap_nonexistent); RUN_TEST(platform_path_helpers_use_per_thread_storage); + RUN_TEST(platform_cache_dir_rejects_truncated_override); +#ifdef _WIN32 + RUN_TEST(platform_setenv_preserves_utf8_in_wide_environment); + RUN_TEST(platform_windows_empty_environment_is_read_and_unset_idempotently); +#endif RUN_TEST(platform_default_workers_env_override); RUN_TEST(platform_default_workers_env_invalid); RUN_TEST(platform_default_workers_env_unset); diff --git a/tests/test_private_file_lock.c b/tests/test_private_file_lock.c index a1e569d3a..2760bf55b 100644 --- a/tests/test_private_file_lock.c +++ b/tests/test_private_file_lock.c @@ -52,8 +52,7 @@ static int private_lock_macos_set_mutating_acl(const char *path) { acl_add_perm(permissions, ACL_WRITE_DATA) == 0 && acl_add_perm(permissions, ACL_WRITE_ATTRIBUTES) == 0 && acl_add_perm(permissions, ACL_DELETE) == 0 && - acl_set_permset(entry, permissions) == 0 && - acl_valid(acl) == 0; + acl_set_permset(entry, permissions) == 0 && acl_valid(acl) == 0; errno = valid ? 0 : errno; int result = valid ? acl_set_file(path, ACL_TYPE_EXTENDED, acl) : -1; int saved_error = errno; @@ -214,21 +213,19 @@ TEST(private_file_lock_payload_requires_exclusive_write_and_survives_reopen) { cbm_private_file_lock_status_t read_status = CBM_PRIVATE_FILE_LOCK_IO; cbm_private_file_lock_status_t shared_write_status = CBM_PRIVATE_FILE_LOCK_IO; if (started) { - writer_status = cbm_private_file_lock_try_acquire( - fixture.directory, "payload.lock", CBM_PRIVATE_FILE_LOCK_EX, &writer); + writer_status = cbm_private_file_lock_try_acquire(fixture.directory, "payload.lock", + CBM_PRIVATE_FILE_LOCK_EX, &writer); } if (writer_status == CBM_PRIVATE_FILE_LOCK_OK) { - write_status = cbm_private_file_lock_payload_write( - writer, payload, sizeof(payload)); + write_status = cbm_private_file_lock_payload_write(writer, payload, sizeof(payload)); (void)cbm_private_file_lock_release(&writer); - reader_status = cbm_private_file_lock_try_acquire( - fixture.directory, "payload.lock", CBM_PRIVATE_FILE_LOCK_SH, &reader); + reader_status = cbm_private_file_lock_try_acquire(fixture.directory, "payload.lock", + CBM_PRIVATE_FILE_LOCK_SH, &reader); } if (reader_status == CBM_PRIVATE_FILE_LOCK_OK) { - read_status = cbm_private_file_lock_payload_read( - reader, readback, sizeof(readback), &readback_length); - shared_write_status = cbm_private_file_lock_payload_write( - reader, payload, sizeof(payload)); + read_status = cbm_private_file_lock_payload_read(reader, readback, sizeof(readback), + &readback_length); + shared_write_status = cbm_private_file_lock_payload_write(reader, payload, sizeof(payload)); } if (reader) { (void)cbm_private_file_lock_release(&reader); @@ -688,30 +685,26 @@ TEST(private_file_lock_macos_rejects_directory_acl_added_after_adoption) { private_lock_fixture_t fixture; bool started = private_lock_fixture_start(&fixture); char lock_path[PRIVATE_LOCK_TEST_PATH_CAP] = {0}; - bool path_ok = - started && private_lock_path(lock_path, &fixture, "acl-directory.lock"); - int acl_fixture = - path_ok ? private_lock_macos_set_mutating_acl(fixture.root) : -1; + bool path_ok = started && private_lock_path(lock_path, &fixture, "acl-directory.lock"); + int acl_fixture = path_ok ? private_lock_macos_set_mutating_acl(fixture.root) : -1; if (acl_fixture == 0) { private_lock_fixture_finish(&fixture); SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); } struct stat root_status = {0}; - bool mode_stayed_private = - acl_fixture == 1 && lstat(fixture.root, &root_status) == 0 && - S_ISDIR(root_status.st_mode) && (root_status.st_mode & 07777) == 0700; + bool mode_stayed_private = acl_fixture == 1 && lstat(fixture.root, &root_status) == 0 && + S_ISDIR(root_status.st_mode) && + (root_status.st_mode & 07777) == 0700; cbm_private_file_lock_t *lock = NULL; cbm_private_file_lock_status_t acquire_status = mode_stayed_private - ? cbm_private_file_lock_try_acquire( - fixture.directory, "acl-directory.lock", - CBM_PRIVATE_FILE_LOCK_EX, &lock) + ? cbm_private_file_lock_try_acquire(fixture.directory, "acl-directory.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) : CBM_PRIVATE_FILE_LOCK_IO; struct stat unexpected = {0}; errno = 0; - bool file_was_not_created = - lstat(lock_path, &unexpected) != 0 && errno == ENOENT; + bool file_was_not_created = lstat(lock_path, &unexpected) != 0 && errno == ENOENT; if (lock) { (void)cbm_private_file_lock_release(&lock); } @@ -731,18 +724,15 @@ TEST(private_file_lock_macos_rejects_file_acl_added_after_acquisition) { private_lock_fixture_t fixture; bool started = private_lock_fixture_start(&fixture); char lock_path[PRIVATE_LOCK_TEST_PATH_CAP] = {0}; - bool path_ok = - started && private_lock_path(lock_path, &fixture, "acl-file.lock"); + bool path_ok = started && private_lock_path(lock_path, &fixture, "acl-file.lock"); cbm_private_file_lock_t *lock = NULL; cbm_private_file_lock_status_t acquire_status = - path_ok ? cbm_private_file_lock_try_acquire( - fixture.directory, "acl-file.lock", - CBM_PRIVATE_FILE_LOCK_EX, &lock) + path_ok ? cbm_private_file_lock_try_acquire(fixture.directory, "acl-file.lock", + CBM_PRIVATE_FILE_LOCK_EX, &lock) : CBM_PRIVATE_FILE_LOCK_IO; - int acl_fixture = - acquire_status == CBM_PRIVATE_FILE_LOCK_OK - ? private_lock_macos_set_mutating_acl(lock_path) - : -1; + int acl_fixture = acquire_status == CBM_PRIVATE_FILE_LOCK_OK + ? private_lock_macos_set_mutating_acl(lock_path) + : -1; if (acl_fixture == 0) { if (lock) { (void)cbm_private_file_lock_release(&lock); @@ -751,22 +741,18 @@ TEST(private_file_lock_macos_rejects_file_acl_added_after_acquisition) { SKIP_PLATFORM("macOS fixture filesystem has no extended ACL support"); } struct stat file_status = {0}; - bool mode_stayed_private = - acl_fixture == 1 && lstat(lock_path, &file_status) == 0 && - S_ISREG(file_status.st_mode) && (file_status.st_mode & 07777) == 0600; + bool mode_stayed_private = acl_fixture == 1 && lstat(lock_path, &file_status) == 0 && + S_ISREG(file_status.st_mode) && + (file_status.st_mode & 07777) == 0600; static const unsigned char payload[] = {'a', 'c', 'l'}; cbm_private_file_lock_status_t payload_status = - mode_stayed_private - ? cbm_private_file_lock_payload_write(lock, payload, - sizeof(payload)) - : CBM_PRIVATE_FILE_LOCK_IO; + mode_stayed_private ? cbm_private_file_lock_payload_write(lock, payload, sizeof(payload)) + : CBM_PRIVATE_FILE_LOCK_IO; cbm_private_file_lock_status_t release_status = lock ? cbm_private_file_lock_release(&lock) : CBM_PRIVATE_FILE_LOCK_IO; cbm_private_file_lock_t *reopened = NULL; - cbm_private_file_lock_status_t reopen_status = - cbm_private_file_lock_try_acquire( - fixture.directory, "acl-file.lock", CBM_PRIVATE_FILE_LOCK_EX, - &reopened); + cbm_private_file_lock_status_t reopen_status = cbm_private_file_lock_try_acquire( + fixture.directory, "acl-file.lock", CBM_PRIVATE_FILE_LOCK_EX, &reopened); if (reopened) { (void)cbm_private_file_lock_release(&reopened); } diff --git a/tests/test_project_lock.c b/tests/test_project_lock.c index 75377b138..52b406977 100644 --- a/tests/test_project_lock.c +++ b/tests/test_project_lock.c @@ -13,24 +13,21 @@ enum { PROJECT_LOCK_TEST_PATH_CAP = 1024 }; static void project_lock_test_release(cbm_project_lock_lease_t **lease) { - while (lease && *lease && - cbm_project_lock_lease_release(lease) != CBM_PRIVATE_FILE_LOCK_OK) { + while (lease && *lease && cbm_project_lock_lease_release(lease) != CBM_PRIVATE_FILE_LOCK_OK) { cbm_usleep(1000); } } TEST(project_lock_coordinates_instances_projects_wildcard_and_case_aliases) { char runtime_parent[PROJECT_LOCK_TEST_PATH_CAP]; - (void)snprintf(runtime_parent, sizeof(runtime_parent), - "%s/cbm-project-lock-XXXXXX", cbm_tmpdir()); + (void)snprintf(runtime_parent, sizeof(runtime_parent), "%s/cbm-project-lock-XXXXXX", + cbm_tmpdir()); ASSERT_NOT_NULL(cbm_mkdtemp(runtime_parent)); cbm_daemon_ipc_endpoint_t *endpoint = cbm_daemon_ipc_endpoint_new("0123456789abcdef", runtime_parent); - cbm_project_lock_manager_t *first = - cbm_project_lock_manager_new(endpoint); - cbm_project_lock_manager_t *second = - cbm_project_lock_manager_new(endpoint); + cbm_project_lock_manager_t *first = cbm_project_lock_manager_new(endpoint); + cbm_project_lock_manager_t *second = cbm_project_lock_manager_new(endpoint); ASSERT_NOT_NULL(endpoint); ASSERT_NOT_NULL(first); ASSERT_NOT_NULL(second); @@ -38,11 +35,9 @@ TEST(project_lock_coordinates_instances_projects_wildcard_and_case_aliases) { cbm_project_lock_lease_t *foo = NULL; cbm_project_lock_lease_t *alias = NULL; cbm_project_lock_lease_t *bar = NULL; - ASSERT_EQ(cbm_project_lock_try_acquire(first, "Foo", &foo), - CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(cbm_project_lock_try_acquire(first, "Foo", &foo), CBM_PRIVATE_FILE_LOCK_OK); ASSERT_NOT_NULL(foo); - ASSERT_EQ(cbm_project_lock_try_acquire(second, "foo", &alias), - CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_EQ(cbm_project_lock_try_acquire(second, "foo", &alias), CBM_PRIVATE_FILE_LOCK_BUSY); ASSERT_NULL(alias); ASSERT_EQ(cbm_project_lock_acquire(second, "bar", UINT64_MAX, NULL, &bar), CBM_PRIVATE_FILE_LOCK_OK); @@ -55,8 +50,7 @@ TEST(project_lock_coordinates_instances_projects_wildcard_and_case_aliases) { ASSERT_EQ(cbm_project_lock_acquire(first, "*", UINT64_MAX, NULL, &all), CBM_PRIVATE_FILE_LOCK_OK); ASSERT_NOT_NULL(all); - ASSERT_EQ(cbm_project_lock_try_acquire(second, "unrelated", &bar), - CBM_PRIVATE_FILE_LOCK_BUSY); + ASSERT_EQ(cbm_project_lock_try_acquire(second, "unrelated", &bar), CBM_PRIVATE_FILE_LOCK_BUSY); ASSERT_NULL(bar); project_lock_test_release(&all); diff --git a/tests/test_security_fuzz_harness.sh b/tests/test_security_fuzz_harness.sh new file mode 100644 index 000000000..b9bb78707 --- /dev/null +++ b/tests/test_security_fuzz_harness.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Regression tests for scripts/security-fuzz.sh itself. A fuzz case is green +# only after the target proves it consumed the adversarial request; merely +# surviving initialization and then exiting cleanly at EOF is insufficient. + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +WORKDIR="$(mktemp -d)" +trap 'rm -rf "$WORKDIR"' EXIT + +INIT_ONLY="$WORKDIR/init-only-mcp" +cat > "$INIT_ONLY" <<'EOF' +#!/usr/bin/env bash +IFS= read -r _initialize || exit 0 +printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{},"serverInfo":{"name":"fixture","version":"1"}}}' +exit 0 +EOF +chmod +x "$INIT_ONLY" + +if "$ROOT/scripts/security-fuzz.sh" "$INIT_ONLY" \ + > "$WORKDIR/init-only.out" 2>&1; then + echo "FAIL: security-fuzz accepted a process that never consumed the adversarial payload" + exit 1 +fi + +ECHO_ONLY="$WORKDIR/echo-only-mcp" +cat > "$ECHO_ONLY" <<'EOF' +#!/usr/bin/env bash +# Seeing the acknowledgement id in echoed input is not proof that the request +# reached JSON-RPC dispatch; no response object is ever produced here. +while IFS= read -r line; do + printf '%s\n' "$line" +done +EOF +chmod +x "$ECHO_ONLY" + +if "$ROOT/scripts/security-fuzz.sh" "$ECHO_ONLY" \ + > "$WORKDIR/echo-only.out" 2>&1; then + echo "FAIL: security-fuzz accepted an echoed request as an acknowledgement" + exit 1 +fi + +ENV_PROBE="$WORKDIR/environment-probe-mcp" +cat > "$ENV_PROBE" <<'EOF' +#!/usr/bin/env bash +printf '%s\t%s\n' "${HOME-}" "${CBM_CACHE_DIR-}" >> "$CBM_FUZZ_ENV_PROBE" + +# Echo a JSON-RPC result for every request with a numeric id. This keeps the +# fixture compatible with both the current fixed ids and a future per-case +# acknowledgement id without depending on the malformed payload itself. +while IFS= read -r line; do + id=$(printf '%s\n' "$line" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p') + if [[ -n "$id" ]]; then + printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" + fi +done +EOF +chmod +x "$ENV_PROBE" + +CALLER_HOME="$WORKDIR/caller-home" +CALLER_CACHE="$WORKDIR/caller-cache" +ENV_LOG="$WORKDIR/environment.log" +mkdir -p "$CALLER_HOME" "$CALLER_CACHE" + +if ! HOME="$CALLER_HOME" \ + CBM_CACHE_DIR="$CALLER_CACHE" \ + CBM_FUZZ_ENV_PROBE="$ENV_LOG" \ + "$ROOT/scripts/security-fuzz.sh" "$ENV_PROBE" \ + > "$WORKDIR/environment.out" 2>&1; then + echo "FAIL: environment-probe fixture was rejected" + cat "$WORKDIR/environment.out" + exit 1 +fi + +if [[ ! -s "$ENV_LOG" ]]; then + echo "FAIL: security-fuzz did not execute the environment-probe fixture" + exit 1 +fi + +while IFS=$'\t' read -r child_home child_cache; do + if [[ -z "$child_home" || "$child_home" == "$CALLER_HOME" ]]; then + echo "FAIL: security-fuzz exposed the caller HOME to a fuzz target" + exit 1 + fi + if [[ -z "$child_cache" || "$child_cache" == "$CALLER_CACHE" ]]; then + echo "FAIL: security-fuzz exposed the caller CBM_CACHE_DIR to a fuzz target" + exit 1 + fi + child_home_parent=${child_home%/*} + child_cache_parent=${child_cache%/*} + if [[ "$child_home_parent" != "$child_cache_parent" || + "${child_home##*/}" != "home" || + "${child_cache##*/}" != "cache" ]]; then + echo "FAIL: fuzz HOME/cache were not isolated beneath one harness temp directory" + exit 1 + fi +done < "$ENV_LOG" + +echo "PASS: security fuzz harness requires payload progress and isolates HOME/cache" diff --git a/tests/test_semantic.c b/tests/test_semantic.c index c4030a0fa..d1bf7b54b 100644 --- a/tests/test_semantic.c +++ b/tests/test_semantic.c @@ -24,7 +24,8 @@ TEST(sem_tokenize_camel) { ASSERT_STR_EQ(tokens[0], "parse"); ASSERT_STR_EQ(tokens[1], "user"); ASSERT_STR_EQ(tokens[2], "input"); - for (int i = 0; i < n; i++) free(tokens[i]); + for (int i = 0; i < n; i++) + free(tokens[i]); PASS(); } @@ -35,7 +36,8 @@ TEST(sem_tokenize_snake) { ASSERT_STR_EQ(tokens[0], "handle"); ASSERT_STR_EQ(tokens[1], "http"); ASSERT_STR_EQ(tokens[2], "request"); - for (int i = 0; i < n; i++) free(tokens[i]); + for (int i = 0; i < n; i++) + free(tokens[i]); PASS(); } @@ -46,7 +48,8 @@ TEST(sem_tokenize_dot) { ASSERT_STR_EQ(tokens[0], "net"); ASSERT_STR_EQ(tokens[1], "http"); ASSERT_STR_EQ(tokens[2], "client"); - for (int i = 0; i < n; i++) free(tokens[i]); + for (int i = 0; i < n; i++) + free(tokens[i]); PASS(); } @@ -60,7 +63,8 @@ TEST(sem_tokenize_max_out) { char *tokens[3]; int n = cbm_sem_tokenize("a_b_c_d_e_f_g", tokens, 3); ASSERT_EQ(n, 3); - for (int i = 0; i < n; i++) free(tokens[i]); + for (int i = 0; i < n; i++) + free(tokens[i]); PASS(); } @@ -71,20 +75,26 @@ TEST(sem_tokenize_abbrev_expansion) { ASSERT_GTE(n, 4); bool has_ctx = false, has_context = false, has_err = false, has_error = false; for (int i = 0; i < n; i++) { - if (strcmp(tokens[i], "ctx") == 0) has_ctx = true; - if (strcmp(tokens[i], "context") == 0) has_context = true; - if (strcmp(tokens[i], "err") == 0) has_err = true; - if (strcmp(tokens[i], "error") == 0) has_error = true; + if (strcmp(tokens[i], "ctx") == 0) + has_ctx = true; + if (strcmp(tokens[i], "context") == 0) + has_context = true; + if (strcmp(tokens[i], "err") == 0) + has_err = true; + if (strcmp(tokens[i], "error") == 0) + has_error = true; } ASSERT_TRUE(has_ctx && has_context && has_err && has_error); - for (int i = 0; i < n; i++) free(tokens[i]); + for (int i = 0; i < n; i++) + free(tokens[i]); PASS(); } /* ── Cosine similarity ───────────────────────────────────────────── */ static void fill_vec(cbm_sem_vec_t *v, float val) { - for (int i = 0; i < CBM_SEM_DIM; i++) v->v[i] = val; + for (int i = 0; i < CBM_SEM_DIM; i++) + v->v[i] = val; } TEST(sem_cosine_identical) { @@ -148,7 +158,8 @@ TEST(sem_normalize_scales) { fill_vec(&v, 2.0f); cbm_sem_normalize(&v); float mag_sq = 0.0f; - for (int i = 0; i < CBM_SEM_DIM; i++) mag_sq += v.v[i] * v.v[i]; + for (int i = 0; i < CBM_SEM_DIM; i++) + mag_sq += v.v[i] * v.v[i]; float mag = sqrtf(mag_sq); ASSERT_FLOAT_EQ(mag, 1.0f, 0.01f); PASS(); @@ -183,8 +194,8 @@ TEST(sem_vec_add_scaled_basic) { TEST(sem_vec_add_scaled_null) { cbm_sem_vec_t v; fill_vec(&v, 1.0f); - cbm_sem_vec_add_scaled(NULL, &v, 1.0f); /* should not crash */ - cbm_sem_vec_add_scaled(&v, NULL, 1.0f); /* should not crash */ + cbm_sem_vec_add_scaled(NULL, &v, 1.0f); /* should not crash */ + cbm_sem_vec_add_scaled(&v, NULL, 1.0f); /* should not crash */ PASS(); } @@ -270,7 +281,8 @@ TEST(sem_diffuse_single_neighbor) { cbm_sem_diffuse(&v, &nb, 1, 0.3f); /* After diffuse+normalize, result should still be unit-length */ float mag_sq = 0.0f; - for (int i = 0; i < CBM_SEM_DIM; i++) mag_sq += v.v[i] * v.v[i]; + for (int i = 0; i < CBM_SEM_DIM; i++) + mag_sq += v.v[i] * v.v[i]; ASSERT_FLOAT_EQ(sqrtf(mag_sq), 1.0f, 0.01f); /* Component 0 should be pulled toward neighbor's strong dim-0 */ ASSERT_TRUE(v.v[0] > 0.0f); diff --git a/tests/test_store_checkpoint.c b/tests/test_store_checkpoint.c index 407bec929..4f3bcf11b 100644 --- a/tests/test_store_checkpoint.c +++ b/tests/test_store_checkpoint.c @@ -35,10 +35,8 @@ TEST(checkpoint_does_not_truncate_wal) { ASSERT(s != NULL); /* Grow WAL beyond zero bytes via direct SQL. */ - int rc_sql = cbm_store_exec( - s, - "INSERT OR IGNORE INTO projects(name, indexed_at, root_path) " - "VALUES('p', '2026-01-01', '/tmp/p');"); + int rc_sql = cbm_store_exec(s, "INSERT OR IGNORE INTO projects(name, indexed_at, root_path) " + "VALUES('p', '2026-01-01', '/tmp/p');"); ASSERT_EQ(rc_sql, 0); for (int i = 0; i < N_ROWS; i++) { char sql[256]; @@ -74,7 +72,6 @@ TEST(checkpoint_does_not_truncate_wal) { PASS(); } - /* #897: any code path installing a fresh DB file must delete the * destination's -wal/-shm first. SQLite decides whether to replay a WAL * purely from the sidecar's own header/checksums — a leftover WAL from a diff --git a/tests/test_subprocess.c b/tests/test_subprocess.c index fc564184d..4cecbb335 100644 --- a/tests/test_subprocess.c +++ b/tests/test_subprocess.c @@ -120,6 +120,28 @@ TEST(subprocess_run_exit_nonzero) { #endif } +/* Daemon background helpers intentionally invoke fixed tool names such as + * `curl` and `git`. A shell-free spawn must still perform the normal PATH + * lookup for a name without a directory separator; exact binary paths keep + * their existing exec semantics. */ +TEST(subprocess_run_resolves_literal_binary_name_from_path) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX PATH/execvp semantics"); +#else + const char *argv[] = {"sh", "-c", "exit 0", NULL}; + cbm_proc_opts_t opts = { + .bin = "sh", + .argv = argv, + }; + cbm_proc_result_t result; + int rc = cbm_subprocess_run(&opts, &result); + ASSERT_EQ(rc, 0); + ASSERT_EQ(result.outcome, CBM_PROC_CLEAN); + ASSERT_EQ(result.exit_code, 0); + PASS(); +#endif +} + /* A child that dies of SIGSEGV must classify as CRASH — NOT exit_nonzero and NOT * killed. This is the whole point of the primitive: distinguish a crash from a * clean failure so the supervisor can quarantine the culprit. */ @@ -151,7 +173,7 @@ TEST(subprocess_run_spawn_failure) { #ifdef _WIN32 SKIP_PLATFORM("POSIX exec semantics"); #else - /* execv of a bogus path: fork succeeds, child _exit(127). We classify the + /* execvp of a bogus path: fork succeeds, child _exit(127). We classify the * reaped 127 as exit_nonzero — spawn_failed is reserved for fork() failing. */ const char *argv[] = {"/nonexistent/cbm-bogus-binary", NULL}; cbm_proc_opts_t opts = {0}; @@ -791,6 +813,7 @@ SUITE(subprocess) { RUN_TEST(subprocess_outcome_str); RUN_TEST(subprocess_run_clean); RUN_TEST(subprocess_run_exit_nonzero); + RUN_TEST(subprocess_run_resolves_literal_binary_name_from_path); RUN_TEST(subprocess_run_crash_is_crash); RUN_TEST(subprocess_run_hang_is_hang); RUN_TEST(subprocess_run_spawn_failure); diff --git a/tests/test_ui.c b/tests/test_ui.c index daabbc05d..30e069a12 100644 --- a/tests/test_ui.c +++ b/tests/test_ui.c @@ -88,9 +88,7 @@ TEST(config_save_atomically_replaces_a_complete_generation) { char *td = cbm_mkdtemp(tmpdir); ASSERT_NOT_NULL(td); - char *old_cache = getenv("CBM_CACHE_DIR") - ? strdup(getenv("CBM_CACHE_DIR")) - : NULL; + char *old_cache = getenv("CBM_CACHE_DIR") ? strdup(getenv("CBM_CACHE_DIR")) : NULL; ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", td, 1), 0); cbm_ui_config_t old_generation = { @@ -104,10 +102,9 @@ TEST(config_save_atomically_replaces_a_complete_generation) { #ifdef _WIN32 wchar_t *wide_path = cbm_utf8_to_wide(path); ASSERT_NOT_NULL(wide_path); - HANDLE old_handle = CreateFileW( - wide_path, GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, - OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); + HANDLE old_handle = + CreateFileW(wide_path, GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); free(wide_path); ASSERT_TRUE(old_handle != INVALID_HANDLE_VALUE); #else @@ -124,14 +121,12 @@ TEST(config_save_atomically_replaces_a_complete_generation) { char old_bytes[512] = {0}; #ifdef _WIN32 DWORD old_length = 0; - ASSERT_TRUE(ReadFile(old_handle, old_bytes, - (DWORD)sizeof(old_bytes) - 1U, &old_length, - NULL) != 0); + ASSERT_TRUE(ReadFile(old_handle, old_bytes, (DWORD)sizeof(old_bytes) - 1U, &old_length, NULL) != + 0); ASSERT_GT(old_length, 0); ASSERT_TRUE(CloseHandle(old_handle) != 0); #else - size_t old_length = fread(old_bytes, 1, sizeof(old_bytes) - 1, - old_handle); + size_t old_length = fread(old_bytes, 1, sizeof(old_bytes) - 1, old_handle); ASSERT_GT(old_length, 0); ASSERT_EQ(fclose(old_handle), 0); #endif diff --git a/tests/test_version_cohort.c b/tests/test_version_cohort.c index 04133ad25..a1e791b4b 100644 --- a/tests/test_version_cohort.c +++ b/tests/test_version_cohort.c @@ -32,6 +32,10 @@ static const char VERSION_COHORT_BUILD_A[] = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; static const char VERSION_COHORT_BUILD_B[] = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +static const char VERSION_COHORT_CACHE_A[] = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +static const char VERSION_COHORT_CACHE_B[] = + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; typedef struct { char parent[VERSION_COHORT_TEST_PATH_CAP]; @@ -49,11 +53,11 @@ typedef struct { cbm_version_cohort_lease_t *lease; } version_cohort_mutation_wait_t; -static cbm_daemon_build_identity_t version_cohort_identity( - const char *version, const char *build) { +static cbm_daemon_build_identity_t version_cohort_identity(const char *version, const char *build) { cbm_daemon_build_identity_t identity = { .semantic_version = version, .build_fingerprint = build, + .cache_fingerprint = VERSION_COHORT_CACHE_A, .protocol_abi = 3, .store_abi = 11, .feature_abi = 7, @@ -61,33 +65,26 @@ static cbm_daemon_build_identity_t version_cohort_identity( return identity; } -static bool version_cohort_fixture_start(version_cohort_fixture_t *fixture, - const char *tag) { +static bool version_cohort_fixture_start(version_cohort_fixture_t *fixture, const char *tag) { memset(fixture, 0, sizeof(*fixture)); int written = snprintf(fixture->parent, sizeof(fixture->parent), "%s/cbm-version-cohort-%s-XXXXXX", cbm_tmpdir(), tag); - if (written <= 0 || written >= (int)sizeof(fixture->parent) || - !cbm_mkdtemp(fixture->parent)) { + if (written <= 0 || written >= (int)sizeof(fixture->parent) || !cbm_mkdtemp(fixture->parent)) { return false; } - fixture->endpoint = - cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture->parent); + fixture->endpoint = cbm_daemon_ipc_endpoint_new("0123456789abcdef", fixture->parent); return fixture->endpoint != NULL; } static void version_cohort_release(cbm_version_cohort_lease_t **lease) { - while (lease && *lease && - cbm_version_cohort_lease_release(lease) != - CBM_PRIVATE_FILE_LOCK_OK) { + while (lease && *lease && cbm_version_cohort_lease_release(lease) != CBM_PRIVATE_FILE_LOCK_OK) { cbm_usleep(1000); } } -static void version_cohort_manager_close( - cbm_version_cohort_manager_t **manager) { +static void version_cohort_manager_close(cbm_version_cohort_manager_t **manager) { while (manager && *manager && - cbm_version_cohort_manager_free(manager) != - CBM_PRIVATE_FILE_LOCK_OK) { + cbm_version_cohort_manager_free(manager) != CBM_PRIVATE_FILE_LOCK_OK) { cbm_usleep(1000); } } @@ -100,9 +97,9 @@ static void version_cohort_fixture_finish(version_cohort_fixture_t *fixture) { memset(fixture, 0, sizeof(*fixture)); } -static void version_cohort_mutation_wait_init( - version_cohort_mutation_wait_t *wait, - cbm_version_cohort_manager_t *manager, uint64_t deadline_ms) { +static void version_cohort_mutation_wait_init(version_cohort_mutation_wait_t *wait, + cbm_version_cohort_manager_t *manager, + uint64_t deadline_ms) { memset(wait, 0, sizeof(*wait)); wait->manager = manager; wait->deadline_ms = deadline_ms; @@ -113,11 +110,9 @@ static void version_cohort_mutation_wait_init( atomic_init(&wait->finished, false); } -static cbm_version_cohort_quiesce_result_t -version_cohort_test_request_quiesce(void *context) { +static cbm_version_cohort_quiesce_result_t version_cohort_test_request_quiesce(void *context) { version_cohort_mutation_wait_t *wait = context; - (void)atomic_fetch_add_explicit(&wait->callback_count, 1, - memory_order_relaxed); + (void)atomic_fetch_add_explicit(&wait->callback_count, 1, memory_order_relaxed); atomic_store_explicit(&wait->callback_seen, true, memory_order_release); return CBM_VERSION_COHORT_QUIESCE_REQUESTED; } @@ -125,16 +120,14 @@ version_cohort_test_request_quiesce(void *context) { static void *version_cohort_mutation_wait_thread(void *context) { version_cohort_mutation_wait_t *wait = context; wait->status = cbm_version_cohort_reserve_for_mutation( - wait->manager, wait->deadline_ms, version_cohort_test_request_quiesce, - wait, &wait->quiesce_result, &wait->lease); + wait->manager, wait->deadline_ms, version_cohort_test_request_quiesce, wait, + &wait->quiesce_result, &wait->lease); atomic_store_explicit(&wait->finished, true, memory_order_release); return NULL; } -static bool version_cohort_wait_for_atomic(atomic_bool *value, - uint64_t deadline_ms) { - while (!atomic_load_explicit(value, memory_order_acquire) && - cbm_now_ms() < deadline_ms) { +static bool version_cohort_wait_for_atomic(atomic_bool *value, uint64_t deadline_ms) { + while (!atomic_load_explicit(value, memory_order_acquire) && cbm_now_ms() < deadline_ms) { cbm_usleep(1000); } return atomic_load_explicit(value, memory_order_acquire); @@ -143,32 +136,25 @@ static bool version_cohort_wait_for_atomic(atomic_bool *value, TEST(version_cohort_shares_exact_build_rejects_conflict_and_turns_over) { version_cohort_fixture_t fixture; ASSERT_TRUE(version_cohort_fixture_start(&fixture, "matrix")); - cbm_version_cohort_manager_t *first = - cbm_version_cohort_manager_new(fixture.endpoint); - cbm_version_cohort_manager_t *second = - cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *first = cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *second = cbm_version_cohort_manager_new(fixture.endpoint); ASSERT_NOT_NULL(first); ASSERT_NOT_NULL(second); - cbm_daemon_build_identity_t build_a = - version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); - cbm_daemon_build_identity_t build_b = - version_cohort_identity("2.5.0", VERSION_COHORT_BUILD_B); + cbm_daemon_build_identity_t build_a = version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_daemon_build_identity_t build_b = version_cohort_identity("2.5.0", VERSION_COHORT_BUILD_B); cbm_version_cohort_lease_t *a_first = NULL; cbm_version_cohort_lease_t *a_second = NULL; cbm_version_cohort_lease_t *b_lease = NULL; cbm_daemon_conflict_t conflict; - ASSERT_EQ(cbm_version_cohort_acquire(first, &build_a, UINT64_MAX, - &a_first, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(first, &build_a, UINT64_MAX, &a_first, &conflict), CBM_VERSION_COHORT_OK); ASSERT_NOT_NULL(a_first); - ASSERT_EQ(cbm_version_cohort_acquire(second, &build_a, UINT64_MAX, - &a_second, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(second, &build_a, UINT64_MAX, &a_second, &conflict), CBM_VERSION_COHORT_OK); ASSERT_NOT_NULL(a_second); - ASSERT_EQ(cbm_version_cohort_acquire(second, &build_b, cbm_now_ms(), - &b_lease, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(second, &build_b, cbm_now_ms(), &b_lease, &conflict), CBM_VERSION_COHORT_CONFLICT); ASSERT_NULL(b_lease); ASSERT_EQ(conflict.status, CBM_DAEMON_HELLO_VERSION_CONFLICT); @@ -177,8 +163,7 @@ TEST(version_cohort_shares_exact_build_rejects_conflict_and_turns_over) { version_cohort_release(&a_second); version_cohort_release(&a_first); - ASSERT_EQ(cbm_version_cohort_acquire(second, &build_b, UINT64_MAX, - &b_lease, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(second, &build_b, UINT64_MAX, &b_lease, &conflict), CBM_VERSION_COHORT_OK); ASSERT_NOT_NULL(b_lease); @@ -192,26 +177,22 @@ TEST(version_cohort_shares_exact_build_rejects_conflict_and_turns_over) { TEST(version_cohort_rejects_same_hash_with_different_abi) { version_cohort_fixture_t fixture; ASSERT_TRUE(version_cohort_fixture_start(&fixture, "abi")); - cbm_version_cohort_manager_t *first = - cbm_version_cohort_manager_new(fixture.endpoint); - cbm_version_cohort_manager_t *second = - cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *first = cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *second = cbm_version_cohort_manager_new(fixture.endpoint); ASSERT_NOT_NULL(first); ASSERT_NOT_NULL(second); - cbm_daemon_build_identity_t active = - version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_daemon_build_identity_t active = version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); cbm_daemon_build_identity_t requested = active; requested.feature_abi++; cbm_version_cohort_lease_t *active_lease = NULL; cbm_version_cohort_lease_t *requested_lease = NULL; cbm_daemon_conflict_t conflict; - ASSERT_EQ(cbm_version_cohort_acquire(first, &active, UINT64_MAX, - &active_lease, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(first, &active, UINT64_MAX, &active_lease, &conflict), CBM_VERSION_COHORT_OK); - ASSERT_EQ(cbm_version_cohort_acquire(second, &requested, cbm_now_ms(), - &requested_lease, &conflict), - CBM_VERSION_COHORT_CONFLICT); + ASSERT_EQ( + cbm_version_cohort_acquire(second, &requested, cbm_now_ms(), &requested_lease, &conflict), + CBM_VERSION_COHORT_CONFLICT); ASSERT_NULL(requested_lease); ASSERT_EQ(conflict.status, CBM_DAEMON_HELLO_FEATURE_ABI_CONFLICT); @@ -222,6 +203,68 @@ TEST(version_cohort_rejects_same_hash_with_different_abi) { PASS(); } +/* A cohort identity without a canonical cache fingerprint would reintroduce + * an unscoped namespace that can silently share with another cache-less + * process. Cohort admission must fail closed even though the stable daemon + * HELLO envelope intentionally remains cache-agnostic. */ +TEST(version_cohort_rejects_missing_cache_fingerprint) { + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "missing-cache")); + cbm_version_cohort_manager_t *manager = cbm_version_cohort_manager_new(fixture.endpoint); + ASSERT_NOT_NULL(manager); + cbm_daemon_build_identity_t identity = version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + identity.cache_fingerprint = NULL; + cbm_version_cohort_lease_t *lease = NULL; + cbm_daemon_conflict_t conflict; + + ASSERT_EQ(cbm_version_cohort_acquire(manager, &identity, cbm_now_ms(), &lease, &conflict), + CBM_VERSION_COHORT_UNSAFE); + ASSERT_NULL(lease); + + version_cohort_manager_close(&manager); + version_cohort_fixture_finish(&fixture); + PASS(); +} + +/* One account has one daemon and therefore one canonical cache generation. + * A second exact-build process with another cache root must fail before it can + * join lifetime ownership or request activation against the wrong storage. */ +TEST(version_cohort_rejects_exact_build_with_different_cache_root) { + version_cohort_fixture_t fixture; + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "cache-root")); + cbm_version_cohort_manager_t *first = cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *second = cbm_version_cohort_manager_new(fixture.endpoint); + ASSERT_NOT_NULL(first); + ASSERT_NOT_NULL(second); + + cbm_daemon_build_identity_t active = version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_daemon_build_identity_t requested = active; + active.cache_fingerprint = VERSION_COHORT_CACHE_A; + requested.cache_fingerprint = VERSION_COHORT_CACHE_B; + cbm_version_cohort_lease_t *active_lease = NULL; + cbm_version_cohort_lease_t *requested_lease = NULL; + cbm_daemon_conflict_t conflict; + + ASSERT_EQ(cbm_version_cohort_acquire(first, &active, UINT64_MAX, &active_lease, &conflict), + CBM_VERSION_COHORT_OK); + ASSERT_EQ( + cbm_version_cohort_acquire(second, &requested, cbm_now_ms(), &requested_lease, &conflict), + CBM_VERSION_COHORT_CONFLICT); + ASSERT_NULL(requested_lease); + ASSERT_EQ(conflict.status, CBM_DAEMON_HELLO_CACHE_CONFLICT); + ASSERT_STR_EQ(conflict.active_cache_fingerprint, VERSION_COHORT_CACHE_A); + ASSERT_STR_EQ(conflict.requested_cache_fingerprint, VERSION_COHORT_CACHE_B); + char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; + ASSERT_TRUE(cbm_daemon_conflict_format(&conflict, message, sizeof(message))); + ASSERT_NOT_NULL(strstr(message, "cache")); + + version_cohort_release(&active_lease); + version_cohort_manager_close(&second); + version_cohort_manager_close(&first); + version_cohort_fixture_finish(&fixture); + PASS(); +} + TEST(version_cohort_exclusive_activation_blocks_and_is_blocked_by_participants) { version_cohort_fixture_t fixture; ASSERT_TRUE(version_cohort_fixture_start(&fixture, "activation")); @@ -231,33 +274,30 @@ TEST(version_cohort_exclusive_activation_blocks_and_is_blocked_by_participants) cbm_version_cohort_manager_new(fixture.endpoint); ASSERT_NOT_NULL(participant_manager); ASSERT_NOT_NULL(activation_manager); - cbm_daemon_build_identity_t build_a = - version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_daemon_build_identity_t build_a = version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); cbm_version_cohort_lease_t *participant = NULL; cbm_version_cohort_lease_t *activation = NULL; cbm_daemon_conflict_t conflict; - ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, - UINT64_MAX, &participant, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, UINT64_MAX, &participant, + &conflict), CBM_VERSION_COHORT_OK); - ASSERT_EQ(cbm_version_cohort_reserve_exclusive( - activation_manager, cbm_now_ms(), &activation), + ASSERT_EQ(cbm_version_cohort_reserve_exclusive(activation_manager, cbm_now_ms(), &activation), CBM_VERSION_COHORT_BUSY); ASSERT_NULL(activation); version_cohort_release(&participant); - ASSERT_EQ(cbm_version_cohort_reserve_exclusive( - activation_manager, UINT64_MAX, &activation), + ASSERT_EQ(cbm_version_cohort_reserve_exclusive(activation_manager, UINT64_MAX, &activation), CBM_VERSION_COHORT_OK); ASSERT_NOT_NULL(activation); - ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, - cbm_now_ms(), &participant, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, cbm_now_ms(), &participant, + &conflict), CBM_VERSION_COHORT_BUSY); ASSERT_NULL(participant); version_cohort_release(&activation); - ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, - UINT64_MAX, &participant, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, UINT64_MAX, &participant, + &conflict), CBM_VERSION_COHORT_OK); version_cohort_release(&participant); version_cohort_manager_close(&activation_manager); @@ -279,64 +319,51 @@ TEST(version_cohort_mutation_intent_fails_new_admission_and_spans_lease) { ASSERT_NOT_NULL(mutation_manager); ASSERT_NOT_NULL(contender_manager); - cbm_daemon_build_identity_t build_a = - version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_daemon_build_identity_t build_a = version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); cbm_daemon_conflict_t conflict; cbm_version_cohort_lease_t *participant = NULL; cbm_version_cohort_lease_t *contender = NULL; - ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, - UINT64_MAX, &participant, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, UINT64_MAX, &participant, + &conflict), CBM_VERSION_COHORT_OK); cbm_version_cohort_maintenance_presence_t before = cbm_version_cohort_maintenance_presence(contender_manager); version_cohort_mutation_wait_t wait; - version_cohort_mutation_wait_init(&wait, mutation_manager, - cbm_now_ms() + 5000U); + version_cohort_mutation_wait_init(&wait, mutation_manager, cbm_now_ms() + 5000U); cbm_thread_t thread; - bool started = cbm_thread_create( - &thread, 0, version_cohort_mutation_wait_thread, - &wait) == 0; + bool started = cbm_thread_create(&thread, 0, version_cohort_mutation_wait_thread, &wait) == 0; bool callback_seen = - started && version_cohort_wait_for_atomic(&wait.callback_seen, - cbm_now_ms() + 2000U); + started && version_cohort_wait_for_atomic(&wait.callback_seen, cbm_now_ms() + 2000U); cbm_version_cohort_maintenance_presence_t during = - callback_seen - ? cbm_version_cohort_maintenance_presence(contender_manager) - : CBM_VERSION_COHORT_MAINTENANCE_IO; + callback_seen ? cbm_version_cohort_maintenance_presence(contender_manager) + : CBM_VERSION_COHORT_MAINTENANCE_IO; cbm_version_cohort_status_t racing_status = - callback_seen - ? cbm_version_cohort_acquire(contender_manager, &build_a, - UINT64_MAX, &contender, &conflict) - : CBM_VERSION_COHORT_IO; + callback_seen ? cbm_version_cohort_acquire(contender_manager, &build_a, UINT64_MAX, + &contender, &conflict) + : CBM_VERSION_COHORT_IO; bool racing_lease_absent = contender == NULL; bool still_draining = - callback_seen && - !atomic_load_explicit(&wait.finished, memory_order_acquire); + callback_seen && !atomic_load_explicit(&wait.finished, memory_order_acquire); version_cohort_release(&contender); version_cohort_release(&participant); - bool finished = - started && version_cohort_wait_for_atomic(&wait.finished, - cbm_now_ms() + 5500U); + bool finished = started && version_cohort_wait_for_atomic(&wait.finished, cbm_now_ms() + 5500U); bool joined = started && cbm_thread_join(&thread) == 0; cbm_version_cohort_maintenance_presence_t retained = - finished && wait.lease - ? cbm_version_cohort_maintenance_presence(contender_manager) - : CBM_VERSION_COHORT_MAINTENANCE_IO; + finished && wait.lease ? cbm_version_cohort_maintenance_presence(contender_manager) + : CBM_VERSION_COHORT_MAINTENANCE_IO; cbm_version_cohort_status_t retained_admission_status = - finished && wait.lease - ? cbm_version_cohort_acquire(contender_manager, &build_a, - UINT64_MAX, &contender, &conflict) - : CBM_VERSION_COHORT_IO; + finished && wait.lease ? cbm_version_cohort_acquire(contender_manager, &build_a, UINT64_MAX, + &contender, &conflict) + : CBM_VERSION_COHORT_IO; bool retained_admission_absent = contender == NULL; version_cohort_release(&contender); version_cohort_release(&wait.lease); cbm_version_cohort_maintenance_presence_t after = cbm_version_cohort_maintenance_presence(contender_manager); cbm_version_cohort_status_t post_status = - cbm_version_cohort_acquire(contender_manager, &build_a, UINT64_MAX, - &contender, &conflict); + cbm_version_cohort_acquire(contender_manager, &build_a, UINT64_MAX, &contender, &conflict); version_cohort_release(&contender); version_cohort_manager_close(&contender_manager); @@ -354,11 +381,8 @@ TEST(version_cohort_mutation_intent_fails_new_admission_and_spans_lease) { ASSERT_TRUE(finished); ASSERT_TRUE(joined); ASSERT_EQ(wait.status, CBM_VERSION_COHORT_OK); - ASSERT_EQ(wait.quiesce_result, - CBM_VERSION_COHORT_QUIESCE_REQUESTED); - ASSERT_EQ(atomic_load_explicit(&wait.callback_count, - memory_order_relaxed), - 1); + ASSERT_EQ(wait.quiesce_result, CBM_VERSION_COHORT_QUIESCE_REQUESTED); + ASSERT_EQ(atomic_load_explicit(&wait.callback_count, memory_order_relaxed), 1); ASSERT_EQ(retained, CBM_VERSION_COHORT_MAINTENANCE_REQUESTED); ASSERT_EQ(retained_admission_status, CBM_VERSION_COHORT_BUSY); ASSERT_TRUE(retained_admission_absent); @@ -370,46 +394,34 @@ TEST(version_cohort_mutation_intent_fails_new_admission_and_spans_lease) { TEST(version_cohort_mutation_waits_for_every_lifetime_participant) { version_cohort_fixture_t fixture; ASSERT_TRUE(version_cohort_fixture_start(&fixture, "mutation-drain")); - cbm_version_cohort_manager_t *first_manager = - cbm_version_cohort_manager_new(fixture.endpoint); - cbm_version_cohort_manager_t *second_manager = - cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *first_manager = cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *second_manager = cbm_version_cohort_manager_new(fixture.endpoint); cbm_version_cohort_manager_t *mutation_manager = cbm_version_cohort_manager_new(fixture.endpoint); ASSERT_NOT_NULL(first_manager); ASSERT_NOT_NULL(second_manager); ASSERT_NOT_NULL(mutation_manager); - cbm_daemon_build_identity_t build_a = - version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_daemon_build_identity_t build_a = version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); cbm_daemon_conflict_t conflict; cbm_version_cohort_lease_t *first = NULL; cbm_version_cohort_lease_t *second = NULL; - ASSERT_EQ(cbm_version_cohort_acquire(first_manager, &build_a, UINT64_MAX, - &first, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(first_manager, &build_a, UINT64_MAX, &first, &conflict), CBM_VERSION_COHORT_OK); - ASSERT_EQ(cbm_version_cohort_acquire(second_manager, &build_a, - UINT64_MAX, &second, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(second_manager, &build_a, UINT64_MAX, &second, &conflict), CBM_VERSION_COHORT_OK); version_cohort_mutation_wait_t wait; - version_cohort_mutation_wait_init(&wait, mutation_manager, - cbm_now_ms() + 5000U); + version_cohort_mutation_wait_init(&wait, mutation_manager, cbm_now_ms() + 5000U); cbm_thread_t thread; - bool started = cbm_thread_create( - &thread, 0, version_cohort_mutation_wait_thread, - &wait) == 0; + bool started = cbm_thread_create(&thread, 0, version_cohort_mutation_wait_thread, &wait) == 0; bool callback_seen = - started && version_cohort_wait_for_atomic(&wait.callback_seen, - cbm_now_ms() + 2000U); + started && version_cohort_wait_for_atomic(&wait.callback_seen, cbm_now_ms() + 2000U); version_cohort_release(&first); cbm_usleep(20000); - bool finished_after_one = - atomic_load_explicit(&wait.finished, memory_order_acquire); + bool finished_after_one = atomic_load_explicit(&wait.finished, memory_order_acquire); version_cohort_release(&second); - bool finished = - started && version_cohort_wait_for_atomic(&wait.finished, - cbm_now_ms() + 5500U); + bool finished = started && version_cohort_wait_for_atomic(&wait.finished, cbm_now_ms() + 5500U); bool joined = started && cbm_thread_join(&thread) == 0; version_cohort_release(&wait.lease); @@ -424,11 +436,8 @@ TEST(version_cohort_mutation_waits_for_every_lifetime_participant) { ASSERT_TRUE(finished); ASSERT_TRUE(joined); ASSERT_EQ(wait.status, CBM_VERSION_COHORT_OK); - ASSERT_EQ(wait.quiesce_result, - CBM_VERSION_COHORT_QUIESCE_REQUESTED); - ASSERT_EQ(atomic_load_explicit(&wait.callback_count, - memory_order_relaxed), - 1); + ASSERT_EQ(wait.quiesce_result, CBM_VERSION_COHORT_QUIESCE_REQUESTED); + ASSERT_EQ(atomic_load_explicit(&wait.callback_count, memory_order_relaxed), 1); PASS(); } @@ -439,60 +448,48 @@ TEST(version_cohort_mutation_timeout_releases_all_guards) { cbm_version_cohort_manager_new(fixture.endpoint); cbm_version_cohort_manager_t *mutation_manager = cbm_version_cohort_manager_new(fixture.endpoint); - cbm_version_cohort_manager_t *probe_manager = - cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *probe_manager = cbm_version_cohort_manager_new(fixture.endpoint); ASSERT_NOT_NULL(participant_manager); ASSERT_NOT_NULL(mutation_manager); ASSERT_NOT_NULL(probe_manager); - cbm_daemon_build_identity_t build_a = - version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_daemon_build_identity_t build_a = version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); cbm_daemon_conflict_t conflict; cbm_version_cohort_lease_t *participant = NULL; cbm_version_cohort_lease_t *mutation = NULL; cbm_version_cohort_lease_t *probe = NULL; - ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, - UINT64_MAX, &participant, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(participant_manager, &build_a, UINT64_MAX, &participant, + &conflict), CBM_VERSION_COHORT_OK); version_cohort_mutation_wait_t callback; - version_cohort_mutation_wait_init(&callback, mutation_manager, - cbm_now_ms() + 25U); - cbm_version_cohort_quiesce_result_t quiesce_result = - CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; - cbm_version_cohort_status_t timeout_status = - cbm_version_cohort_reserve_for_mutation( - mutation_manager, callback.deadline_ms, - version_cohort_test_request_quiesce, &callback, &quiesce_result, - &mutation); + version_cohort_mutation_wait_init(&callback, mutation_manager, cbm_now_ms() + 25U); + cbm_version_cohort_quiesce_result_t quiesce_result = CBM_VERSION_COHORT_QUIESCE_NOT_NEEDED; + cbm_version_cohort_status_t timeout_status = cbm_version_cohort_reserve_for_mutation( + mutation_manager, callback.deadline_ms, version_cohort_test_request_quiesce, &callback, + &quiesce_result, &mutation); bool no_mutation_authority = mutation == NULL; cbm_version_cohort_maintenance_presence_t after_timeout = cbm_version_cohort_maintenance_presence(probe_manager); cbm_version_cohort_status_t admission_status = - cbm_version_cohort_acquire(probe_manager, &build_a, UINT64_MAX, - &probe, &conflict); + cbm_version_cohort_acquire(probe_manager, &build_a, UINT64_MAX, &probe, &conflict); version_cohort_release(&probe); version_cohort_release(&participant); - cbm_version_cohort_quiesce_result_t invalid_result = - CBM_VERSION_COHORT_QUIESCE_REQUESTED; - cbm_version_cohort_status_t unbounded_status = - cbm_version_cohort_reserve_for_mutation( - mutation_manager, UINT64_MAX, version_cohort_test_request_quiesce, - &callback, &invalid_result, &mutation); + cbm_version_cohort_quiesce_result_t invalid_result = CBM_VERSION_COHORT_QUIESCE_REQUESTED; + cbm_version_cohort_status_t unbounded_status = cbm_version_cohort_reserve_for_mutation( + mutation_manager, UINT64_MAX, version_cohort_test_request_quiesce, &callback, + &invalid_result, &mutation); bool unbounded_lease_absent = mutation == NULL; - cbm_version_cohort_quiesce_result_t retry_result = - CBM_VERSION_COHORT_QUIESCE_REQUESTED; - cbm_version_cohort_status_t retry_status = - cbm_version_cohort_reserve_for_mutation( - mutation_manager, cbm_now_ms() + 250U, - version_cohort_test_request_quiesce, &callback, &retry_result, - &mutation); + cbm_version_cohort_quiesce_result_t retry_result = CBM_VERSION_COHORT_QUIESCE_REQUESTED; + cbm_version_cohort_status_t retry_status = cbm_version_cohort_reserve_for_mutation( + mutation_manager, cbm_now_ms() + 250U, version_cohort_test_request_quiesce, &callback, + &retry_result, &mutation); cbm_version_cohort_maintenance_presence_t during_retry = mutation ? cbm_version_cohort_maintenance_presence(probe_manager) : CBM_VERSION_COHORT_MAINTENANCE_IO; - int callback_count_after_retry = atomic_load_explicit( - &callback.callback_count, memory_order_relaxed); + int callback_count_after_retry = + atomic_load_explicit(&callback.callback_count, memory_order_relaxed); version_cohort_release(&mutation); cbm_version_cohort_maintenance_presence_t after_retry = @@ -505,9 +502,7 @@ TEST(version_cohort_mutation_timeout_releases_all_guards) { ASSERT_EQ(timeout_status, CBM_VERSION_COHORT_BUSY); ASSERT_TRUE(no_mutation_authority); ASSERT_EQ(quiesce_result, CBM_VERSION_COHORT_QUIESCE_REQUESTED); - ASSERT_EQ(atomic_load_explicit(&callback.callback_count, - memory_order_relaxed), - 1); + ASSERT_EQ(atomic_load_explicit(&callback.callback_count, memory_order_relaxed), 1); ASSERT_EQ(after_timeout, CBM_VERSION_COHORT_MAINTENANCE_ABSENT); ASSERT_EQ(admission_status, CBM_VERSION_COHORT_OK); ASSERT_EQ(unbounded_status, CBM_VERSION_COHORT_UNSAFE); @@ -525,19 +520,14 @@ TEST(version_cohort_does_not_repurpose_daemon_startup_lock_for_lifetime) { version_cohort_fixture_t fixture; ASSERT_TRUE(version_cohort_fixture_start(&fixture, "startup-independent")); cbm_daemon_ipc_startup_lock_t *startup = NULL; - ASSERT_EQ(cbm_daemon_ipc_startup_lock_try_acquire(fixture.endpoint, - &startup), - 1); + ASSERT_EQ(cbm_daemon_ipc_startup_lock_try_acquire(fixture.endpoint, &startup), 1); ASSERT_NOT_NULL(startup); - cbm_version_cohort_manager_t *manager = - cbm_version_cohort_manager_new(fixture.endpoint); - cbm_daemon_build_identity_t build_a = - version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); + cbm_version_cohort_manager_t *manager = cbm_version_cohort_manager_new(fixture.endpoint); + cbm_daemon_build_identity_t build_a = version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); cbm_version_cohort_lease_t *lease = NULL; cbm_daemon_conflict_t conflict; ASSERT_NOT_NULL(manager); - ASSERT_EQ(cbm_version_cohort_acquire(manager, &build_a, UINT64_MAX, - &lease, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(manager, &build_a, UINT64_MAX, &lease, &conflict), CBM_VERSION_COHORT_OK); ASSERT_NOT_NULL(lease); @@ -551,8 +541,7 @@ TEST(version_cohort_does_not_repurpose_daemon_startup_lock_for_lifetime) { TEST(version_cohort_distinguishes_coordinated_daemon_without_connecting) { version_cohort_fixture_t fixture; ASSERT_TRUE(version_cohort_fixture_start(&fixture, "daemon-marker")); - cbm_version_cohort_manager_t *manager = - cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *manager = cbm_version_cohort_manager_new(fixture.endpoint); cbm_version_cohort_daemon_claim_t *claim = NULL; cbm_daemon_ipc_lifetime_reservation_t *lifetime = NULL; cbm_daemon_ipc_startup_lock_t *startup = NULL; @@ -564,9 +553,7 @@ TEST(version_cohort_distinguishes_coordinated_daemon_without_connecting) { /* Startup is also part of the migration boundary: on POSIX the legacy and * current startup locks are the same; on Windows current startup retains * the security-validated legacy mutex as an interlock. */ - ASSERT_EQ(cbm_daemon_ipc_startup_lock_try_acquire(fixture.endpoint, - &startup), - 1); + ASSERT_EQ(cbm_daemon_ipc_startup_lock_try_acquire(fixture.endpoint, &startup), 1); ASSERT_NOT_NULL(startup); ASSERT_EQ(cbm_version_cohort_daemon_presence(manager, fixture.endpoint), CBM_VERSION_COHORT_DAEMON_UNCOORDINATED); @@ -578,15 +565,12 @@ TEST(version_cohort_distinguishes_coordinated_daemon_without_connecting) { /* A pre-cohort daemon owns the stable daemon lifetime reservation but * cannot own the new crash-released coordination marker. The local CLI * must fail closed without opening a protocol connection. */ - ASSERT_EQ(cbm_daemon_ipc_lifetime_reservation_try_acquire( - fixture.endpoint, &lifetime), - 1); + ASSERT_EQ(cbm_daemon_ipc_lifetime_reservation_try_acquire(fixture.endpoint, &lifetime), 1); ASSERT_NOT_NULL(lifetime); ASSERT_EQ(cbm_version_cohort_daemon_presence(manager, fixture.endpoint), CBM_VERSION_COHORT_DAEMON_UNCOORDINATED); - ASSERT_EQ(cbm_version_cohort_daemon_claim_acquire(manager, &claim), - CBM_VERSION_COHORT_OK); + ASSERT_EQ(cbm_version_cohort_daemon_claim_acquire(manager, &claim), CBM_VERSION_COHORT_OK); ASSERT_NOT_NULL(claim); ASSERT_EQ(cbm_version_cohort_daemon_presence(manager, fixture.endpoint), CBM_VERSION_COHORT_DAEMON_COORDINATED); @@ -600,8 +584,7 @@ TEST(version_cohort_distinguishes_coordinated_daemon_without_connecting) { ASSERT_EQ(cbm_version_cohort_daemon_presence(manager, fixture.endpoint), CBM_VERSION_COHORT_DAEMON_COORDINATED); - ASSERT_EQ(cbm_version_cohort_daemon_claim_release(&claim), - CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(cbm_version_cohort_daemon_claim_release(&claim), CBM_PRIVATE_FILE_LOCK_OK); ASSERT_NULL(claim); ASSERT_EQ(cbm_version_cohort_daemon_presence(manager, fixture.endpoint), CBM_VERSION_COHORT_DAEMON_ABSENT); @@ -613,51 +596,41 @@ TEST(version_cohort_distinguishes_coordinated_daemon_without_connecting) { TEST(version_cohort_transition_presence_is_authoritative_and_marker_checked) { version_cohort_fixture_t fixture; - ASSERT_TRUE(version_cohort_fixture_start(&fixture, - "transition-presence")); - cbm_version_cohort_manager_t *manager = - cbm_version_cohort_manager_new(fixture.endpoint); + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "transition-presence")); + cbm_version_cohort_manager_t *manager = cbm_version_cohort_manager_new(fixture.endpoint); cbm_daemon_ipc_local_transition_t *transition = NULL; cbm_daemon_ipc_lifetime_reservation_t *lifetime = NULL; cbm_version_cohort_daemon_claim_t *claim = NULL; ASSERT_NOT_NULL(manager); - ASSERT_EQ(cbm_daemon_ipc_local_transition_try_acquire( - fixture.endpoint, &transition), - 1); + ASSERT_EQ(cbm_daemon_ipc_local_transition_try_acquire(fixture.endpoint, &transition), 1); ASSERT_NOT_NULL(transition); - ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( - manager, fixture.endpoint, transition), - CBM_VERSION_COHORT_DAEMON_UNSAFE); + ASSERT_EQ( + cbm_version_cohort_daemon_presence_under_transition(manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_UNSAFE); ASSERT_EQ(cbm_daemon_ipc_local_transition_seal_legacy(transition), 1); - ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( - manager, fixture.endpoint, transition), - CBM_VERSION_COHORT_DAEMON_ABSENT); + ASSERT_EQ( + cbm_version_cohort_daemon_presence_under_transition(manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_ABSENT); ASSERT_TRUE(cbm_daemon_ipc_local_transition_release(&transition)); ASSERT_NULL(transition); - ASSERT_EQ(cbm_daemon_ipc_lifetime_reservation_try_acquire( - fixture.endpoint, &lifetime), - 1); + ASSERT_EQ(cbm_daemon_ipc_lifetime_reservation_try_acquire(fixture.endpoint, &lifetime), 1); ASSERT_NOT_NULL(lifetime); - ASSERT_EQ(cbm_daemon_ipc_local_transition_try_acquire( - fixture.endpoint, &transition), - 1); + ASSERT_EQ(cbm_daemon_ipc_local_transition_try_acquire(fixture.endpoint, &transition), 1); ASSERT_NOT_NULL(transition); ASSERT_EQ(cbm_daemon_ipc_local_transition_seal_legacy(transition), 1); - ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( - manager, fixture.endpoint, transition), - CBM_VERSION_COHORT_DAEMON_UNCOORDINATED); + ASSERT_EQ( + cbm_version_cohort_daemon_presence_under_transition(manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_UNCOORDINATED); - ASSERT_EQ(cbm_version_cohort_daemon_claim_acquire(manager, &claim), - CBM_VERSION_COHORT_OK); + ASSERT_EQ(cbm_version_cohort_daemon_claim_acquire(manager, &claim), CBM_VERSION_COHORT_OK); ASSERT_NOT_NULL(claim); - ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( - manager, fixture.endpoint, transition), - CBM_VERSION_COHORT_DAEMON_COORDINATED); + ASSERT_EQ( + cbm_version_cohort_daemon_presence_under_transition(manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_COORDINATED); - ASSERT_EQ(cbm_version_cohort_daemon_claim_release(&claim), - CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ(cbm_version_cohort_daemon_claim_release(&claim), CBM_PRIVATE_FILE_LOCK_OK); ASSERT_NULL(claim); ASSERT_TRUE(cbm_daemon_ipc_local_transition_release(&transition)); ASSERT_NULL(transition); @@ -670,40 +643,32 @@ TEST(version_cohort_transition_presence_is_authoritative_and_marker_checked) { TEST(version_cohort_transition_shutdown_order_has_no_false_conflict) { version_cohort_fixture_t fixture; - ASSERT_TRUE(version_cohort_fixture_start(&fixture, - "transition-shutdown")); - cbm_version_cohort_manager_t *manager = - cbm_version_cohort_manager_new(fixture.endpoint); + ASSERT_TRUE(version_cohort_fixture_start(&fixture, "transition-shutdown")); + cbm_version_cohort_manager_t *manager = cbm_version_cohort_manager_new(fixture.endpoint); cbm_daemon_ipc_lifetime_reservation_t *lifetime = NULL; cbm_version_cohort_daemon_claim_t *claim = NULL; cbm_daemon_ipc_local_transition_t *transition = NULL; ASSERT_NOT_NULL(manager); - ASSERT_EQ(cbm_daemon_ipc_lifetime_reservation_try_acquire( - fixture.endpoint, &lifetime), - 1); - ASSERT_EQ(cbm_version_cohort_daemon_claim_acquire(manager, &claim), - CBM_VERSION_COHORT_OK); - ASSERT_EQ(cbm_daemon_ipc_local_transition_try_acquire( - fixture.endpoint, &transition), - 1); + ASSERT_EQ(cbm_daemon_ipc_lifetime_reservation_try_acquire(fixture.endpoint, &lifetime), 1); + ASSERT_EQ(cbm_version_cohort_daemon_claim_acquire(manager, &claim), CBM_VERSION_COHORT_OK); + ASSERT_EQ(cbm_daemon_ipc_local_transition_try_acquire(fixture.endpoint, &transition), 1); ASSERT_EQ(cbm_daemon_ipc_local_transition_seal_legacy(transition), 1); - ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( - manager, fixture.endpoint, transition), - CBM_VERSION_COHORT_DAEMON_COORDINATED); + ASSERT_EQ( + cbm_version_cohort_daemon_presence_under_transition(manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_COORDINATED); /* Host teardown closes listener/lifetime before its daemon marker. The * overlap is still coordinated, never the pre-cohort conflict state. */ cbm_daemon_ipc_lifetime_reservation_release(lifetime); lifetime = NULL; - ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( - manager, fixture.endpoint, transition), - CBM_VERSION_COHORT_DAEMON_COORDINATED); - ASSERT_EQ(cbm_version_cohort_daemon_claim_release(&claim), - CBM_PRIVATE_FILE_LOCK_OK); - ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( - manager, fixture.endpoint, transition), - CBM_VERSION_COHORT_DAEMON_ABSENT); + ASSERT_EQ( + cbm_version_cohort_daemon_presence_under_transition(manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_COORDINATED); + ASSERT_EQ(cbm_version_cohort_daemon_claim_release(&claim), CBM_PRIVATE_FILE_LOCK_OK); + ASSERT_EQ( + cbm_version_cohort_daemon_presence_under_transition(manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_ABSENT); ASSERT_TRUE(cbm_daemon_ipc_local_transition_release(&transition)); version_cohort_manager_close(&manager); @@ -722,8 +687,7 @@ TEST(version_cohort_presence_recovers_current_posix_listener_crash) { pid_t child = fork(); if (child == 0) { (void)close(ready_pipe[0]); - cbm_daemon_ipc_listener_t *listener = - cbm_daemon_ipc_listen(fixture.endpoint); + cbm_daemon_ipc_listener_t *listener = cbm_daemon_ipc_listen(fixture.endpoint); char ready = listener ? 'R' : 'E'; ssize_t reported = write(ready_pipe[1], &ready, 1); (void)close(ready_pipe[1]); @@ -744,18 +708,15 @@ TEST(version_cohort_presence_recovers_current_posix_listener_crash) { ASSERT_EQ(cbm_daemon_ipc_lifetime_reservation_probe(fixture.endpoint), 0); ASSERT_EQ(cbm_daemon_ipc_endpoint_probe(fixture.endpoint, 1), 1); - cbm_version_cohort_manager_t *manager = - cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *manager = cbm_version_cohort_manager_new(fixture.endpoint); cbm_daemon_ipc_local_transition_t *transition = NULL; ASSERT_NOT_NULL(manager); - ASSERT_EQ(cbm_daemon_ipc_local_transition_try_acquire( - fixture.endpoint, &transition), - 1); + ASSERT_EQ(cbm_daemon_ipc_local_transition_try_acquire(fixture.endpoint, &transition), 1); ASSERT_NOT_NULL(transition); ASSERT_EQ(cbm_daemon_ipc_local_transition_seal_legacy(transition), 1); - ASSERT_EQ(cbm_version_cohort_daemon_presence_under_transition( - manager, fixture.endpoint, transition), - CBM_VERSION_COHORT_DAEMON_ABSENT); + ASSERT_EQ( + cbm_version_cohort_daemon_presence_under_transition(manager, fixture.endpoint, transition), + CBM_VERSION_COHORT_DAEMON_ABSENT); ASSERT_EQ(cbm_daemon_ipc_endpoint_probe(fixture.endpoint, 1), 0); ASSERT_TRUE(cbm_daemon_ipc_local_transition_release(&transition)); @@ -773,17 +734,11 @@ TEST(version_cohort_crash_releases_process_lifetime_lease) { char ready_path[VERSION_COHORT_TEST_PATH_CAP]; char self[MAX_PATH]; DWORD self_length = GetModuleFileNameA(NULL, self, sizeof(self)); - int ready_length = snprintf(ready_path, sizeof(ready_path), "%s/ready", - fixture.parent); - bool launch_ready = self_length > 0 && self_length < sizeof(self) && - ready_length > 0 && + int ready_length = snprintf(ready_path, sizeof(ready_path), "%s/ready", fixture.parent); + bool launch_ready = self_length > 0 && self_length < sizeof(self) && ready_length > 0 && ready_length < (int)sizeof(ready_path); const char *const argv[] = { - self, - "__cbm_version_cohort_crash_holder", - "0123456789abcdef", - fixture.parent, - ready_path, + self, "__cbm_version_cohort_crash_holder", "0123456789abcdef", fixture.parent, ready_path, NULL, }; cbm_proc_opts_t options = { @@ -793,8 +748,7 @@ TEST(version_cohort_crash_releases_process_lifetime_lease) { .cancel_grace_ms = 1, }; cbm_subprocess_t *child = NULL; - int spawn_status = - launch_ready ? cbm_subprocess_spawn(&options, &child) : -1; + int spawn_status = launch_ready ? cbm_subprocess_spawn(&options, &child) : -1; bool ready = false; bool terminal = false; cbm_proc_result_t process_result = {0}; @@ -821,18 +775,15 @@ TEST(version_cohort_crash_releases_process_lifetime_lease) { cbm_version_cohort_manager_t *manager = ready ? cbm_version_cohort_manager_new(fixture.endpoint) : NULL; - cbm_daemon_build_identity_t build_b = - version_cohort_identity("2.5.0", VERSION_COHORT_BUILD_B); + cbm_daemon_build_identity_t build_b = version_cohort_identity("2.5.0", VERSION_COHORT_BUILD_B); cbm_version_cohort_lease_t *lease = NULL; cbm_daemon_conflict_t conflict; cbm_version_cohort_status_t conflict_status = - manager ? cbm_version_cohort_acquire(manager, &build_b, cbm_now_ms(), - &lease, &conflict) + manager ? cbm_version_cohort_acquire(manager, &build_b, cbm_now_ms(), &lease, &conflict) : CBM_VERSION_COHORT_IO; bool conflict_lease_absent = lease == NULL; version_cohort_release(&lease); - bool cancel_requested = - child && !terminal && cbm_subprocess_request_cancel(child); + bool cancel_requested = child && !terminal && cbm_subprocess_request_cancel(child); uint64_t terminal_deadline = cbm_now_ms() + 5000U; while (child && !terminal && cbm_now_ms() < terminal_deadline) { cbm_proc_poll_t poll = cbm_subprocess_poll(child, &process_result); @@ -847,8 +798,7 @@ TEST(version_cohort_crash_releases_process_lifetime_lease) { } cbm_version_cohort_status_t turnover_status = manager && terminal - ? cbm_version_cohort_acquire(manager, &build_b, UINT64_MAX, - &lease, &conflict) + ? cbm_version_cohort_acquire(manager, &build_b, UINT64_MAX, &lease, &conflict) : CBM_VERSION_COHORT_IO; version_cohort_release(&lease); @@ -884,16 +834,13 @@ TEST(version_cohort_crash_releases_process_lifetime_lease) { if (child == 0) { (void)close(ready_pipe[0]); (void)close(command_pipe[1]); - cbm_version_cohort_manager_t *manager = - cbm_version_cohort_manager_new(fixture.endpoint); + cbm_version_cohort_manager_t *manager = cbm_version_cohort_manager_new(fixture.endpoint); cbm_daemon_build_identity_t build_a = version_cohort_identity("2.4.0", VERSION_COHORT_BUILD_A); cbm_version_cohort_lease_t *lease = NULL; cbm_daemon_conflict_t conflict; - bool acquired = manager && - cbm_version_cohort_acquire( - manager, &build_a, UINT64_MAX, &lease, &conflict) == - CBM_VERSION_COHORT_OK; + bool acquired = manager && cbm_version_cohort_acquire(manager, &build_a, UINT64_MAX, &lease, + &conflict) == CBM_VERSION_COHORT_OK; char ready = acquired ? 'R' : 'E'; ssize_t ignored = write(ready_pipe[1], &ready, 1); (void)ignored; @@ -913,15 +860,12 @@ TEST(version_cohort_crash_releases_process_lifetime_lease) { ASSERT_EQ(ready, 'R'); ASSERT_GT(child, 0); - cbm_version_cohort_manager_t *manager = - cbm_version_cohort_manager_new(fixture.endpoint); - cbm_daemon_build_identity_t build_b = - version_cohort_identity("2.5.0", VERSION_COHORT_BUILD_B); + cbm_version_cohort_manager_t *manager = cbm_version_cohort_manager_new(fixture.endpoint); + cbm_daemon_build_identity_t build_b = version_cohort_identity("2.5.0", VERSION_COHORT_BUILD_B); cbm_version_cohort_lease_t *lease = NULL; cbm_daemon_conflict_t conflict; ASSERT_NOT_NULL(manager); - ASSERT_EQ(cbm_version_cohort_acquire(manager, &build_b, cbm_now_ms(), - &lease, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(manager, &build_b, cbm_now_ms(), &lease, &conflict), CBM_VERSION_COHORT_CONFLICT); ASSERT_NULL(lease); ASSERT_EQ(conflict.status, CBM_DAEMON_HELLO_VERSION_CONFLICT); @@ -934,8 +878,7 @@ TEST(version_cohort_crash_releases_process_lifetime_lease) { ASSERT_TRUE(WIFEXITED(child_status)); ASSERT_EQ(WEXITSTATUS(child_status), 0); - ASSERT_EQ(cbm_version_cohort_acquire(manager, &build_b, UINT64_MAX, - &lease, &conflict), + ASSERT_EQ(cbm_version_cohort_acquire(manager, &build_b, UINT64_MAX, &lease, &conflict), CBM_VERSION_COHORT_OK); version_cohort_release(&lease); version_cohort_manager_close(&manager); @@ -947,6 +890,8 @@ TEST(version_cohort_crash_releases_process_lifetime_lease) { SUITE(version_cohort) { RUN_TEST(version_cohort_shares_exact_build_rejects_conflict_and_turns_over); RUN_TEST(version_cohort_rejects_same_hash_with_different_abi); + RUN_TEST(version_cohort_rejects_missing_cache_fingerprint); + RUN_TEST(version_cohort_rejects_exact_build_with_different_cache_root); RUN_TEST(version_cohort_exclusive_activation_blocks_and_is_blocked_by_participants); RUN_TEST(version_cohort_mutation_intent_fails_new_admission_and_spans_lease); RUN_TEST(version_cohort_mutation_waits_for_every_lifetime_participant); diff --git a/tests/test_watcher.c b/tests/test_watcher.c index 2b07e31ca..f89b5a2a2 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -343,8 +343,7 @@ TEST(watcher_prune_waits_for_daemon_project_mutation) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); cbm_daemon_application_config_t config = {.watcher = w}; - cbm_daemon_application_t *application = - cbm_daemon_application_new(&config); + cbm_daemon_application_t *application = cbm_daemon_application_new(&config); if (!store || !w || !application) { cbm_daemon_application_free(application); cbm_watcher_free(w); @@ -358,8 +357,7 @@ TEST(watcher_prune_waits_for_daemon_project_mutation) { th_rmtree(f.rootdir); bool mutation_held = - cbm_daemon_application_project_mutation_try_begin(application, - "stale-project"); + cbm_daemon_application_project_mutation_try_begin(application, "stale-project"); for (int miss = 0; mutation_held && miss < 3; miss++) { cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); @@ -377,10 +375,8 @@ TEST(watcher_prune_waits_for_daemon_project_mutation) { cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); } - bool pruned_after_release = cbm_watcher_watch_count(w) == 0 && - access(f.db_path, F_OK) != 0 && - access(f.wal_path, F_OK) != 0 && - access(f.shm_path, F_OK) != 0; + bool pruned_after_release = cbm_watcher_watch_count(w) == 0 && access(f.db_path, F_OK) != 0 && + access(f.wal_path, F_OK) != 0 && access(f.shm_path, F_OK) != 0; cbm_daemon_application_free(application); cbm_watcher_free(w); @@ -402,9 +398,8 @@ TEST(watcher_prune_guard_denial_and_success_are_balanced) { cbm_store_t *store = cbm_store_open_memory(); cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); prune_guard_probe_t probe = {0}; - cbm_watcher_set_project_mutation_guard( - w, prune_guard_probe_begin, prune_guard_probe_end, - prune_guard_probe_pruned, &probe); + cbm_watcher_set_project_mutation_guard(w, prune_guard_probe_begin, prune_guard_probe_end, + prune_guard_probe_pruned, &probe); cbm_watcher_watch(w, "stale-project", f.rootdir); cbm_watcher_poll_once(w); th_rmtree(f.rootdir); @@ -412,16 +407,14 @@ TEST(watcher_prune_guard_denial_and_success_are_balanced) { cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); } - bool denied_preserved = cbm_watcher_watch_count(w) == 1 && - access(f.db_path, F_OK) == 0 && probe.begins == 1 && - probe.ends == 0 && probe.pruned == 0; + bool denied_preserved = cbm_watcher_watch_count(w) == 1 && access(f.db_path, F_OK) == 0 && + probe.begins == 1 && probe.ends == 0 && probe.pruned == 0; probe.allow = true; cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); - bool success_balanced = cbm_watcher_watch_count(w) == 0 && - access(f.db_path, F_OK) != 0 && probe.begins == 2 && - probe.ends == 1 && probe.pruned == 1; + bool success_balanced = cbm_watcher_watch_count(w) == 0 && access(f.db_path, F_OK) != 0 && + probe.begins == 2 && probe.ends == 1 && probe.pruned == 1; cbm_watcher_set_project_mutation_guard(w, NULL, NULL, NULL, NULL); cbm_watcher_free(w); @@ -444,9 +437,8 @@ TEST(watcher_prune_restats_root_after_guard_acquisition) { .allow = true, .restore_root = true, }; - cbm_watcher_set_project_mutation_guard( - w, prune_guard_probe_begin, prune_guard_probe_end, - prune_guard_probe_pruned, &probe); + cbm_watcher_set_project_mutation_guard(w, prune_guard_probe_begin, prune_guard_probe_end, + prune_guard_probe_pruned, &probe); cbm_watcher_watch(w, "stale-project", f.rootdir); cbm_watcher_poll_once(w); th_rmtree(f.rootdir); @@ -456,9 +448,8 @@ TEST(watcher_prune_restats_root_after_guard_acquisition) { } bool restored = access(f.rootdir, F_OK) == 0; - bool retained = cbm_watcher_watch_count(w) == 1 && - access(f.db_path, F_OK) == 0 && access(f.wal_path, F_OK) == 0 && - access(f.shm_path, F_OK) == 0; + bool retained = cbm_watcher_watch_count(w) == 1 && access(f.db_path, F_OK) == 0 && + access(f.wal_path, F_OK) == 0 && access(f.shm_path, F_OK) == 0; bool balanced = probe.begins == 1 && probe.ends == 1 && probe.pruned == 0; cbm_watcher_set_project_mutation_guard(w, NULL, NULL, NULL, NULL); cbm_watcher_free(w); @@ -484,15 +475,13 @@ TEST(watcher_prune_delete_failure_retains_watch_for_retry) { /* A directory at the main DB path makes unlink fail deterministically on * POSIX and Windows. The watcher must remain registered for a later retry * and must not delete recoverable sidecars after that failure. */ - bool failure_ready = cbm_unlink(f.db_path) == 0 && - cbm_mkdir_p(f.db_path, 0755); + bool failure_ready = cbm_unlink(f.db_path) == 0 && cbm_mkdir_p(f.db_path, 0755); for (int miss = 0; failure_ready && miss < 3; miss++) { cbm_watcher_touch(w, "stale-project"); cbm_watcher_poll_once(w); } bool watch_retained = cbm_watcher_watch_count(w) == 1; - bool artifacts_retained = access(f.db_path, F_OK) == 0 && - access(f.wal_path, F_OK) == 0 && + bool artifacts_retained = access(f.db_path, F_OK) == 0 && access(f.wal_path, F_OK) == 0 && access(f.shm_path, F_OK) == 0; cbm_watcher_free(w); @@ -2209,8 +2198,14 @@ TEST(watcher_callback_data_passed) { if (!cbm_mkdtemp(tmpdir)) FAIL("cbm_mkdtemp failed"); - if (wt_git(tmpdir, "init -q") != 0) { th_rmtree(tmpdir); FAIL("git init failed"); } - { char p[300]; th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); } + if (wt_git(tmpdir, "init -q") != 0) { + th_rmtree(tmpdir); + FAIL("git init failed"); + } + { + char p[300]; + th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n"); + } wt_git(tmpdir, "add file.txt"); wt_git(tmpdir, "commit -q -m init"); diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh new file mode 100644 index 000000000..5d69b511d --- /dev/null +++ b/tests/test_windows_bundle_contract.sh @@ -0,0 +1,544 @@ +#!/usr/bin/env bash +# Static release-surface contract for the Windows permanent launcher. +# +# This intentionally inspects the checked-in packaging sources. Native +# launcher behavior is covered separately on Windows; this guard makes it hard +# for a release/package edit to silently put the managed launcher where a +# portable wrapper expects the payload (or omit one half of the release pair). + +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +python3 - "$ROOT" <<'PY' +from __future__ import annotations + +import pathlib +import re +import sys + + +root = pathlib.Path(sys.argv[1]) +failures: list[str] = [] + + +def read(relative: str) -> str: + return (root / relative).read_text(encoding="utf-8") + + +def require(condition: bool, message: str) -> None: + if not condition: + failures.append(message) + + +def yaml_run_blocks(text: str) -> list[str]: + """Return literal/folded YAML run blocks without requiring PyYAML.""" + lines = text.splitlines() + blocks: list[str] = [] + index = 0 + while index < len(lines): + match = re.match(r"^(\s*)run:\s*[|>]", lines[index]) + if match is None: + index += 1 + continue + base_indent = len(match.group(1)) + block: list[str] = [] + index += 1 + while index < len(lines): + line = lines[index] + if line.strip() and len(line) - len(line.lstrip()) <= base_indent: + break + block.append(line) + index += 1 + blocks.append("\n".join(block)) + return blocks + + +launcher = "codebase-memory-mcp.exe" +payload = "codebase-memory-mcp.payload.exe" +windows_archive_names = ( + launcher, + payload, + "LICENSE", + "install.ps1", + "THIRD_PARTY_NOTICES.md", +) + +# Every Windows archive (standard/UI, x64/ARM64) is a five-file bundle containing +# the executable pair plus the three release/legal files. Check the producing run +# block itself so an attestation or comment cannot accidentally satisfy it. +build_workflow = read(".github/workflows/_build.yml") +build_blocks = yaml_run_blocks(build_workflow) +archives = ( + "codebase-memory-mcp-windows-amd64.zip", + "codebase-memory-mcp-ui-windows-amd64.zip", + "codebase-memory-mcp-windows-arm64.zip", + "codebase-memory-mcp-ui-windows-arm64.zip", +) +for archive in archives: + archive_blocks = [block for block in build_blocks if archive in block] + require(bool(archive_blocks), f"_build.yml has no run block producing {archive}") + require( + any(all(name in block for name in windows_archive_names) for block in archive_blocks), + f"{archive} must archive the exact official five-file Windows bundle", + ) + +# install.ps1 must preserve the complete adjacent pair and enter the native +# process through the downloaded launcher. The launcher owns containment and +# resolves the payload; invoking the payload directly bypasses that boundary. +installer = read("install.ps1") +require( + "$DownloadedLauncher = Join-Path $TmpDir $BinName" in installer + and "$DownloadedPayload = Join-Path $TmpDir $PayloadName" in installer + and "Test-Path -LiteralPath $DownloadedLauncher -PathType Leaf" in installer + and "Test-Path -LiteralPath $DownloadedPayload -PathType Leaf" in installer, + "install.ps1 must retain an adjacent downloaded launcher/payload pair", +) +require( + "& $DownloadedLauncher --version" in installer + and "& $DownloadedLauncher @InstallArgs" in installer + and "& $DownloadedPayload @InstallArgs" not in installer, + "install.ps1 must verify and install through the downloaded launcher", +) + +# Package-manager shims are intentionally one-shot portable instances on +# Windows. Their downloaders validate both release executables adjacently in a +# private temporary directory, cache the immutable pair for a later managed +# install transition, and execute the launcher. They must not create or +# interpret managed generation state themselves. +portable_cache_contracts = { + "pkg/npm/install.js": ( + r"const\s+WINDOWS_PAYLOAD_NAME\s*=\s*['\"]codebase-memory-mcp\.payload\.exe['\"]", + r"binName\s*=\s*platform\s*===\s*['\"]windows['\"]\s*\?\s*WINDOWS_PAYLOAD_NAME", + ), + "pkg/npm/bin.js": ( + r"binName\s*=\s*isWindows\s*\?\s*['\"]codebase-memory-mcp\.payload\.exe['\"]", + ), + "pkg/pypi/src/codebase_memory_mcp/_cli.py": ( + r"_WINDOWS_PAYLOAD_NAME\s*=\s*['\"]codebase-memory-mcp\.payload\.exe['\"]", + r"def\s+_bin_path[\s\S]{0,500}_WINDOWS_PAYLOAD_NAME\s+if\s+sys\.platform\s*==\s*['\"]win32['\"]", + ), + "pkg/go/cmd/codebase-memory-mcp/main.go": ( + r"windowsPayloadName\s*=\s*['\"]codebase-memory-mcp\.payload\.exe['\"]", + r"if\s+runtime\.GOOS\s*==\s*['\"]windows['\"][\s\S]{0,300}name\s*=\s*windowsPayloadName", + ), +} +for relative, patterns in portable_cache_contracts.items(): + source = read(relative) + require( + payload in source + and all(re.search(pattern, source, re.DOTALL) for pattern in patterns), + f"{relative} must retain {payload} as the Windows cache readiness path", + ) + require( + ".cbm/generations" not in source and "current-v1" not in source, + f"{relative} must remain portable and not own managed launcher state", + ) + +# Portable package shims keep an immutable, exact-version launcher/payload pair. +# The launcher resolves its adjacent payload for every native command. +# Concurrent first-run contenders publish launcher first and payload +# last; payload is therefore the readiness signal. They never pre-delete or +# roll back a destination, and may accept a collision only after proving that +# the winner is byte-identical to the authenticated staged source and runnable. +immutable_pair_cache_contracts = { + "pkg/npm/install.js": ( + "const cacheNames = platform === 'windows'", + "const publishNames = platform === 'windows'", + "filesEqualSha256", + ), + "pkg/pypi/src/codebase_memory_mcp/_cli.py": ( + "cache_names = extraction_names", + "publish_names = (", + "_files_equal_sha256", + ), + "pkg/go/cmd/codebase-memory-mcp/main.go": ( + "installWindowsPairAtomically(tmp, filepath.Dir(dest))", + "filesEqualSHA256", + ), +} +for relative, needles in immutable_pair_cache_contracts.items(): + source = read(relative) + require( + all(needle in source for needle in needles), + f"{relative} must publish an immutable exact-version Windows pair with {payload} last", + ) + require( + "publishedPaths" not in source + and "published_paths" not in source + and "const published = []" not in source, + f"{relative} must not roll back published destinations by pathname", + ) + +npm_installer = read("pkg/npm/install.js") +require( + re.search( + r"const publishNames = platform === 'windows'\s*\?\s*" + r"\[WINDOWS_LAUNCHER_NAME, WINDOWS_PAYLOAD_NAME\]", + npm_installer, + ) is not None + and "filesEqualSha256(stagedPaths.get(name), destination)" in npm_installer + and "fs.unlinkSync(destination)" not in npm_installer, + "npm must publish launcher then payload and only accept an identical collision", +) +python_wrapper = read("pkg/pypi/src/codebase_memory_mcp/_cli.py") +require( + re.search( + r"publish_names = \(\s*\(_WINDOWS_LAUNCHER_NAME, " + r"_WINDOWS_PAYLOAD_NAME\)", + python_wrapper, + ) is not None + and "_files_equal_sha256(staged_paths[name], target)" in python_wrapper, + "PyPI must publish launcher then payload and only accept an identical collision", +) +go_wrapper = read("pkg/go/cmd/codebase-memory-mcp/main.go") +require( + "names := []string{windowsLauncherName, windowsPayloadName}" in go_wrapper + and "filesEqualSHA256(staged[name], target)" in go_wrapper + and "os.Remove(target)" not in go_wrapper, + "Go must publish launcher then payload and only accept an identical collision", +) + +npm_shim = read("pkg/npm/bin.js") +require( + "launcherPath" in npm_shim + and "windowsLauncherName" in npm_shim + and "fs.existsSync(launcherPath)" in npm_shim, + "pkg/npm/bin.js readiness must require the adjacent launcher/payload pair", +) +require( + "const executionPath = isWindows ? launcherPath : binPath" in npm_shim + and "spawnSync(executionPath, process.argv.slice(2)" in npm_shim + and "spawnSync(binPath, process.argv.slice(2)" not in npm_shim, + "npm must execute Windows commands through the adjacent launcher", +) +require( + "verifyCandidate(extractedPaths.get(WINDOWS_LAUNCHER_NAME))" in npm_installer + and "verifyCandidate(path.join(BIN_DIR, WINDOWS_LAUNCHER_NAME))" in npm_installer, + "npm must validate extracted and cached Windows pairs through the launcher", +) +require( + "_windows_pair_ready" in python_wrapper, + "PyPI readiness must require and validate the adjacent launcher/payload pair", +) +require( + "return payload.with_name(_WINDOWS_LAUNCHER_NAME)" in python_wrapper + and "execution_path = _execution_path(bin_path, sys.platform)" in python_wrapper + and "args = [str(execution_path)] + sys.argv[1:]" in python_wrapper + and "result = subprocess.run(args)" in python_wrapper + and "args = [str(bin_path)] + sys.argv[1:]" not in python_wrapper, + "PyPI must execute Windows commands through the adjacent launcher", +) +require( + "windowsLauncherPath" in go_wrapper + and "installWindowsPairAtomically" in go_wrapper, + "Go readiness must require and validate the adjacent launcher/payload pair", +) +require( + "return filepath.Join(filepath.Dir(payload), windowsLauncherName)" in go_wrapper + and "return executionPathForOS(payload, runtime.GOOS), nil" in go_wrapper + and "execBinary(executable, os.Args[1:])" in go_wrapper, + "Go must execute Windows commands through the adjacent launcher", +) + +# All package downloaders parse the Windows archive against the exact official +# five-root-file allowlist. Portable wrappers privately validate the executable +# pair and cache that pair immutably; they may not silently ignore an +# attacker-controlled sixth member. +exact_archive_guards = { + "install.ps1": ( + "$seen.Count -ne $WindowsArchiveNames.Count", + '"LICENSE"', + '"install.ps1"', + "THIRD_PARTY_NOTICES.md", + ), + "pkg/npm/install.js": ( + "$seen.Count -ne $requiredNames.Count", + "WINDOWS_LAUNCHER_NAME", + "WINDOWS_PAYLOAD_NAME", + "'LICENSE'", + "'install.ps1'", + "THIRD_PARTY_NOTICES.md", + ), + "pkg/pypi/src/codebase_memory_mcp/_cli.py": ( + "name not in required_set", + "len(seen) != len(required)", + "_WINDOWS_LAUNCHER_NAME", + "_WINDOWS_PAYLOAD_NAME", + '"LICENSE"', + '"install.ps1"', + "THIRD_PARTY_NOTICES.md", + ), + "pkg/go/cmd/codebase-memory-mcp/main.go": ( + "len(seen) != len(archiveNames)", + "windowsLauncherName", + "windowsPayloadName", + '"LICENSE"', + '"install.ps1"', + "THIRD_PARTY_NOTICES.md", + ), +} +for relative, needles in exact_archive_guards.items(): + source = read(relative) + require( + all(needle in source for needle in needles), + f"{relative} must reject every Windows zip namespace except the official five-file allowlist", + ) + +# A portable mutation refusal must point to the owning package manager and to +# the explicit one-time transition into managed mode. +guidance_contracts = { + "pkg/npm/bin.js": ( + "npm install codebase-memory-mcp@latest", + "npm uninstall codebase-memory-mcp", + "codebase-memory-mcp install --yes", + ), + "pkg/pypi/src/codebase_memory_mcp/_cli.py": ( + "python -m pip install --upgrade codebase-memory-mcp", + "python -m pip uninstall codebase-memory-mcp", + "install --yes", + ), + "pkg/go/cmd/codebase-memory-mcp/main.go": ( + "go install github.com/DeusData/codebase-memory-mcp/pkg/go/cmd/codebase-memory-mcp@latest", + "Remove-Item (Get-Command codebase-memory-mcp).Source", + "codebase-memory-mcp install --yes", + ), +} +for relative, needles in guidance_contracts.items(): + source = read(relative) + require( + all(needle in source for needle in needles), + f"{relative} must provide actionable package and managed-install guidance", + ) + +# Native Windows regression coverage moves from the transient activation helper +# to the permanent launcher suite. +windows_test_driver = read("scripts/test-windows.ps1") +require( + "tests\\windows\\test_windows_launcher.py" in windows_test_driver + or "tests/windows/test_windows_launcher.py" in windows_test_driver, + "scripts/test-windows.ps1 must run tests/windows/test_windows_launcher.py", +) +require( + "test_cli_activation_helper.py" not in windows_test_driver, + "scripts/test-windows.ps1 must not run the legacy transient activation-helper test", +) + +# Launcher supervision has two distinct failure directions: killing the +# launcher must kill its payload job, and killing only the launcher's immediate +# parent must terminate the launcher plus every descendant. Require the native +# test to invoke both probes, not merely define helpers that never run. +windows_launcher_test = read("tests/windows/test_windows_launcher.py") +require( + windows_launcher_test.count("assert_launcher_death_contains_payload(") >= 2 + and "server.proc.kill()" in windows_launcher_test + and "wait_for_process_exit(child_pid" in windows_launcher_test, + "native Windows coverage must kill the launcher and reject an orphaned payload", +) +require( + windows_launcher_test.count( + "assert_immediate_parent_death_contains_launcher_tree(" + ) >= 2 + and '"--launcher-parent-probe"' in windows_launcher_test + and "wrapper.kill()" in windows_launcher_test + and "wait_for_process_exit(launcher_pid" in windows_launcher_test, + "native Windows coverage must kill the immediate parent and reject a surviving launcher tree", +) + +launcher_source = read("src/launcher/windows_launcher.c") +require( + "JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE" in launcher_source + and "PROC_THREAD_ATTRIBUTE_JOB_LIST" in launcher_source + and "launcher_parent_liveness_open" in launcher_source + and "WaitForMultipleObjects" in launcher_source + and "TerminateJobObject" in launcher_source, + "the permanent launcher must contain payloads and supervise immediate-parent death", +) + +# The build system exposes a distinct launcher artifact; release jobs later copy +# that small binary to the canonical public name. +makefile = read("Makefile.cbm") +launcher_target = re.search( + r"(?m)^\s*[^#\n]*codebase-memory-mcp-launcher(?:\.exe|\$\([A-Za-z0-9_]+\))?\s*:", + makefile, +) +require( + launcher_target is not None, + "Makefile.cbm must expose a codebase-memory-mcp-launcher executable target", +) + +# Every native path-tree trust boundary must validate opened ancestor handles, +# reject reparse points, inspect mutation-capable allow ACEs, and recognize the +# bounded privileged-principal set (current user, SYSTEM, Administrators, and +# the fixed TrustedInstaller service SID). The native test injects a real +# Everyone-modify ACE; these static markers keep all three independently linked +# implementations aligned even on non-Windows presubmit hosts. +ancestor_security_contracts = { + "src/daemon/ipc.c": ( + "win_directory_component_secure", + "win_file_security_secure(security, directory, false)", + "ACCESS_SYSTEM_SECURITY", + "956008885U", + "FILE_ATTRIBUTE_REPARSE_POINT", + ), + "src/cli/windows_launcher_state.c": ( + "windows_owner_secure(component, false)", + "windows_acl_secure(component)", + "ACCESS_SYSTEM_SECURITY", + "956008885U", + "FILE_FLAG_OPEN_REPARSE_POINT", + ), + "src/launcher/windows_launcher.c": ( + "launcher_security_is_safe(component, false)", + "ACCESS_SYSTEM_SECURITY", + "956008885U", + "FILE_FLAG_OPEN_REPARSE_POINT", + "PIPE_REJECT_REMOTE_CLIENTS", + ), +} +for relative, needles in ancestor_security_contracts.items(): + source = read(relative) + require( + all(needle in source for needle in needles), + f"{relative} must enforce the shared cross-account ancestor trust policy", + ) + +# Release smoke must run the canonical launcher while preserving the payload it +# resolves. Requiring both names in the Windows smoke job prevents a test from +# accidentally continuing to exercise a renamed monolithic payload. +smoke_workflow = read(".github/workflows/_smoke.yml") +windows_match = re.search( + r"(?ms)^ smoke-windows:\s*(.*?)(?=^ [A-Za-z0-9_-]+:\s*$|\Z)", + smoke_workflow, +) +windows_smoke = windows_match.group(1) if windows_match else "" +require(bool(windows_smoke), "_smoke.yml must contain the smoke-windows job") +require( + re.search( + r"scripts/smoke-test\.sh\s+(?:\"|')?\./codebase-memory-mcp\.exe(?:\"|')?", + windows_smoke, + ) is not None, + f"Windows release smoke must execute the canonical {launcher}", +) +require( + launcher in windows_smoke and payload in windows_smoke, + f"Windows release smoke must retain both {launcher} and {payload}", +) +smoke_blocks = yaml_run_blocks(windows_smoke) +require( + any("zip" in block and launcher in block and payload in block for block in smoke_blocks), + "Windows artifact-server smoke archive must contain launcher and payload", +) + +# Native update transport remains HTTPS-only in production. Release smoke may +# use an explicit file:// CBM_DOWNLOAD_URL only for its local fixture, while the +# installer and raw-curl phases continue to exercise the loopback HTTP server. +smoke_script = read("scripts/smoke-test.sh") +require( + 'SMOKE_UPDATE_FIXTURE_DIR' in smoke_script + and 'UPDATE_DOWNLOAD_URL="file://$UPDATE_FIXTURE_DIR"' in smoke_script + and 'UPDATE_DOWNLOAD_URL="file:///$UPDATE_FIXTURE_DIR"' in smoke_script + and 'CBM_DOWNLOAD_URL="$UPDATE_DOWNLOAD_URL"' in smoke_script, + "Phase 14 native update must use an explicit file:// fixture override", +) +require( + 'CBM_DOWNLOAD_URL="$SMOKE_DOWNLOAD_URL"' in smoke_script + and '"$SMOKE_DOWNLOAD_URL/$DL_ARCHIVE"' in smoke_script, + "installer and raw download smoke phases must retain loopback HTTP coverage", +) +require( + smoke_workflow.count("SMOKE_UPDATE_FIXTURE_DIR: /tmp/smoke-server") == 2, + "Unix and Windows release smoke must identify their local update fixture", +) + +cli_source = read("src/cli/cli.c") +file_override_match = re.search( + r"static bool cli_download_is_explicit_file_override\(.*?\n}\n", + cli_source, + re.DOTALL, +) +protocol_match = re.search( + r"static const char \*cli_download_protocol\(.*?\n}\n", + cli_source, + re.DOTALL, +) +file_override = file_override_match.group(0) if file_override_match else "" +protocol = protocol_match.group(0) if protocol_match else "" +require( + 'cbm_safe_getenv(\n "CBM_DOWNLOAD_URL"' in file_override + and 'strncmp(override, "file://", 7)' in file_override + and "strncmp(url, override, override_length)" in file_override, + "file:// downloads must remain restricted to the explicit test override", +) +require( + 'strncmp(url, "https://", 8)' in protocol + and 'return "=https"' in protocol + and "cli_download_is_explicit_file_override(url)" in protocol + and 'return "=file"' in protocol + and '"http://"' not in protocol, + "production native downloads must remain HTTPS-only", +) +download_helpers = cli_source[ + cli_source.find("static int cbm_download_to_file("): + cli_source.find("/* ── macOS ad-hoc signing") +] +require( + download_helpers.count('"--proto"') >= 2 + and download_helpers.count('"--proto-redir"') >= 2, + "native curl invocations must pin both initial and redirected protocols", +) + +# PR smoke builds artifacts in-place rather than downloading a release archive. +# It must therefore stage the build payload and permanent launcher under their +# release names in a disposable directory, then exercise the canonical launcher. +pr_workflow = read(".github/workflows/pr.yml") +pr_smoke_match = re.search( + r"(?ms)^ pr-smoke:\s*(.*?)(?=^ [A-Za-z0-9_-]+:\s*$|\Z)", + pr_workflow, +) +pr_smoke = pr_smoke_match.group(1) if pr_smoke_match else "" +require(bool(pr_smoke), "pr.yml must contain the pr-smoke job") +pr_windows_blocks = [ + block + for block in yaml_run_blocks(pr_smoke) + if "scripts/build.sh CC=clang CXX=clang++" in block +] +require( + len(pr_windows_blocks) == 1, + "PR smoke must contain exactly one Windows production build run block", +) +if pr_windows_blocks: + pr_windows_block = re.sub(r"\\\s*\n\s*", " ", pr_windows_blocks[0]) + pr_windows_block = re.sub(r"\s+", " ", pr_windows_block).strip() + staging_steps = ( + 'SMOKE_DIR="$(mktemp -d)"', + 'trap \'rm -rf "$SMOKE_DIR"\' EXIT', + 'cp build/c/codebase-memory-mcp-launcher.exe ' + '"$SMOKE_DIR/codebase-memory-mcp.exe"', + 'cp build/c/codebase-memory-mcp.exe ' + '"$SMOKE_DIR/codebase-memory-mcp.payload.exe"', + 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"', + ) + positions = [pr_windows_block.find(step) for step in staging_steps] + require( + all(position >= 0 for position in positions), + "Windows PR smoke must stage launcher and payload under release names " + "and invoke the canonical launcher", + ) + require( + all(left < right for left, right in zip(positions, positions[1:])), + "Windows PR smoke must create the temporary bundle before invoking it", + ) + require( + pr_windows_block.count("scripts/smoke-test.sh") == 1, + "Windows PR smoke must invoke smoke-test exactly once through the launcher", + ) + +if failures: + print("Windows launcher bundle contract FAILED:", file=sys.stderr) + for failure in failures: + print(f" - {failure}", file=sys.stderr) + raise SystemExit(1) + +print("Windows launcher bundle contract passed") +PY diff --git a/tests/test_windows_launcher_state.c b/tests/test_windows_launcher_state.c new file mode 100644 index 000000000..d6a09911e --- /dev/null +++ b/tests/test_windows_launcher_state.c @@ -0,0 +1,395 @@ +/* + * test_windows_launcher_state.c — Permanent Windows launcher state contract. + * + * This suite exercises the stable public byte contract for the managed Windows + * layout: a tiny launcher reads one fixed current-v1 record and resolves the + * immutable payload generation + * relative to its own location. Keep these pure helpers platform-independent + * so the record format and command policy are exercised on every CI host. + */ +#include "test_framework.h" + +#include + +#include +#include +#include +#include + +static const char launcher_sha256[] = "0123456789abcdef0123456789abcdef" + "0123456789abcdef0123456789abcdef"; + +static cbm_windows_current_v1_t launcher_valid_state(void) { + cbm_windows_current_v1_t state; + memset(&state, 0, sizeof(state)); + state.launcher_abi_min = 1; + state.launcher_abi_max = 3; + state.payload_size = UINT64_C(0x0102030405060708); + memcpy(state.payload_sha256, launcher_sha256, sizeof(launcher_sha256)); + return state; +} + +static bool launcher_memory_is_zero(const void *memory, size_t size) { + const uint8_t *bytes = memory; + for (size_t index = 0; index < size; index++) { + if (bytes[index] != 0) { + return false; + } + } + return true; +} + +static void launcher_put_u32_le(uint8_t *out, uint32_t value) { + out[0] = (uint8_t)value; + out[1] = (uint8_t)(value >> 8); + out[2] = (uint8_t)(value >> 16); + out[3] = (uint8_t)(value >> 24); +} + +static void launcher_put_u64_le(uint8_t *out, uint64_t value) { + for (unsigned int index = 0; index < 8U; index++) { + out[index] = (uint8_t)(value >> (index * 8U)); + } +} + +static cbm_windows_release_descriptor_v1_t launcher_valid_descriptor(void) { + cbm_windows_release_descriptor_v1_t descriptor; + memset(&descriptor, 0, sizeof(descriptor)); + descriptor.launcher_abi = 2U; + descriptor.payload_launcher_abi_min = 1U; + descriptor.payload_launcher_abi_max = 3U; + descriptor.payload_size = UINT64_C(0x0102030405060708); + memcpy(descriptor.payload_sha256, launcher_sha256, sizeof(launcher_sha256)); + return descriptor; +} + +TEST(windows_release_descriptor_v1_encoding_is_exact_and_round_trips) { + cbm_windows_release_descriptor_v1_t descriptor = launcher_valid_descriptor(); + uint8_t actual[CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE]; + uint8_t expected[CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE]; + memset(expected, 0, sizeof(expected)); + memcpy(expected, "CBMWRD1\0", 8U); + launcher_put_u32_le(expected + 8U, 1U); + launcher_put_u32_le(expected + 12U, CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE); + launcher_put_u32_le(expected + 16U, descriptor.launcher_abi); + launcher_put_u32_le(expected + 20U, descriptor.payload_launcher_abi_min); + launcher_put_u32_le(expected + 24U, descriptor.payload_launcher_abi_max); + launcher_put_u64_le(expected + 32U, descriptor.payload_size); + memcpy(expected + 40U, launcher_sha256, 64U); + ASSERT_TRUE(cbm_windows_release_descriptor_v1_encode(&descriptor, actual)); + ASSERT_MEM_EQ(actual, expected, sizeof(actual)); + + cbm_windows_release_descriptor_v1_t decoded; + ASSERT_TRUE(cbm_windows_release_descriptor_v1_decode(actual, sizeof(actual), &decoded)); + ASSERT_EQ(decoded.launcher_abi, descriptor.launcher_abi); + ASSERT_EQ(decoded.payload_launcher_abi_min, descriptor.payload_launcher_abi_min); + ASSERT_EQ(decoded.payload_launcher_abi_max, descriptor.payload_launcher_abi_max); + ASSERT_EQ(decoded.payload_size, descriptor.payload_size); + ASSERT_STR_EQ(decoded.payload_sha256, descriptor.payload_sha256); + PASS(); +} + +TEST(windows_release_descriptor_v1_rejects_noncanonical_or_incompatible) { + cbm_windows_release_descriptor_v1_t descriptor = launcher_valid_descriptor(); + uint8_t record[CBM_WINDOWS_RELEASE_DESCRIPTOR_V1_SIZE]; + ASSERT_TRUE(cbm_windows_release_descriptor_v1_encode(&descriptor, record)); + record[28] = 1U; + ASSERT_FALSE(cbm_windows_release_descriptor_v1_decode(record, sizeof(record), &descriptor)); + record[28] = 0U; + record[104] = 1U; + ASSERT_FALSE(cbm_windows_release_descriptor_v1_decode(record, sizeof(record), &descriptor)); + + descriptor = launcher_valid_descriptor(); + descriptor.launcher_abi = 4U; + ASSERT_FALSE(cbm_windows_release_descriptor_v1_encode(&descriptor, record)); + descriptor = launcher_valid_descriptor(); + descriptor.payload_launcher_abi_min = 0U; + ASSERT_FALSE(cbm_windows_release_descriptor_v1_encode(&descriptor, record)); + PASS(); +} + +TEST(windows_transition_plan_requires_a_crash_safe_compatibility_bridge) { + cbm_windows_current_v1_t current = launcher_valid_state(); + cbm_windows_release_descriptor_v1_t candidate = launcher_valid_descriptor(); + + current.launcher_abi_min = 1U; + current.launcher_abi_max = 1U; + candidate.launcher_abi = 1U; + candidate.payload_launcher_abi_min = 1U; + candidate.payload_launcher_abi_max = 1U; + ASSERT_EQ(cbm_windows_transition_plan(¤t, &candidate), + CBM_WINDOWS_TRANSITION_LAUNCHER_FIRST); + + candidate.launcher_abi = 2U; + candidate.payload_launcher_abi_min = 1U; + candidate.payload_launcher_abi_max = 2U; + ASSERT_EQ(cbm_windows_transition_plan(¤t, &candidate), + CBM_WINDOWS_TRANSITION_CURRENT_FIRST); + + current.launcher_abi_min = 1U; + current.launcher_abi_max = 2U; + candidate.payload_launcher_abi_min = 2U; + candidate.payload_launcher_abi_max = 2U; + ASSERT_EQ(cbm_windows_transition_plan(¤t, &candidate), + CBM_WINDOWS_TRANSITION_LAUNCHER_FIRST); + + current.launcher_abi_min = 1U; + current.launcher_abi_max = 1U; + ASSERT_EQ(cbm_windows_transition_plan(¤t, &candidate), + CBM_WINDOWS_TRANSITION_INCOMPATIBLE); + + current.launcher_abi_min = 1U; + current.launcher_abi_max = 3U; + candidate.launcher_abi = 4U; + candidate.payload_launcher_abi_min = 2U; + candidate.payload_launcher_abi_max = 4U; + ASSERT_EQ(cbm_windows_transition_plan(¤t, &candidate), + CBM_WINDOWS_TRANSITION_INCOMPATIBLE); + ASSERT_EQ(cbm_windows_transition_plan(NULL, &candidate), CBM_WINDOWS_TRANSITION_INCOMPATIBLE); + PASS(); +} + +TEST(windows_current_v1_encoding_is_exact_fixed_little_endian_record) { + cbm_windows_current_v1_t state = launcher_valid_state(); + state.launcher_abi_min = UINT32_C(0x01020304); + state.launcher_abi_max = UINT32_C(0x05060708); + + uint8_t actual[CBM_WINDOWS_CURRENT_V1_SIZE]; + memset(actual, 0xa5, sizeof(actual)); + ASSERT_TRUE(cbm_windows_current_v1_encode(&state, actual)); + + uint8_t expected[128] = {0}; + const uint8_t magic[8] = {'C', 'B', 'M', 'C', 'U', 'R', '1', '\0'}; + memcpy(expected, magic, sizeof(magic)); + launcher_put_u32_le(expected + 8, 1); + launcher_put_u32_le(expected + 12, 128); + launcher_put_u32_le(expected + 16, state.launcher_abi_min); + launcher_put_u32_le(expected + 20, state.launcher_abi_max); + expected[24] = 0x08; + expected[25] = 0x07; + expected[26] = 0x06; + expected[27] = 0x05; + expected[28] = 0x04; + expected[29] = 0x03; + expected[30] = 0x02; + expected[31] = 0x01; + memcpy(expected + 32, launcher_sha256, 64); + + ASSERT_EQ(CBM_WINDOWS_CURRENT_V1_SIZE, 128); + ASSERT_MEM_EQ(actual, expected, sizeof(expected)); + ASSERT_TRUE(launcher_memory_is_zero(actual + 96, 32)); + PASS(); +} + +TEST(windows_current_v1_round_trip_preserves_authenticated_payload_fields) { + cbm_windows_current_v1_t input = launcher_valid_state(); + uint8_t record[CBM_WINDOWS_CURRENT_V1_SIZE]; + ASSERT_TRUE(cbm_windows_current_v1_encode(&input, record)); + + cbm_windows_current_v1_t output; + memset(&output, 0xa5, sizeof(output)); + ASSERT_TRUE(cbm_windows_current_v1_decode(record, sizeof(record), &output)); + ASSERT_EQ(output.launcher_abi_min, input.launcher_abi_min); + ASSERT_EQ(output.launcher_abi_max, input.launcher_abi_max); + ASSERT_EQ(output.payload_size, input.payload_size); + ASSERT_STR_EQ(output.payload_sha256, input.payload_sha256); + ASSERT_EQ(output.payload_sha256[64], '\0'); + PASS(); +} + +TEST(windows_current_v1_launcher_abi_range_is_inclusive_and_fail_closed) { + cbm_windows_current_v1_t state = launcher_valid_state(); + ASSERT_FALSE(cbm_windows_current_v1_supports_launcher_abi(&state, 0)); + ASSERT_TRUE(cbm_windows_current_v1_supports_launcher_abi(&state, 1)); + ASSERT_TRUE(cbm_windows_current_v1_supports_launcher_abi(&state, 2)); + ASSERT_TRUE(cbm_windows_current_v1_supports_launcher_abi(&state, 3)); + ASSERT_FALSE(cbm_windows_current_v1_supports_launcher_abi(&state, 4)); + ASSERT_FALSE(cbm_windows_current_v1_supports_launcher_abi(NULL, 2)); + PASS(); +} + +TEST(windows_current_v1_encoder_rejects_noncanonical_state) { + cbm_windows_current_v1_t state = launcher_valid_state(); + uint8_t record[CBM_WINDOWS_CURRENT_V1_SIZE]; + + ASSERT_FALSE(cbm_windows_current_v1_encode(NULL, record)); + ASSERT_FALSE(cbm_windows_current_v1_encode(&state, NULL)); + + state.launcher_abi_min = 0; + ASSERT_FALSE(cbm_windows_current_v1_encode(&state, record)); + state = launcher_valid_state(); + state.launcher_abi_min = 4; + state.launcher_abi_max = 3; + ASSERT_FALSE(cbm_windows_current_v1_encode(&state, record)); + state = launcher_valid_state(); + state.payload_size = 0; + ASSERT_FALSE(cbm_windows_current_v1_encode(&state, record)); + state = launcher_valid_state(); + state.payload_sha256[0] = 'A'; + ASSERT_FALSE(cbm_windows_current_v1_encode(&state, record)); + state = launcher_valid_state(); + state.payload_sha256[17] = 'g'; + ASSERT_FALSE(cbm_windows_current_v1_encode(&state, record)); + state = launcher_valid_state(); + memset(state.payload_sha256, 'a', sizeof(state.payload_sha256)); + ASSERT_FALSE(cbm_windows_current_v1_encode(&state, record)); + PASS(); +} + +static int launcher_assert_decode_rejected(const uint8_t *record, size_t size) { + cbm_windows_current_v1_t output; + memset(&output, 0xa5, sizeof(output)); + if (cbm_windows_current_v1_decode(record, size, &output)) { + return 1; + } + /* A rejected attacker-controlled record must not leave partial state that + * a caller could accidentally consume. */ + return launcher_memory_is_zero(&output, sizeof(output)) ? 0 : 1; +} + +TEST(windows_current_v1_decoder_rejects_every_noncanonical_field) { + cbm_windows_current_v1_t state = launcher_valid_state(); + uint8_t valid[CBM_WINDOWS_CURRENT_V1_SIZE + 1]; + memset(valid, 0, sizeof(valid)); + ASSERT_TRUE(cbm_windows_current_v1_encode(&state, valid)); + + ASSERT_EQ(launcher_assert_decode_rejected(valid, 127), 0); + ASSERT_EQ(launcher_assert_decode_rejected(valid, 129), 0); + ASSERT_FALSE(cbm_windows_current_v1_decode(NULL, 128, &state)); + ASSERT_FALSE(cbm_windows_current_v1_decode(valid, 128, NULL)); + + uint8_t damaged[CBM_WINDOWS_CURRENT_V1_SIZE]; + memcpy(damaged, valid, sizeof(damaged)); + damaged[0] ^= 1; + ASSERT_EQ(launcher_assert_decode_rejected(damaged, sizeof(damaged)), 0); + + memcpy(damaged, valid, sizeof(damaged)); + launcher_put_u32_le(damaged + 8, 2); + ASSERT_EQ(launcher_assert_decode_rejected(damaged, sizeof(damaged)), 0); + + memcpy(damaged, valid, sizeof(damaged)); + launcher_put_u32_le(damaged + 12, 127); + ASSERT_EQ(launcher_assert_decode_rejected(damaged, sizeof(damaged)), 0); + + memcpy(damaged, valid, sizeof(damaged)); + launcher_put_u32_le(damaged + 16, 0); + ASSERT_EQ(launcher_assert_decode_rejected(damaged, sizeof(damaged)), 0); + + memcpy(damaged, valid, sizeof(damaged)); + launcher_put_u32_le(damaged + 16, 4); + launcher_put_u32_le(damaged + 20, 3); + ASSERT_EQ(launcher_assert_decode_rejected(damaged, sizeof(damaged)), 0); + + memcpy(damaged, valid, sizeof(damaged)); + memset(damaged + 24, 0, 8); + ASSERT_EQ(launcher_assert_decode_rejected(damaged, sizeof(damaged)), 0); + + memcpy(damaged, valid, sizeof(damaged)); + damaged[32] = 'A'; + ASSERT_EQ(launcher_assert_decode_rejected(damaged, sizeof(damaged)), 0); + + memcpy(damaged, valid, sizeof(damaged)); + damaged[63] = 'g'; + ASSERT_EQ(launcher_assert_decode_rejected(damaged, sizeof(damaged)), 0); + + memcpy(damaged, valid, sizeof(damaged)); + damaged[96] = 1; + ASSERT_EQ(launcher_assert_decode_rejected(damaged, sizeof(damaged)), 0); + PASS(); +} + +TEST(windows_generation_path_is_launcher_relative_for_custom_unicode_directory) { + const wchar_t launcher[] = + L"D:\\CBM Custom\\M\u00e4rtin\\\u5de5\u5177\\codebase-memory-mcp.exe"; + const wchar_t expected[] = L"D:\\CBM Custom\\M\u00e4rtin\\\u5de5\u5177\\.cbm\\generations\\" + L"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\\" + L"codebase-memory-mcp.payload.exe"; + wchar_t actual[512]; + ASSERT_TRUE(cbm_windows_generation_payload_path(launcher, launcher_sha256, actual, + sizeof(actual) / sizeof(actual[0]))); + ASSERT_EQ(wcscmp(actual, expected), 0); + + const wchar_t root_launcher[] = L"C:\\codebase-memory-mcp.exe"; + const wchar_t root_expected[] = + L"C:\\.cbm\\generations\\" + L"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\\" + L"codebase-memory-mcp.payload.exe"; + ASSERT_TRUE(cbm_windows_generation_payload_path(root_launcher, launcher_sha256, actual, + sizeof(actual) / sizeof(actual[0]))); + ASSERT_EQ(wcscmp(actual, root_expected), 0); + PASS(); +} + +TEST(windows_generation_path_rejects_ambiguous_or_truncated_inputs) { + wchar_t output[512]; + memset(output, 0xa5, sizeof(output)); + ASSERT_FALSE(cbm_windows_generation_payload_path(L"codebase-memory-mcp.exe", launcher_sha256, + output, sizeof(output) / sizeof(output[0]))); + ASSERT_EQ(output[0], L'\0'); + + ASSERT_FALSE(cbm_windows_generation_payload_path(L"C:\\codebase-memory-mcp.exe", + launcher_sha256, output, 12)); + ASSERT_EQ(output[0], L'\0'); + + char uppercase[65]; + memcpy(uppercase, launcher_sha256, sizeof(uppercase)); + uppercase[0] = 'A'; + ASSERT_FALSE(cbm_windows_generation_payload_path(L"C:\\codebase-memory-mcp.exe", uppercase, + output, sizeof(output) / sizeof(output[0]))); + ASSERT_EQ(output[0], L'\0'); + PASS(); +} + +TEST(windows_launcher_action_classifier_matches_top_level_dispatch) { + const char *ordinary_mcp[] = {"codebase-memory-mcp.exe"}; + const char *ordinary_cli[] = {"codebase-memory-mcp.exe", "cli", "search_graph", + "{\"query\":\"update\"}"}; + const char *ordinary_install[] = {"codebase-memory-mcp.exe", "install", "update"}; + const char *ordinary_help[] = {"codebase-memory-mcp.exe", "--help", "update"}; + const char *update[] = {"codebase-memory-mcp.exe", "--profile", "update", "--yes"}; + const char *uninstall[] = {"codebase-memory-mcp.exe", "ignored", "uninstall", "--yes"}; + const char *first_wins[] = {"codebase-memory-mcp.exe", "update", "uninstall"}; + + ASSERT_EQ(cbm_windows_launcher_classify_action(1, ordinary_mcp), + CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY); + ASSERT_EQ(cbm_windows_launcher_classify_action(4, ordinary_cli), + CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY); + ASSERT_EQ(cbm_windows_launcher_classify_action(3, ordinary_install), + CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY); + ASSERT_EQ(cbm_windows_launcher_classify_action(3, ordinary_help), + CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY); + ASSERT_EQ(cbm_windows_launcher_classify_action(4, update), CBM_WINDOWS_LAUNCHER_ACTION_UPDATE); + ASSERT_EQ(cbm_windows_launcher_classify_action(4, uninstall), + CBM_WINDOWS_LAUNCHER_ACTION_UNINSTALL); + ASSERT_EQ(cbm_windows_launcher_classify_action(3, first_wins), + CBM_WINDOWS_LAUNCHER_ACTION_UPDATE); + ASSERT_EQ(cbm_windows_launcher_classify_action(0, NULL), CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY); + PASS(); +} + +TEST(windows_portable_payload_rejects_only_managed_mutations) { + ASSERT_TRUE(cbm_windows_launcher_action_allowed(CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY, false)); + ASSERT_FALSE(cbm_windows_launcher_action_allowed(CBM_WINDOWS_LAUNCHER_ACTION_UPDATE, false)); + ASSERT_FALSE(cbm_windows_launcher_action_allowed(CBM_WINDOWS_LAUNCHER_ACTION_UNINSTALL, false)); + + ASSERT_TRUE(cbm_windows_launcher_action_allowed(CBM_WINDOWS_LAUNCHER_ACTION_ORDINARY, true)); + ASSERT_TRUE(cbm_windows_launcher_action_allowed(CBM_WINDOWS_LAUNCHER_ACTION_UPDATE, true)); + ASSERT_TRUE(cbm_windows_launcher_action_allowed(CBM_WINDOWS_LAUNCHER_ACTION_UNINSTALL, true)); + ASSERT_FALSE(cbm_windows_launcher_action_allowed((cbm_windows_launcher_action_t)99, true)); + PASS(); +} + +SUITE(windows_launcher_state) { + RUN_TEST(windows_release_descriptor_v1_encoding_is_exact_and_round_trips); + RUN_TEST(windows_release_descriptor_v1_rejects_noncanonical_or_incompatible); + RUN_TEST(windows_transition_plan_requires_a_crash_safe_compatibility_bridge); + RUN_TEST(windows_current_v1_encoding_is_exact_fixed_little_endian_record); + RUN_TEST(windows_current_v1_round_trip_preserves_authenticated_payload_fields); + RUN_TEST(windows_current_v1_launcher_abi_range_is_inclusive_and_fail_closed); + RUN_TEST(windows_current_v1_encoder_rejects_noncanonical_state); + RUN_TEST(windows_current_v1_decoder_rejects_every_noncanonical_field); + RUN_TEST(windows_generation_path_is_launcher_relative_for_custom_unicode_directory); + RUN_TEST(windows_generation_path_rejects_ambiguous_or_truncated_inputs); + RUN_TEST(windows_launcher_action_classifier_matches_top_level_dispatch); + RUN_TEST(windows_portable_payload_rejects_only_managed_mutations); +} diff --git a/tests/windows/test_windows_launcher.py b/tests/windows/test_windows_launcher.py new file mode 100644 index 000000000..9adc86b48 --- /dev/null +++ b/tests/windows/test_windows_launcher.py @@ -0,0 +1,1074 @@ +"""GREEN native-Windows guard for the permanent launcher contract. + +The release contains two executables: + +* ``codebase-memory-mcp.exe`` is the small permanent launcher. +* ``codebase-memory-mcp.payload.exe`` is the portable CBM payload. + +Only ``install`` turns that pair into a managed installation. A managed +launcher resolves its payload from a strict, fixed-size current-v1 record +relative to the launcher's own directory. Portable package-manager payloads +remain useful for ordinary MCP/CLI commands, but must refuse self-update and +self-uninstall before they disturb an active daemon generation. + +This guard deliberately crosses real CreateProcess/stdio/filesystem boundaries +on native Windows. It also kills the launcher abruptly and inspects the +process tree, because a waiting launcher must own its payload child strongly +enough that killing the launcher cannot leave an orphaned MCP client. + +Exit code: 0 == contract honored, 1 == regression, 2 == precondition failure. + +Usage: + python test_windows_launcher.py \ + +""" + +import ctypes +from ctypes import wintypes +import hashlib +import os +import pathlib +import platform +import re +import shutil +import struct +import subprocess +import sys +import tempfile +import time +import zipfile + +if os.name == "nt": + import winreg + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from mcp_stdio import McpError, McpServer # noqa: E402 + + +CURRENT_MAGIC = b"CBMCUR1\0" +CURRENT_RECORD_SIZE = 128 +CURRENT_FORMAT_OFFSET = 8 +CURRENT_SIZE_OFFSET = 12 +CURRENT_ABI_MIN_OFFSET = 16 +CURRENT_ABI_MAX_OFFSET = 20 +CURRENT_PAYLOAD_SIZE_OFFSET = 24 +CURRENT_SHA256_OFFSET = 32 +CURRENT_SHA256_SIZE = 64 +CURRENT_RESERVED_OFFSET = 96 + +DESCRIPTOR_MAGIC = b"CBMWRD1\0" +DESCRIPTOR_RECORD_SIZE = 128 +DESCRIPTOR_FORMAT_OFFSET = 8 +DESCRIPTOR_SIZE_OFFSET = 12 +DESCRIPTOR_LAUNCHER_ABI_OFFSET = 16 +DESCRIPTOR_PAYLOAD_ABI_MIN_OFFSET = 20 +DESCRIPTOR_PAYLOAD_ABI_MAX_OFFSET = 24 +DESCRIPTOR_FLAGS_OFFSET = 28 +DESCRIPTOR_PAYLOAD_SIZE_OFFSET = 32 +DESCRIPTOR_SHA256_OFFSET = 40 +DESCRIPTOR_RESERVED_OFFSET = 104 + +TH32CS_SNAPPROCESS = 0x00000002 +INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value +PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 +SYNCHRONIZE = 0x00100000 +WAIT_OBJECT_0 = 0x00000000 +WAIT_TIMEOUT = 0x00000102 + + +class GuardFailure(Exception): + pass + + +class PROCESSENTRY32W(ctypes.Structure): + _fields_ = [ + ("dwSize", wintypes.DWORD), + ("cntUsage", wintypes.DWORD), + ("th32ProcessID", wintypes.DWORD), + ("th32DefaultHeapID", ctypes.c_size_t), + ("th32ModuleID", wintypes.DWORD), + ("cntThreads", wintypes.DWORD), + ("th32ParentProcessID", wintypes.DWORD), + ("pcPriClassBase", wintypes.LONG), + ("dwFlags", wintypes.DWORD), + ("szExeFile", wintypes.WCHAR * 260), + ] + + +def require(condition, message): + if not condition: + raise GuardFailure(message) + + +def output_text(result): + return ((result.stdout or b"") + b"\n" + (result.stderr or b"")).decode( + "utf-8", "replace" + ) + + +def run(command, env, timeout=30): + try: + return subprocess.run( + [str(part) for part in command], + input=b"", + capture_output=True, + env=env, + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + raise GuardFailure( + "command exceeded %ss: %s" % (timeout, " ".join(map(str, command))) + ) from exc + + +def isolated_environment(work): + home = work / "home" + cache = work / "cache" + home.mkdir(parents=True) + cache.mkdir(parents=True) + env = dict(os.environ) + env.update( + { + "HOME": str(home), + "USERPROFILE": str(home), + "APPDATA": str(home / "AppData" / "Roaming"), + "LOCALAPPDATA": str(home / "AppData" / "Local"), + "CBM_CACHE_DIR": str(cache), + "PYTHONUTF8": "1", + } + ) + return env, cache + + +def copy_portable_pair(source_launcher, source_payload, directory): + directory.mkdir(parents=True, exist_ok=True) + launcher = directory / "codebase-memory-mcp.exe" + payload = directory / "codebase-memory-mcp.payload.exe" + shutil.copy2(source_launcher, launcher) + shutil.copy2(source_payload, payload) + return launcher, payload + + +def copy_portable_payload(source_payload, directory): + directory.mkdir(parents=True, exist_ok=True) + payload = directory / "codebase-memory-mcp.payload.exe" + shutil.copy2(source_payload, payload) + return payload + + +def path_registry_snapshot(): + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key: + value, kind = winreg.QueryValueEx(key, "Path") + return True, value, kind + except FileNotFoundError: + return False, None, None + + +def path_registry_restore(snapshot): + existed, value, kind = snapshot + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, "Environment") as key: + if existed: + winreg.SetValueEx(key, "Path", 0, kind, value) + else: + try: + winreg.DeleteValue(key, "Path") + except FileNotFoundError: + pass + + +def find_and_validate_current(managed_dir): + matches = [] + for candidate in (managed_dir / ".cbm").rglob("*"): + if not candidate.is_file(): + continue + try: + data = candidate.read_bytes() + except OSError: + continue + if len(data) == CURRENT_RECORD_SIZE and data.startswith(CURRENT_MAGIC): + matches.append((candidate, data)) + require( + len(matches) == 1, + "managed layout must contain exactly one 128-byte CBMCUR1 current record; got %d" + % len(matches), + ) + current, data = matches[0] + version = struct.unpack_from(" 0 and abi_min <= abi_max, "current-v1 ABI range is invalid") + require(re.fullmatch(r"[0-9a-f]{64}", digest), "current-v1 digest is not lowercase SHA-256") + require( + data[CURRENT_RESERVED_OFFSET:] == bytes(CURRENT_RECORD_SIZE - CURRENT_RESERVED_OFFSET), + "current-v1 reserved bytes must be zero", + ) + + payload = ( + managed_dir + / ".cbm" + / "generations" + / digest + / "codebase-memory-mcp.payload.exe" + ) + require(payload.is_file(), "current-v1 generation payload is missing: %s" % payload) + require(payload.stat().st_size == payload_size, "current-v1 payload size does not match") + observed = hashlib.sha256(payload.read_bytes()).hexdigest() + require(observed == digest, "installed generation name/digest does not match payload bytes") + return current, data, payload + + +def parse_release_descriptor(data): + require(len(data) == DESCRIPTOR_RECORD_SIZE, "release descriptor size is not 128") + require(data[:8] == DESCRIPTOR_MAGIC, "release descriptor magic is invalid") + require( + struct.unpack_from(" 0 and payload_min <= launcher_abi <= payload_max, + "release descriptor launcher/payload ABI range is incompatible", + ) + require(payload_size > 0, "release descriptor payload size is zero") + require(re.fullmatch(r"[0-9a-f]{64}", digest), "release descriptor digest is invalid") + require( + data[DESCRIPTOR_RESERVED_OFFSET:] == bytes( + DESCRIPTOR_RECORD_SIZE - DESCRIPTOR_RESERVED_OFFSET + ), + "release descriptor reserved bytes are nonzero", + ) + return launcher_abi, payload_min, payload_max, payload_size, digest + + +def assert_release_descriptor(source_launcher, source_payload, env, cache): + cache_before = sorted(str(path.relative_to(cache)) for path in cache.rglob("*")) + result = run( + [source_launcher, "__cbm_windows_release_descriptor_v1"], env, timeout=30 + ) + require( + result.returncode == 0, + "release descriptor probe failed: %s" % output_text(result)[-800:], + ) + _, _, _, payload_size, digest = parse_release_descriptor(result.stdout) + require(payload_size == source_payload.stat().st_size, "descriptor payload size mismatch") + require(digest == sha256_file(source_payload), "descriptor payload digest mismatch") + cache_after = sorted(str(path.relative_to(cache)) for path in cache.rglob("*")) + require(cache_after == cache_before, "release descriptor probe touched cache/daemon state") + print("PASS: release pair self-described ABI and payload identity without side effects") + + +def assert_portable_mutations_refuse(source_payload, env, cache, work): + session_payload = copy_portable_payload(source_payload, work / "portable-session") + with McpServer(str(session_payload), cache_dir=str(cache), extra_env=env) as server: + server.initialize(timeout=30) + require(server.tools_list(timeout=30), "portable MCP control session has no tools") + + for action, options in ( + ("update", ["--yes", "--standard"]), + ("uninstall", ["--yes"]), + ): + command_payload = copy_portable_payload( + source_payload, work / ("portable-" + action) + ) + command_env = dict(env) + # If the update guard regresses, keep its unintended network path + # deterministic and fast. A correct implementation refuses before + # it consults this URL or asks the daemon to drain. + command_env["CBM_DOWNLOAD_URL"] = "https://127.0.0.1:1" + started = time.monotonic() + result = run([command_payload, action] + options, command_env, timeout=10) + elapsed = time.monotonic() - started + diagnostic = output_text(result).lower() + require(result.returncode != 0, "portable %s unexpectedly succeeded" % action) + require(elapsed < 8.0, "portable %s did not fail early" % action) + require( + "install" in diagnostic + and any( + word in diagnostic + for word in ("package manager", "managed", "launcher") + ), + "portable %s did not explain package-manager/managed-install recovery" + % action, + ) + # The same already-open stdio session must still own the same live + # daemon connection. A stop-and-transparent-restart is not enough: + # the existing pipe itself has to remain usable. + require( + server.tools_list(timeout=10), + "portable %s drained the active MCP/daemon session before refusing" + % action, + ) + print("PASS: portable %s refused early without draining the session" % action) + + +def assert_untrusted_ancestor_acl_rejected( + source_launcher, source_payload, env, work +): + unsafe_ancestor = work / "unsafe cross-account ACL ancestor" + launcher, _ = copy_portable_pair( + source_launcher, source_payload, unsafe_ancestor / "bundle" + ) + require( + run([launcher, "--version"], env).returncode == 0, + "portable launcher ACL control failed before the unsafe ACE was added", + ) + + grant = run( + ["icacls", unsafe_ancestor, "/grant", "*S-1-1-0:(M)"], env + ) + require( + grant.returncode == 0, + "could not install native Everyone-modify ancestor fixture: %s" + % output_text(grant)[-600:], + ) + try: + result = run([launcher, "--version"], env) + diagnostic = output_text(result).lower() + require( + result.returncode != 0, + "launcher accepted an ancestor granting cross-account modify access", + ) + require( + any( + word in diagnostic + for word in ("unsafe", "security", "ownership", "access", "resolve") + ), + "unsafe ancestor ACL refusal was not explicit", + ) + finally: + remove = run( + ["icacls", unsafe_ancestor, "/remove:g", "*S-1-1-0"], env + ) + require( + remove.returncode == 0, + "could not remove native Everyone-modify ancestor fixture", + ) + require( + run([launcher, "--version"], env).returncode == 0, + "launcher did not recover after unsafe ancestor ACE removal", + ) + print("PASS: launcher rejected an untrusted mutation ACE on an ancestor") + + +def process_entries(): + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] + kernel32.CreateToolhelp32Snapshot.restype = wintypes.HANDLE + kernel32.Process32FirstW.argtypes = [wintypes.HANDLE, ctypes.POINTER(PROCESSENTRY32W)] + kernel32.Process32FirstW.restype = wintypes.BOOL + kernel32.Process32NextW.argtypes = [wintypes.HANDLE, ctypes.POINTER(PROCESSENTRY32W)] + kernel32.Process32NextW.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + + snapshot = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) + if snapshot == INVALID_HANDLE_VALUE: + raise GuardFailure("CreateToolhelp32Snapshot failed") + entries = [] + try: + entry = PROCESSENTRY32W() + entry.dwSize = ctypes.sizeof(PROCESSENTRY32W) + ok = kernel32.Process32FirstW(snapshot, ctypes.byref(entry)) + while ok: + entries.append( + (int(entry.th32ProcessID), int(entry.th32ParentProcessID), entry.szExeFile) + ) + entry.dwSize = ctypes.sizeof(PROCESSENTRY32W) + ok = kernel32.Process32NextW(snapshot, ctypes.byref(entry)) + finally: + kernel32.CloseHandle(snapshot) + return entries + + +def process_is_alive(pid): + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + kernel32.OpenProcess.restype = wintypes.HANDLE + kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + kernel32.WaitForSingleObject.restype = wintypes.DWORD + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + handle = kernel32.OpenProcess( + SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, False, pid + ) + if not handle: + return False + try: + status = kernel32.WaitForSingleObject(handle, 0) + return status == WAIT_TIMEOUT + finally: + kernel32.CloseHandle(handle) + + +def wait_for_payload_child(launcher_pid, timeout=10): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + children = [ + (pid, name) + for pid, parent, name in process_entries() + if parent == launcher_pid + and name.lower().endswith("codebase-memory-mcp.payload.exe") + ] + if len(children) == 1: + return children[0][0] + if len(children) > 1: + raise GuardFailure("launcher spawned more than one direct payload child") + time.sleep(0.05) + raise GuardFailure("launcher did not create its direct payload child") + + +def wait_for_process_exit(pid, timeout=10): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if not process_is_alive(pid): + return True + time.sleep(0.05) + return not process_is_alive(pid) + + +def descendant_pids(root_pid): + entries = process_entries() + descendants = set() + frontier = {root_pid} + while frontier: + next_frontier = { + pid + for pid, parent, _ in entries + if parent in frontier and pid not in descendants + } + descendants.update(next_frontier) + frontier = next_frontier + return descendants + + +def assert_launcher_death_contains_payload(launcher, env, cache): + server = McpServer(str(launcher), cache_dir=str(cache), extra_env=env) + try: + server.start() + server.initialize(timeout=30) + child_pid = wait_for_payload_child(server.proc.pid) + require(process_is_alive(child_pid), "payload child exited before crash probe") + server.proc.kill() + server.proc.wait(timeout=10) + require( + wait_for_process_exit(child_pid, timeout=10), + "killing the permanent launcher orphaned payload pid %d" % child_pid, + ) + print("PASS: abrupt launcher death contained its payload child") + finally: + server.close() + + +def assert_immediate_parent_death_contains_launcher_tree(launcher, env, work): + pid_file = work / "launcher-parent-probe.pid" + wrapper = subprocess.Popen( + [ + sys.executable, + str(pathlib.Path(__file__).resolve()), + "--launcher-parent-probe", + str(launcher), + str(pid_file), + ], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + env=env, + ) + try: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline and not pid_file.exists(): + if wrapper.poll() is not None: + raise GuardFailure("parent-probe wrapper exited before publishing launcher PID") + time.sleep(0.05) + require(pid_file.exists(), "parent-probe wrapper did not publish launcher PID") + launcher_pid = int(pid_file.read_text(encoding="ascii").strip()) + payload_pid = wait_for_payload_child(launcher_pid, timeout=10) + tracked = {launcher_pid, payload_pid} | descendant_pids(launcher_pid) + require(process_is_alive(launcher_pid), "launcher exited before parent-death probe") + require(process_is_alive(payload_pid), "payload exited before parent-death probe") + + # Kill only the wrapper. The test process intentionally keeps the + # wrapper stdin pipe open, so MCP EOF cannot explain child teardown. + wrapper.kill() + wrapper.wait(timeout=10) + require( + wait_for_process_exit(launcher_pid, timeout=10), + "killing only the wrapper left launcher pid %d alive" % launcher_pid, + ) + survivors = [pid for pid in tracked if process_is_alive(pid)] + descendant_deadline = time.monotonic() + 10 + while survivors and time.monotonic() < descendant_deadline: + time.sleep(0.05) + survivors = [pid for pid in survivors if process_is_alive(pid)] + require(not survivors, "wrapper death left launcher descendants alive: %s" % survivors) + print("PASS: immediate parent death terminated launcher and payload session tree") + finally: + if wrapper.poll() is None: + wrapper.kill() + wrapper.wait(timeout=10) + if wrapper.stdin: + wrapper.stdin.close() + + +def assert_launcher_relay(launcher, payload, env, cache): + direct_version = run([payload, "--version"], env) + launched_version = run([launcher, "--version"], env) + require(direct_version.returncode == 0, "managed payload --version failed") + require( + launched_version.returncode == direct_version.returncode, + "launcher did not propagate the payload's success exit status exactly", + ) + require( + launched_version.stdout == direct_version.stdout + and launched_version.stderr == direct_version.stderr, + "launcher did not relay --version stdout/stderr byte-for-byte", + ) + + # A missing CLI tool has a stable non-zero product exit. Compare it with + # the same generation payload rather than baking the numeric code into the + # launcher contract. + direct_error = run([payload, "cli"], env) + launched_error = run([launcher, "cli"], env) + require(direct_error.returncode != 0, "CLI error control unexpectedly succeeded") + require( + launched_error.returncode == direct_error.returncode, + "launcher changed the payload's non-zero exit status", + ) + require( + launched_error.stdout == direct_error.stdout + and launched_error.stderr == direct_error.stderr, + "launcher did not relay failing CLI stdout/stderr byte-for-byte", + ) + + with McpServer(str(launcher), cache_dir=str(cache), extra_env=env) as server: + init = server.initialize(timeout=30) + require("result" in init, "launcher did not relay MCP initialize response") + require(server.tools_list(timeout=30), "launcher did not relay MCP tools/list") + print("PASS: launcher relayed MCP stdio and exact child exit/status streams") + + +def assert_current_fail_closed(launcher, current, original, env): + incompatible = bytearray(original) + struct.pack_into(" " + ) + return 2 + + source_launcher = pathlib.Path(sys.argv[1]).resolve() + source_payload = pathlib.Path(sys.argv[2]).resolve() + abi_mismatch_launcher = pathlib.Path(sys.argv[3]).resolve() + if not source_launcher.is_file(): + print("PRECONDITION: launcher not found: %s" % source_launcher) + return 2 + if not source_payload.is_file(): + print("PRECONDITION: payload not found: %s" % source_payload) + return 2 + if not abi_mismatch_launcher.is_file(): + print("PRECONDITION: ABI mismatch launcher not found: %s" % abi_mismatch_launcher) + return 2 + if source_launcher.samefile(source_payload): + print("PRECONDITION: launcher and payload must be distinct executables") + return 2 + + work = pathlib.Path(tempfile.mkdtemp(prefix="cbm_win_launcher_")) + path_snapshot = path_registry_snapshot() + try: + env, cache = isolated_environment(work) + assert_release_descriptor(source_launcher, source_payload, env, cache) + assert_portable_mutations_refuse(source_payload, env, cache, work) + assert_untrusted_ancestor_acl_rejected( + source_launcher, source_payload, env, work + ) + + _, portable_payload = copy_portable_pair( + source_launcher, source_payload, work / "portable-install" + ) + managed_dir = work / "managed café_日本語 with spaces" + # Reproduce the only durable partial state in a fresh launcher-first + # publication: the canonical launcher rename completed, then the + # process died before current-v1 was published. A retry must repair an + # exact private candidate, while the native preflight still rejects an + # unrelated conflicting launcher. + managed_dir.mkdir(parents=True) + interrupted_launcher = managed_dir / "codebase-memory-mcp.exe" + shutil.copy2(abi_mismatch_launcher, interrupted_launcher) + conflict = run( + [ + portable_payload, + "install", + "--yes", + "--force", + "--skip-config", + "--dir", + managed_dir, + ], + env, + timeout=30, + ) + require( + conflict.returncode != 0, + "fresh install accepted an unrelated launcher-only conflict", + ) + require( + sha256_file(interrupted_launcher) == sha256_file(abi_mismatch_launcher), + "rejected launcher-only conflict was modified", + ) + shutil.copy2(source_launcher, interrupted_launcher) + install = run( + [ + portable_payload, + "install", + "--yes", + "--force", + "--skip-config", + "--dir", + managed_dir, + ], + env, + timeout=60, + ) + require(install.returncode == 0, "managed install failed: %s" % output_text(install)[-800:]) + + launcher = managed_dir / "codebase-memory-mcp.exe" + require(launcher.is_file(), "managed custom path has no canonical launcher") + current, current_bytes, generation_payload = find_and_validate_current(managed_dir) + print( + "PASS: interrupted fresh install recovered into a strict current-v1 " + "generation layout" + ) + + assert_launcher_relay(launcher, generation_payload, env, cache) + assert_current_fail_closed(launcher, current, current_bytes, env) + assert_managed_update_dry_run_skips_capability_probe( + launcher, managed_dir, env + ) + assert_managed_update_rejects_unrunnable_launcher_before_drain( + source_payload, launcher, managed_dir, env, cache, work + ) + assert_managed_update_rejects_cross_abi_pair_before_drain( + abi_mismatch_launcher, + source_payload, + launcher, + managed_dir, + env, + cache, + work, + ) + assert_failed_update_rolls_back_new_generation( + source_launcher, + source_payload, + launcher, + managed_dir, + env, + work, + ) + assert_managed_update( + source_launcher, + source_payload, + launcher, + managed_dir, + env, + work, + ) + assert_launcher_death_contains_payload(launcher, env, cache) + assert_immediate_parent_death_contains_launcher_tree( + launcher, env, work + ) + assert_capability_probe_fail_hard( + source_launcher, source_payload, launcher, env, cache, work + ) + assert_managed_uninstall(launcher, managed_dir, env) + print("\nGREEN: permanent Windows launcher contract honored.") + return 0 + except (GuardFailure, McpError, OSError, subprocess.SubprocessError) as exc: + print("\nRED: %s" % exc) + return 1 + finally: + try: + path_registry_restore(path_snapshot) + except OSError as exc: + print("CLEANUP WARNING: could not restore HKCU Environment\\Path: %s" % exc) + shutil.rmtree(work, ignore_errors=True) + + +if __name__ == "__main__": + sys.exit(main()) From 4693b625e035cca2c6f278e01cc6b1d2f74956c2 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 02:22:37 +0200 Subject: [PATCH 03/23] fix: stabilize cross-platform daemon smoke Signed-off-by: Martin Vogel --- scripts/security-fuzz.sh | 25 ++++++- scripts/smoke-test.sh | 33 +++------ scripts/test_mcp_interactive.py | 103 +++++++++++++++++++++++++--- src/cli/cli.c | 2 + tests/test_security_fuzz_harness.sh | 6 +- 5 files changed, 134 insertions(+), 35 deletions(-) diff --git a/scripts/security-fuzz.sh b/scripts/security-fuzz.sh index 9d74a175e..a396f609f 100755 --- a/scripts/security-fuzz.sh +++ b/scripts/security-fuzz.sh @@ -79,6 +79,29 @@ test_payload() { fi } +# Mutating operations are session-owned and intentionally cancel on EOF. Keep +# stdin open until the invalid-path response and a subsequent ping arrive so +# this case tests robustness without racing the disconnect contract. +test_invalid_index_interactive() { + local name="index nonexistent path" + TOTAL=$((TOTAL + 1)) + local tmpoutput="$FUZZ_TMPDIR/output_${TOTAL}.jsonl" + local ec=0 + + HOME="$FUZZ_HOME" CBM_CACHE_DIR="$FUZZ_CACHE" \ + python3 "$(dirname "$0")/test_mcp_interactive.py" "$BINARY" \ + --scenario invalid-index --repo-path /nonexistent/path/abc123 \ + --response-timeout 30 --exit-timeout 15 > "$tmpoutput" 2>&1 || ec=$? + + if [[ $ec -eq 0 ]]; then + PASS=$((PASS + 1)) + else + echo "FAIL: $name — interactive session failed" + sed -n '1,20p' "$tmpoutput" + FAIL=$((FAIL + 1)) + fi +} + echo "" echo "--- Malformed JSON ---" @@ -124,7 +147,7 @@ test_payload "path traversal in qualified_name" '{"jsonrpc":"2.0","id":2,"method test_payload "shell injection in file_pattern" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_code","arguments":{"pattern":"test","file_pattern":"*.py'\'' ; cat /etc/passwd #"}}}' # index_repository with non-existent path (should return error, not crash) -test_payload "index nonexistent path" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"index_repository","arguments":{"repo_path":"/nonexistent/path/abc123"}}}' +test_invalid_index_interactive # Negative/zero values for numeric params test_payload "negative limit" '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search_graph","arguments":{"name_pattern":"test","limit":-1}}}' diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 9aa15e814..04e6225f5 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -559,30 +559,15 @@ fi echo "" echo "=== Phase 4: security checks ===" -# 4a: Clean shutdown — binary must exit within 5 seconds after EOF +# 4a: Clean shutdown — wait through bounded cold bootstrap, then require the +# initialized frontend to exit promptly after EOF. echo "Testing clean shutdown..." -SHUTDOWN_TMPDIR=$(mktemp -d) -cat > "$SHUTDOWN_TMPDIR/input.jsonl" << 'JSONL' -{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}} -JSONL - -# Run binary with EOF and wait up to 5 seconds (portable — no `timeout` needed) -"$BINARY" < "$SHUTDOWN_TMPDIR/input.jsonl" > /dev/null 2>&1 & -SHUTDOWN_PID=$! -SHUTDOWN_WAITED=0 -while kill -0 "$SHUTDOWN_PID" 2>/dev/null && [ "$SHUTDOWN_WAITED" -lt 5 ]; do - sleep 1 - SHUTDOWN_WAITED=$((SHUTDOWN_WAITED + 1)) -done -if kill -0 "$SHUTDOWN_PID" 2>/dev/null; then - kill "$SHUTDOWN_PID" 2>/dev/null || true - wait "$SHUTDOWN_PID" 2>/dev/null || true - rm -rf "$SHUTDOWN_TMPDIR" - echo "FAIL: binary did not exit within 5 seconds after EOF" +if ! python3 "$REPO_ROOT/scripts/test_mcp_interactive.py" \ + "$BINARY" --scenario initialize --repo-path "$TMPDIR" \ + --response-timeout 30 --exit-timeout 8 > /dev/null; then + echo "FAIL: initialized binary did not exit within 8 seconds after EOF" exit 1 fi -wait "$SHUTDOWN_PID" 2>/dev/null || true -rm -rf "$SHUTDOWN_TMPDIR" echo "OK: clean shutdown" # 4b: No residual processes (skip on Windows/MSYS2 where pgrep may not work) @@ -912,9 +897,9 @@ if ! grep -q '"id":3' "$MCP_SC_OUTPUT"; then fi echo "OK: search_code v2 via MCP" -# 7b: get_code_snippet via MCP -if ! grep -q '"id":4' "$MCP_SC_OUTPUT"; then - echo "FAIL: get_code_snippet response (id:4) missing" +# 7b: search_graph discovery + get_code_snippet via MCP +if ! grep -q '"id":4' "$MCP_SC_OUTPUT" || ! grep -q '"id":5' "$MCP_SC_OUTPUT"; then + echo "FAIL: search_graph/get_code_snippet response (id:4 or id:5) missing" exit 1 fi echo "OK: get_code_snippet via MCP" diff --git a/scripts/test_mcp_interactive.py b/scripts/test_mcp_interactive.py index f0c81ef55..5421bd514 100644 --- a/scripts/test_mcp_interactive.py +++ b/scripts/test_mcp_interactive.py @@ -65,6 +65,7 @@ def wait_response( responses: "queue.Queue[dict[str, Any]]", request_id: int, timeout: float, + accept_tool_error: bool = False, ) -> dict[str, Any]: deadline = time.monotonic() + timeout while True: @@ -81,14 +82,23 @@ def wait_response( raise SmokeFailure(f"timed out waiting for MCP response id={request_id}") from error if message.get("id") != request_id: continue + if "result" not in message and "error" not in message: + # An echoed request is not a JSON-RPC response and must never count + # as proof that the server dispatched the request. + continue if "error" in message: raise SmokeFailure( f"MCP response id={request_id} returned JSON-RPC error: {message['error']!r}" ) result = message.get("result") - if isinstance(result, dict) and result.get("isError") is True: + if ( + isinstance(result, dict) + and result.get("isError") is True + and not accept_tool_error + ): + rendered = json.dumps(message, separators=(",", ":"), ensure_ascii=False) raise SmokeFailure( - f"MCP tool response id={request_id} reported isError=true" + f"MCP tool response id={request_id} reported isError=true: {rendered}" ) return message @@ -100,6 +110,7 @@ def request( method: str, params: dict[str, Any], timeout: float, + accept_tool_error: bool = False, ) -> dict[str, Any]: send( process, @@ -110,7 +121,7 @@ def request( "params": params, }, ) - return wait_response(process, responses, request_id, timeout) + return wait_response(process, responses, request_id, timeout, accept_tool_error) def run_scenario( @@ -125,7 +136,30 @@ def run_scenario( process, {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}, ) - request( + if scenario == "initialize": + return + if scenario == "invalid-index": + invalid_index_response = request( + process, + responses, + 2, + "tools/call", + { + "name": "index_repository", + "arguments": {"repo_path": repo_path, "mode": "fast"}, + }, + timeout, + accept_tool_error=True, + ) + invalid_index_result = invalid_index_response.get("result") + if ( + not isinstance(invalid_index_result, dict) + or invalid_index_result.get("isError") is not True + ): + raise SmokeFailure("index_repository unexpectedly accepted a nonexistent path") + request(process, responses, 3, "ping", {}, timeout) + return + index_response = request( process, responses, 2, @@ -136,13 +170,21 @@ def run_scenario( }, timeout, ) + index_result = index_response.get("result") + structured = index_result.get("structuredContent") if isinstance(index_result, dict) else None + project = structured.get("project") if isinstance(structured, dict) else None + if not isinstance(project, str) or not project: + raise SmokeFailure("index_repository response did not identify the indexed project") if scenario == "roundtrip": request( process, responses, 3, "tools/call", - {"name": "search_graph", "arguments": {"name_pattern": "compute"}}, + { + "name": "search_graph", + "arguments": {"project": project, "name_pattern": "compute"}, + }, timeout, ) return @@ -153,18 +195,57 @@ def run_scenario( "tools/call", { "name": "search_code", - "arguments": {"pattern": "compute", "mode": "compact", "limit": 3}, + "arguments": { + "project": project, + "pattern": "compute", + "mode": "compact", + "limit": 3, + }, }, timeout, ) - request( + discovery_response = request( process, responses, 4, "tools/call", + { + "name": "search_graph", + "arguments": { + "project": project, + "name_pattern": "compute", + "format": "json", + "limit": 1, + }, + }, + timeout, + ) + discovery_result = discovery_response.get("result") + discovery_structured = ( + discovery_result.get("structuredContent") + if isinstance(discovery_result, dict) + else None + ) + matches = ( + discovery_structured.get("results") + if isinstance(discovery_structured, dict) + else None + ) + qualified_name = ( + matches[0].get("qualified_name") + if isinstance(matches, list) and matches and isinstance(matches[0], dict) + else None + ) + if not isinstance(qualified_name, str) or not qualified_name: + raise SmokeFailure("search_graph did not discover compute's qualified name") + request( + process, + responses, + 5, + "tools/call", { "name": "get_code_snippet", - "arguments": {"qualified_name": "compute"}, + "arguments": {"project": project, "qualified_name": qualified_name}, }, timeout, ) @@ -184,7 +265,11 @@ def stop_process(process: subprocess.Popen[bytes]) -> None: def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("binary") - parser.add_argument("--scenario", choices=("roundtrip", "advanced"), required=True) + parser.add_argument( + "--scenario", + choices=("initialize", "invalid-index", "roundtrip", "advanced"), + required=True, + ) parser.add_argument("--repo-path", required=True) parser.add_argument("--response-timeout", type=float, default=30.0) parser.add_argument("--exit-timeout", type=float, default=15.0) diff --git a/src/cli/cli.c b/src/cli/cli.c index 9d351beee..4d2364a4e 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -1264,6 +1264,7 @@ static int cli_activation_transaction_finalize_close( return cli_activation_transaction_abort(transaction_io); } +#ifndef _WIN32 static void cli_activation_transaction_finalize_committed_or_fail_stop( cbm_activation_transaction_t **transaction_io, const char *component) { if (!transaction_io || !*transaction_io) { @@ -1296,6 +1297,7 @@ static int cli_activation_transaction_commit_removal(cbm_activation_transaction_ ? CLI_OK : CLI_ERR; } +#endif static bool cli_activation_transaction_expected_build(cbm_activation_transaction_t *transaction, cli_binary_validator_t *validator) { diff --git a/tests/test_security_fuzz_harness.sh b/tests/test_security_fuzz_harness.sh index b9bb78707..f4ea1583d 100644 --- a/tests/test_security_fuzz_harness.sh +++ b/tests/test_security_fuzz_harness.sh @@ -52,7 +52,11 @@ printf '%s\t%s\n' "${HOME-}" "${CBM_CACHE_DIR-}" >> "$CBM_FUZZ_ENV_PROBE" while IFS= read -r line; do id=$(printf '%s\n' "$line" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*\([0-9][0-9]*\).*/\1/p') if [[ -n "$id" ]]; then - printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" + if [[ "$line" == *'"name":"index_repository"'* ]]; then + printf '{"jsonrpc":"2.0","id":%s,"result":{"isError":true}}\n' "$id" + else + printf '{"jsonrpc":"2.0","id":%s,"result":{}}\n' "$id" + fi fi done EOF From cb896a3de4fb79ddedc89e6479e2dc99e401198e Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 02:43:14 +0200 Subject: [PATCH 04/23] fix: harden cross-platform daemon startup Signed-off-by: Martin Vogel --- scripts/security-fuzz.sh | 2 +- scripts/smoke-test.sh | 10 +++++----- scripts/test_mcp_interactive.py | 2 +- src/cli/cli.c | 2 ++ src/main.c | 5 +++-- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/scripts/security-fuzz.sh b/scripts/security-fuzz.sh index a396f609f..52ed25c54 100755 --- a/scripts/security-fuzz.sh +++ b/scripts/security-fuzz.sh @@ -91,7 +91,7 @@ test_invalid_index_interactive() { HOME="$FUZZ_HOME" CBM_CACHE_DIR="$FUZZ_CACHE" \ python3 "$(dirname "$0")/test_mcp_interactive.py" "$BINARY" \ --scenario invalid-index --repo-path /nonexistent/path/abc123 \ - --response-timeout 30 --exit-timeout 15 > "$tmpoutput" 2>&1 || ec=$? + --response-timeout 45 --exit-timeout 15 > "$tmpoutput" 2>&1 || ec=$? if [[ $ec -eq 0 ]]; then PASS=$((PASS + 1)) diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 04e6225f5..be84d1fbc 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -564,7 +564,7 @@ echo "=== Phase 4: security checks ===" echo "Testing clean shutdown..." if ! python3 "$REPO_ROOT/scripts/test_mcp_interactive.py" \ "$BINARY" --scenario initialize --repo-path "$TMPDIR" \ - --response-timeout 30 --exit-timeout 8 > /dev/null; then + --response-timeout 45 --exit-timeout 8 > /dev/null; then echo "FAIL: initialized binary did not exit within 8 seconds after EOF" exit 1 fi @@ -601,7 +601,7 @@ echo "=== Phase 5: MCP stdio transport (agent handshake) ===" # Helper: run binary in background with input, wait up to N seconds, collect output mcp_run() { - local input_file="$1" output_file="$2" max_wait="${3:-10}" + local input_file="$1" output_file="$2" max_wait="${3:-45}" "$BINARY" < "$input_file" > "$output_file" 2>/dev/null & local pid=$! local waited=0 @@ -621,7 +621,7 @@ cat > "$MCP_INPUT" << 'MCPEOF' {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} MCPEOF -mcp_run "$MCP_INPUT" "$MCP_OUTPUT" 10 +mcp_run "$MCP_INPUT" "$MCP_OUTPUT" 45 # 5a: Verify initialize response (id:1) if ! grep -q '"id":1' "$MCP_OUTPUT"; then @@ -706,7 +706,7 @@ TOOLS_MSG='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' TOOLS_LEN=${#TOOLS_MSG} printf "Content-Length: %d\r\n\r\n%s" "$TOOLS_LEN" "$TOOLS_MSG" >> "$MCP_CL_INPUT" -mcp_run "$MCP_CL_INPUT" "$MCP_CL_OUTPUT" 10 +mcp_run "$MCP_CL_INPUT" "$MCP_CL_OUTPUT" 45 if ! grep -q '"id":1' "$MCP_CL_OUTPUT" || ! grep -q '"id":2' "$MCP_CL_OUTPUT"; then echo "FAIL: Content-Length framed handshake did not produce both responses" @@ -3154,7 +3154,7 @@ echo "=== Phase 16: stdio server leaves no orphan after shutdown ===" "$BINARY" < /dev/null > /dev/null 2>&1 & SHUT_SRV_PID=$! SHUT_GONE=0 -for _ in $(seq 1 60); do # bounded ~6s wait (60 × 0.1s) +for _ in $(seq 1 400); do # bounded ~40s wait (400 × 0.1s) if ! kill -0 "$SHUT_SRV_PID" 2>/dev/null; then SHUT_GONE=1; break; fi sleep 0.1 done diff --git a/scripts/test_mcp_interactive.py b/scripts/test_mcp_interactive.py index 5421bd514..4db2a902c 100644 --- a/scripts/test_mcp_interactive.py +++ b/scripts/test_mcp_interactive.py @@ -271,7 +271,7 @@ def main() -> int: required=True, ) parser.add_argument("--repo-path", required=True) - parser.add_argument("--response-timeout", type=float, default=30.0) + parser.add_argument("--response-timeout", type=float, default=45.0) parser.add_argument("--exit-timeout", type=float, default=15.0) args = parser.parse_args() diff --git a/src/cli/cli.c b/src/cli/cli.c index 4d2364a4e..7641a1228 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -1191,6 +1191,7 @@ void cbm_cli_set_activation_cleanup_failure_for_test(bool enabled); int cbm_cli_activation_abort_cleanup_probe_for_test(void); #endif +#if !defined(_WIN32) || defined(CBM_CLI_ENABLE_TEST_API) static void cli_activation_transaction_abort_or_fail_stop( cbm_activation_transaction_t **transaction_io, const char *component) { int cleanup_status = cli_activation_transaction_abort(transaction_io); @@ -1204,6 +1205,7 @@ static void cli_activation_transaction_abort_or_fail_stop( component ? component : "activation_transaction_cleanup"); } } +#endif #ifdef CBM_CLI_ENABLE_TEST_API void cbm_cli_set_activation_cleanup_failure_for_test(bool enabled) { diff --git a/src/main.c b/src/main.c index 0c5dd7cab..d76546993 100644 --- a/src/main.c +++ b/src/main.c @@ -37,6 +37,7 @@ enum { MAIN_PATH_CAP = 4096, MAIN_CONNECT_TIMEOUT_MS = 1000, MAIN_STARTUP_TIMEOUT_MS = 10000, + MAIN_MCP_STARTUP_TIMEOUT_MS = 30000, MAIN_REQUEST_TIMEOUT_MS = 24 * 60 * 60 * 1000, MAIN_HOOK_CONNECT_TIMEOUT_MS = 250, MAIN_HOOK_STARTUP_TIMEOUT_MS = 1500, @@ -1631,7 +1632,7 @@ int main(int argc, char **argv) { ? cbm_version_cohort_acquire(client_cohort_manager, &identity, main_deadline_after(role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? MAIN_HOOK_STARTUP_TIMEOUT_MS - : MAIN_STARTUP_TIMEOUT_MS), + : MAIN_MCP_STARTUP_TIMEOUT_MS), &client_cohort_lease, &client_cohort_conflict) : CBM_VERSION_COHORT_IO; if (client_cohort_status != CBM_VERSION_COHORT_OK) { @@ -1657,7 +1658,7 @@ int main(int argc, char **argv) { .connect_timeout_ms = role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? MAIN_HOOK_CONNECT_TIMEOUT_MS : MAIN_CONNECT_TIMEOUT_MS, .startup_timeout_ms = role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? MAIN_HOOK_STARTUP_TIMEOUT_MS - : MAIN_STARTUP_TIMEOUT_MS, + : MAIN_MCP_STARTUP_TIMEOUT_MS, }; cbm_daemon_bootstrap_result_t bootstrap_result; cbm_daemon_bootstrap_status_t bootstrap_status = From b8a75d142c03e4981a836e410cf3ef2b57e3fabd Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 03:16:37 +0200 Subject: [PATCH 05/23] fix: stabilize cross-platform daemon launch Signed-off-by: Martin Vogel --- .github/workflows/_smoke.yml | 4 +- .github/workflows/pr.yml | 9 ++- scripts/smoke-test.sh | 77 ++++++++++++++--------- src/daemon/bootstrap.c | 88 +++++++++++++++++++++++++++ src/foundation/lock_registry.c | 8 ++- tests/test_windows_bundle_contract.sh | 67 +++++++++++++------- 6 files changed, 197 insertions(+), 56 deletions(-) diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index fd1025e13..1104c4309 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -239,7 +239,9 @@ jobs: - name: Smoke test shell: msys2 {0} - run: scripts/smoke-test.sh ./codebase-memory-mcp.exe + run: | + SMOKE_TEMP_ROOT="$(cygpath -u "$RUNNER_TEMP")" \ + scripts/smoke-test.sh ./codebase-memory-mcp.exe env: SMOKE_DOWNLOAD_URL: http://127.0.0.1:18080 SMOKE_UPDATE_FIXTURE_DIR: /tmp/smoke-server diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e33482003..969170384 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -114,13 +114,18 @@ jobs: shell: msys2 {0} run: | scripts/build.sh CC=clang CXX=clang++ - SMOKE_DIR="$(mktemp -d)" + # MSYS2 /tmp is intentionally shared. The launcher correctly rejects + # a bundle below that writable ancestor, so stage in the runner's + # account-private native temp directory instead. + SMOKE_ROOT="$(cygpath -u "$RUNNER_TEMP")" + SMOKE_DIR="$(mktemp -d "$SMOKE_ROOT/cbm-pr-smoke.XXXXXX")" trap 'rm -rf "$SMOKE_DIR"' EXIT cp build/c/codebase-memory-mcp-launcher.exe \ "$SMOKE_DIR/codebase-memory-mcp.exe" cp build/c/codebase-memory-mcp.exe \ "$SMOKE_DIR/codebase-memory-mcp.payload.exe" - scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" + SMOKE_TEMP_ROOT="$SMOKE_ROOT" \ + scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" ci-ok: # The one required context (besides dco) — fails unless every PR stage diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index be84d1fbc..abc7ae19f 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -19,6 +19,22 @@ if [ -n "$SMOKE_MODE" ] && [ "$SMOKE_MODE" != "--agent-config-only" ]; then fi REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)" +smoke_mktemp_file() { + if [ -n "${SMOKE_TEMP_ROOT:-}" ]; then + mktemp "$SMOKE_TEMP_ROOT/cbm-smoke.XXXXXX" + else + mktemp + fi +} + +smoke_mktemp_dir() { + if [ -n "${SMOKE_TEMP_ROOT:-}" ]; then + mktemp -d "$SMOKE_TEMP_ROOT/cbm-smoke.XXXXXX" + else + mktemp -d + fi +} + # Windows release archives contain a small permanent launcher plus a portable # payload. Whenever a smoke fixture copies the launcher, keep the payload next # to it so the copied fixture remains a complete portable bundle. @@ -39,7 +55,7 @@ copy_smoke_binary() { fi } -TMPDIR=$(mktemp -d) +TMPDIR=$(smoke_mktemp_dir) DRYRUN_HOME="" # On MSYS2/Windows, convert POSIX path to native Windows path for the binary if command -v cygpath &>/dev/null; then @@ -47,12 +63,17 @@ if command -v cygpath &>/dev/null; then fi trap 'rm -rf "$TMPDIR" "${DRYRUN_HOME:-}"' EXIT -CLI_STDERR=$(mktemp) +CLI_STDERR=$(smoke_mktemp_file) cli() { "$BINARY" cli "$@" 2>"$CLI_STDERR"; } echo "=== Phase 1: version ===" -OUTPUT=$("$BINARY" --version 2>&1) +VERSION_STATUS=0 +OUTPUT=$("$BINARY" --version 2>&1) || VERSION_STATUS=$? echo "$OUTPUT" +if [ "$VERSION_STATUS" -ne 0 ]; then + echo "FAIL: --version exited with status $VERSION_STATUS" + exit 1 +fi if ! echo "$OUTPUT" | grep -qE 'v?[0-9]+\.[0-9]+|dev'; then echo "FAIL: unexpected version output" exit 1 @@ -496,7 +517,7 @@ fi echo "OK B4: STDIN input resolves, no deprecation warning" # B5: --args-file — JSON read from a file resolves; must NOT warn deprecated. -IM_ARGS_FILE=$(mktemp) +IM_ARGS_FILE=$(smoke_mktemp_file) echo "{\"project\":\"$PROJECT\"}" > "$IM_ARGS_FILE" if ! IM_AF=$(cli get_graph_schema --args-file "$IM_ARGS_FILE"); then echo "FAIL B5: get_graph_schema --args-file exited non-zero"; cat "$CLI_STDERR"; rm -f "$IM_ARGS_FILE"; exit 1 @@ -613,8 +634,8 @@ mcp_run() { wait "$pid" 2>/dev/null || true } -MCP_INPUT=$(mktemp) -MCP_OUTPUT=$(mktemp) +MCP_INPUT=$(smoke_mktemp_file) +MCP_OUTPUT=$(smoke_mktemp_file) cat > "$MCP_INPUT" << 'MCPEOF' {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"smoke-test","version":"1.0"}}} {"jsonrpc":"2.0","method":"notifications/initialized"} @@ -666,7 +687,7 @@ rm -f "$MCP_INPUT" "$MCP_OUTPUT" # 5e: MCP tool call via JSON-RPC (index + search round-trip) echo "" echo "--- Phase 5e: MCP tool call round-trip ---" -MCP_TOOL_OUTPUT=$(mktemp) +MCP_TOOL_OUTPUT=$(smoke_mktemp_file) if ! python3 "$REPO_ROOT/scripts/test_mcp_interactive.py" \ "$BINARY" --scenario roundtrip --repo-path "$TMPDIR" \ @@ -695,8 +716,8 @@ echo "OK: MCP tool call round-trip (index + search) succeeded" # 5f: Content-Length framing (OpenCode compatibility) echo "" echo "--- Phase 5f: Content-Length framing ---" -MCP_CL_INPUT=$(mktemp) -MCP_CL_OUTPUT=$(mktemp) +MCP_CL_INPUT=$(smoke_mktemp_file) +MCP_CL_OUTPUT=$(smoke_mktemp_file) INIT_MSG='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"cl-test","version":"1.0"}}}' INIT_LEN=${#INIT_MSG} @@ -721,7 +742,7 @@ rm -f "$MCP_CL_INPUT" "$MCP_CL_OUTPUT" "$MCP_TOOL_OUTPUT" echo "" echo "=== Phase 6: CLI subcommands ===" -DRYRUN_HOME=$(mktemp -d) +DRYRUN_HOME=$(smoke_mktemp_dir) DRYRUN_CACHE="$DRYRUN_HOME/.cache/codebase-memory-mcp" mkdir -p "$DRYRUN_CACHE" \ "$DRYRUN_HOME/.local/bin" \ @@ -827,7 +848,7 @@ echo "OK: config set/get/reset round-trip" # Simulates the update command's Steps 3-6: extract, replace, verify. # Uses a copy of the test binary as the "downloaded" version. echo "--- Phase 6e: simulated binary replacement ---" -REPLACE_DIR=$(mktemp -d) +REPLACE_DIR=$(smoke_mktemp_dir) INSTALL_DIR="$REPLACE_DIR/install" mkdir -p "$INSTALL_DIR" @@ -881,7 +902,7 @@ echo "=== Phase 7: MCP advanced tool calls ===" # 7a: search_code via MCP (graph-augmented v2) echo "--- Phase 7a: search_code via MCP ---" -MCP_SC_OUTPUT=$(mktemp) +MCP_SC_OUTPUT=$(smoke_mktemp_file) if ! python3 "$REPO_ROOT/scripts/test_mcp_interactive.py" \ "$BINARY" --scenario advanced --repo-path "$TMPDIR" \ > "$MCP_SC_OUTPUT"; then @@ -914,7 +935,7 @@ echo "=== Phase 8: agent config install E2E ===" # Set up an isolated HOME. Directory-only agents get only the root required for # detection; CLI-detected agents use stubs below so install must create their # config parents from scratch. -FAKE_HOME=$(mktemp -d) +FAKE_HOME=$(smoke_mktemp_dir) mkdir -p "$FAKE_HOME/.claude" mkdir -p "$FAKE_HOME/.codex" mkdir -p "$FAKE_HOME/.gemini/antigravity-cli" @@ -2474,7 +2495,7 @@ echo "--- Phase 9b: adversarial install/uninstall tests ---" # Note: cbm_find_cli searches hardcoded paths (/usr/local/bin, /opt/homebrew/bin) # so PATH-based agents like aider may still be detected. We verify the install # completes without crash and prints "Detected agents:" line. -EMPTY_HOME=$(mktemp -d) +EMPTY_HOME=$(smoke_mktemp_dir) mkdir -p "$EMPTY_HOME/.local/bin" INSTALL_OUT=$(HOME="$EMPTY_HOME" "$BINARY" install -y 2>&1) || true if ! echo "$INSTALL_OUT" | grep -qi 'detected agents'; then @@ -2485,7 +2506,7 @@ echo "OK 9b-1: install with minimal agents exits cleanly" rm -rf "$EMPTY_HOME" # 9b-2: Install twice (idempotent) -IDEM_HOME=$(mktemp -d) +IDEM_HOME=$(smoke_mktemp_dir) mkdir -p "$IDEM_HOME/.claude" "$IDEM_HOME/.local/bin" copy_smoke_binary "$IDEM_HOME/.local/bin/codebase-memory-mcp" HOME="$IDEM_HOME" "$BINARY" install -y 2>&1 > /dev/null || true @@ -2508,14 +2529,14 @@ echo "OK 9b-2: double install is idempotent" rm -rf "$IDEM_HOME" # 9b-3: Uninstall without prior install -CLEAN_HOME=$(mktemp -d) +CLEAN_HOME=$(smoke_mktemp_dir) mkdir -p "$CLEAN_HOME/.claude" "$CLEAN_HOME/.local/bin" UNINSTALL_OUT=$(HOME="$CLEAN_HOME" "$BINARY" uninstall -y -n 2>&1) || true echo "OK 9b-3: uninstall without install doesn't crash" rm -rf "$CLEAN_HOME" # 9b-4: Install over corrupt JSON -CORRUPT_HOME=$(mktemp -d) +CORRUPT_HOME=$(smoke_mktemp_dir) mkdir -p "$CORRUPT_HOME/.claude" "$CORRUPT_HOME/.local/bin" copy_smoke_binary "$CORRUPT_HOME/.local/bin/codebase-memory-mcp" echo '{invalid json here' > "$CORRUPT_HOME/.claude.json" @@ -2525,7 +2546,7 @@ echo "OK 9b-4: install over corrupt JSON doesn't crash" rm -rf "$CORRUPT_HOME" # 9b-8: Double uninstall -DBL_HOME=$(mktemp -d) +DBL_HOME=$(smoke_mktemp_dir) mkdir -p "$DBL_HOME/.claude" "$DBL_HOME/.local/bin" copy_smoke_binary "$DBL_HOME/.local/bin/codebase-memory-mcp" HOME="$DBL_HOME" "$BINARY" install -y 2>&1 > /dev/null || true @@ -2560,7 +2581,7 @@ fi echo "" echo "=== Phase 10: binary security E2E ===" -SECURITY_DIR=$(mktemp -d) +SECURITY_DIR=$(smoke_mktemp_dir) SECURITY_BIN="$SECURITY_DIR/codebase-memory-mcp" copy_smoke_binary "$SECURITY_BIN" chmod 755 "$SECURITY_BIN" @@ -2655,7 +2676,7 @@ echo "" echo "=== Phase 11: process kill E2E ===" # Start MCP server in background -MCP_KILL_INPUT=$(mktemp) +MCP_KILL_INPUT=$(smoke_mktemp_file) echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"kill-test","version":"1.0"}}}' > "$MCP_KILL_INPUT" "$BINARY" < "$MCP_KILL_INPUT" > /dev/null 2>&1 & KILL_PID=$! @@ -2698,7 +2719,7 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then exit 1 fi fi - UPDATE_HOME=$(mktemp -d) + UPDATE_HOME=$(smoke_mktemp_dir) mkdir -p "$UPDATE_HOME/.claude" "$UPDATE_HOME/.local/bin" if [[ "$BINARY" == *.exe ]]; then copy_smoke_binary "$UPDATE_HOME/.local/bin/codebase-memory-mcp.exe" @@ -2801,7 +2822,7 @@ sys.exit(0) else # Local mode: basic binary replacement test (no download) - UPDATE_DIR=$(mktemp -d) + UPDATE_DIR=$(smoke_mktemp_dir) mkdir -p "$UPDATE_DIR/install" copy_smoke_binary "$UPDATE_DIR/install/codebase-memory-mcp" chmod 755 "$UPDATE_DIR/install/codebase-memory-mcp" @@ -2829,7 +2850,7 @@ if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then echo "" echo "=== Phase 12: download + checksum + extraction E2E ===" -DL_DIR=$(mktemp -d) +DL_DIR=$(smoke_mktemp_dir) # Detect platform for archive name DL_OS=$(uname -s | tr 'A-Z' 'a-z') @@ -2977,8 +2998,8 @@ echo "=== Phase 13: install script E2E ===" if [ "$DL_OS" != "windows" ] && [ -f "$REPO_ROOT/install.sh" ]; then echo "--- Phase 13: install.sh E2E ---" - INSTALL_TEST_HOME=$(mktemp -d) - INSTALL_TEST_DIR=$(mktemp -d) + INSTALL_TEST_HOME=$(smoke_mktemp_dir) + INSTALL_TEST_DIR=$(smoke_mktemp_dir) mkdir -p "$INSTALL_TEST_HOME/.claude" mkdir -p "$INSTALL_TEST_HOME/.local/bin" @@ -3041,8 +3062,8 @@ if [ "$DL_OS" != "windows" ] && [ -f "$REPO_ROOT/install.sh" ]; then elif [ -f "$REPO_ROOT/install.ps1" ] && command -v powershell.exe &>/dev/null; then echo "--- Phase 13: install.ps1 E2E (Windows) ---" - PS1_TEST_HOME=$(mktemp -d) - PS1_TEST_DIR=$(mktemp -d) + PS1_TEST_HOME=$(smoke_mktemp_dir) + PS1_TEST_DIR=$(smoke_mktemp_dir) mkdir -p "$PS1_TEST_HOME/.claude" # Convert MSYS paths to Windows paths for PowerShell @@ -3100,7 +3121,7 @@ echo "" echo "=== Phase 15: UI HTTP server ===" UI_PORT=19876 -UI_INPUT=$(mktemp) +UI_INPUT=$(smoke_mktemp_file) "$BINARY" --port "$UI_PORT" < "$UI_INPUT" > /dev/null 2>&1 & UI_PID=$! sleep 1 diff --git a/src/daemon/bootstrap.c b/src/daemon/bootstrap.c index 794f0ebd2..8a74aca6b 100644 --- a/src/daemon/bootstrap.c +++ b/src/daemon/bootstrap.c @@ -29,6 +29,10 @@ #include #include #include +#ifdef __APPLE__ +#include +extern char **environ; +#endif #endif enum { @@ -713,6 +717,90 @@ static bool bootstrap_production_spawn(void *context, (void)CloseHandle(child.hProcess); return true; } +#elif defined(__APPLE__) +static bool bootstrap_darwin_spawn_state_init(posix_spawn_file_actions_t *actions, + posix_spawnattr_t *attributes) { + if (posix_spawn_file_actions_init(actions) != 0) { + return false; + } + bool actions_ready = + posix_spawn_file_actions_addopen(actions, STDIN_FILENO, "/dev/null", O_RDWR, 0) == 0 && + posix_spawn_file_actions_addopen(actions, STDOUT_FILENO, "/dev/null", O_RDWR, 0) == 0 && + posix_spawn_file_actions_addopen(actions, STDERR_FILENO, "/dev/null", O_RDWR, 0) == 0; + if (!actions_ready) { + (void)posix_spawn_file_actions_destroy(actions); + return false; + } + if (posix_spawnattr_init(attributes) != 0) { + (void)posix_spawn_file_actions_destroy(actions); + return false; + } + sigset_t empty; + (void)sigemptyset(&empty); + short flags = POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_CLOEXEC_DEFAULT; + if (posix_spawnattr_setsigmask(attributes, &empty) != 0 || + posix_spawnattr_setflags(attributes, flags) != 0) { + (void)posix_spawnattr_destroy(attributes); + (void)posix_spawn_file_actions_destroy(actions); + return false; + } + return true; +} + +static void bootstrap_daemon_grandchild(const cbm_daemon_bootstrap_launch_spec_t *spec, + const posix_spawn_file_actions_t *actions, + const posix_spawnattr_t *attributes) { + (void)umask(077); + pid_t daemon = 0; + int status = posix_spawn(&daemon, spec->executable_path, actions, attributes, + (char *const *)spec->argv, environ); + _exit(status == 0 ? 0 : 127); +} + +static bool bootstrap_production_spawn(void *context, + const cbm_daemon_bootstrap_launch_spec_t *spec) { + (void)context; + if (!spec || !spec->detached || spec->inherit_standard_handles || spec->use_shell) { + return false; + } + posix_spawn_file_actions_t actions; + posix_spawnattr_t attributes; + if (!bootstrap_darwin_spawn_state_init(&actions, &attributes)) { + return false; + } + pid_t first = fork(); + if (first < 0) { + (void)posix_spawnattr_destroy(&attributes); + (void)posix_spawn_file_actions_destroy(&actions); + return false; + } + if (first == 0) { + if (setsid() < 0) { + _exit(127); + } + pid_t grandchild = fork(); + if (grandchild < 0) { + _exit(127); + } + if (grandchild > 0) { + _exit(0); + } + /* Darwin commonly reports OPEN_MAX near one million. Iterating every + * possible descriptor after fork can consume the entire cold-start + * budget. CLOEXEC_DEFAULT closes the actual inherited set in the + * kernel while the double fork still provides daemon detachment. */ + bootstrap_daemon_grandchild(spec, &actions, &attributes); + } + + int status = 0; + pid_t waited; + do { + waited = waitpid(first, &status, 0); + } while (waited < 0 && errno == EINTR); + (void)posix_spawnattr_destroy(&attributes); + (void)posix_spawn_file_actions_destroy(&actions); + return waited == first && WIFEXITED(status) && WEXITSTATUS(status) == 0; +} #else static void bootstrap_child_close_fds(void) { long open_max = sysconf(_SC_OPEN_MAX); diff --git a/src/foundation/lock_registry.c b/src/foundation/lock_registry.c index 1f70f1cba..11439a0d2 100644 --- a/src/foundation/lock_registry.c +++ b/src/foundation/lock_registry.c @@ -198,11 +198,13 @@ static void lock_registry_notify(cbm_lock_registry_t *registry, cbm_private_file return; } cbm_lock_registry_stage_hook_fn hook = registry->stage_hook; + if (!hook) { + lock_registry_unlock(registry); + return; + } void *context = registry->stage_context; lock_registry_unlock(registry); - if (hook) { - hook(context, mode, stage); - } + hook(context, mode, stage); } static lock_registry_entry_t *lock_registry_find_entry(cbm_lock_registry_t *registry, diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index 5d69b511d..d85e21978 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -137,15 +137,16 @@ for relative, patterns in portable_cache_contracts.items(): # Portable package shims keep an immutable, exact-version launcher/payload pair. # The launcher resolves its adjacent payload for every native command. -# Concurrent first-run contenders publish launcher first and payload -# last; payload is therefore the readiness signal. They never pre-delete or -# roll back a destination, and may accept a collision only after proving that -# the winner is byte-identical to the authenticated staged source and runnable. +# Concurrent first-run contenders publish launcher first and payload last; +# payload is therefore the readiness signal. npm and Go serialize pair repair, +# preserve any complete runnable winner, and roll back only bytes proven to +# belong to their own transaction. PyPI uses identical-byte collision checks. immutable_pair_cache_contracts = { "pkg/npm/install.js": ( "const cacheNames = platform === 'windows'", - "const publishNames = platform === 'windows'", - "filesEqualSha256", + "const lock = acquireWindowsPairLock(destDir)", + "publishedDigests.set(name, stagedDigests.get(name))", + "if (digest && pathMatchesDigest(target, digest))", ), "pkg/pypi/src/codebase_memory_mcp/_cli.py": ( "cache_names = extraction_names", @@ -154,7 +155,9 @@ immutable_pair_cache_contracts = { ), "pkg/go/cmd/codebase-memory-mcp/main.go": ( "installWindowsPairAtomically(tmp, filepath.Dir(dest))", - "filesEqualSHA256", + "lock, err := acquireWindowsPairLock(dstDir)", + "publishedDigests[name] = stagedDigests[name]", + "if ok && pathMatchesSHA256(target, digest)", ), } for relative, needles in immutable_pair_cache_contracts.items(): @@ -172,14 +175,12 @@ for relative, needles in immutable_pair_cache_contracts.items(): npm_installer = read("pkg/npm/install.js") require( - re.search( - r"const publishNames = platform === 'windows'\s*\?\s*" - r"\[WINDOWS_LAUNCHER_NAME, WINDOWS_PAYLOAD_NAME\]", - npm_installer, - ) is not None - and "filesEqualSha256(stagedPaths.get(name), destination)" in npm_installer - and "fs.unlinkSync(destination)" not in npm_installer, - "npm must publish launcher then payload and only accept an identical collision", + "for (const name of [WINDOWS_LAUNCHER_NAME, WINDOWS_PAYLOAD_NAME])" in npm_installer + and "windowsPairReady(destDir, verifier)" in npm_installer + and "publishedDigests.set(name, stagedDigests.get(name))" in npm_installer + and "if (digest && pathMatchesDigest(target, digest))" in npm_installer, + "npm must publish launcher then payload, preserve a coherent winner, and " + "roll back only transaction-owned bytes", ) python_wrapper = read("pkg/pypi/src/codebase_memory_mcp/_cli.py") require( @@ -194,9 +195,11 @@ require( go_wrapper = read("pkg/go/cmd/codebase-memory-mcp/main.go") require( "names := []string{windowsLauncherName, windowsPayloadName}" in go_wrapper - and "filesEqualSHA256(staged[name], target)" in go_wrapper - and "os.Remove(target)" not in go_wrapper, - "Go must publish launcher then payload and only accept an identical collision", + and "windowsPairReady(dstDir, verifier)" in go_wrapper + and "publishedDigests[name] = stagedDigests[name]" in go_wrapper + and "if ok && pathMatchesSHA256(target, digest)" in go_wrapper, + "Go must publish launcher then payload, preserve a coherent winner, and " + "roll back only transaction-owned bytes", ) npm_shim = read("pkg/npm/bin.js") @@ -214,7 +217,8 @@ require( ) require( "verifyCandidate(extractedPaths.get(WINDOWS_LAUNCHER_NAME))" in npm_installer - and "verifyCandidate(path.join(BIN_DIR, WINDOWS_LAUNCHER_NAME))" in npm_installer, + and "if (platform === 'windows' && windowsPairReady(BIN_DIR)) return;" in npm_installer + and "verifier(launcher)" in npm_installer, "npm must validate extracted and cached Windows pairs through the launcher", ) require( @@ -429,11 +433,28 @@ require( any("zip" in block and launcher in block and payload in block for block in smoke_blocks), "Windows artifact-server smoke archive must contain launcher and payload", ) +windows_release_smoke_blocks = [ + re.sub(r"\s+", " ", re.sub(r"\\\s*\n\s*", " ", block)).strip() + for block in smoke_blocks + if "scripts/smoke-test.sh ./codebase-memory-mcp.exe" in block +] +require( + len(windows_release_smoke_blocks) == 1 + and 'SMOKE_TEMP_ROOT="$(cygpath -u "$RUNNER_TEMP")" ' + 'scripts/smoke-test.sh ./codebase-memory-mcp.exe' in windows_release_smoke_blocks[0], + "Windows release smoke must keep every launcher fixture under runner-private temp", +) # Native update transport remains HTTPS-only in production. Release smoke may # use an explicit file:// CBM_DOWNLOAD_URL only for its local fixture, while the # installer and raw-curl phases continue to exercise the loopback HTTP server. smoke_script = read("scripts/smoke-test.sh") +require( + "smoke_mktemp_file" in smoke_script + and "smoke_mktemp_dir" in smoke_script + and re.search(r"\$\(\s*mktemp(?:\s+-d)?(?:\s|\))", smoke_script) is None, + "smoke-test.sh must route every temporary fixture through its private-root helpers", +) require( 'SMOKE_UPDATE_FIXTURE_DIR' in smoke_script and 'UPDATE_DOWNLOAD_URL="file://$UPDATE_FIXTURE_DIR"' in smoke_script @@ -465,7 +486,7 @@ protocol_match = re.search( file_override = file_override_match.group(0) if file_override_match else "" protocol = protocol_match.group(0) if protocol_match else "" require( - 'cbm_safe_getenv(\n "CBM_DOWNLOAD_URL"' in file_override + re.search(r'cbm_safe_getenv\s*\(\s*"CBM_DOWNLOAD_URL"', file_override) is not None and 'strncmp(override, "file://", 7)' in file_override and "strncmp(url, override, override_length)" in file_override, "file:// downloads must remain restricted to the explicit test override", @@ -511,19 +532,21 @@ if pr_windows_blocks: pr_windows_block = re.sub(r"\\\s*\n\s*", " ", pr_windows_blocks[0]) pr_windows_block = re.sub(r"\s+", " ", pr_windows_block).strip() staging_steps = ( - 'SMOKE_DIR="$(mktemp -d)"', + 'SMOKE_ROOT="$(cygpath -u "$RUNNER_TEMP")"', + 'SMOKE_DIR="$(mktemp -d "$SMOKE_ROOT/cbm-pr-smoke.XXXXXX")"', 'trap \'rm -rf "$SMOKE_DIR"\' EXIT', 'cp build/c/codebase-memory-mcp-launcher.exe ' '"$SMOKE_DIR/codebase-memory-mcp.exe"', 'cp build/c/codebase-memory-mcp.exe ' '"$SMOKE_DIR/codebase-memory-mcp.payload.exe"', + 'SMOKE_TEMP_ROOT="$SMOKE_ROOT" ' 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"', ) positions = [pr_windows_block.find(step) for step in staging_steps] require( all(position >= 0 for position in positions), "Windows PR smoke must stage launcher and payload under release names " - "and invoke the canonical launcher", + "in the runner-private native temp directory and invoke the canonical launcher", ) require( all(left < right for left, right in zip(positions, positions[1:])), From 98a28473dbbf2dfb71ecff19f2d81c2456fc5039 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 10:15:22 +0200 Subject: [PATCH 06/23] fix: harden cross-platform daemon startup Signed-off-by: Martin Vogel --- scripts/test-windows.ps1 | 47 ++++++---- scripts/test_mcp_interactive.py | 38 ++++++-- src/daemon/bootstrap.c | 119 +++++++++++++++++-------- src/launcher/windows_launcher.c | 18 +++- src/main.c | 6 ++ tests/test_daemon_bootstrap.c | 32 +++++++ tests/test_windows_bundle_contract.sh | 30 +++++++ tests/windows/test_windows_launcher.py | 58 ++++++++---- 8 files changed, 265 insertions(+), 83 deletions(-) diff --git a/scripts/test-windows.ps1 b/scripts/test-windows.ps1 index 72b287eda..ee1939ccc 100644 --- a/scripts/test-windows.ps1 +++ b/scripts/test-windows.ps1 @@ -3,10 +3,10 @@ Run the native-Windows product-surface test suite for codebase-memory-mcp. .DESCRIPTION - Builds the product binary (build/c/codebase-memory-mcp.exe) if it is not - already present, then runs the deterministic Windows integration tests under - tests/windows/ against a real codebase-memory-mcp.exe (real stdio / CLI / - HTTP UI, real SQLite DB). + Builds the payload and permanent launcher if they are not already present, + stages them under their release names, then runs the deterministic Windows + integration tests under tests/windows/ through the launcher (real stdio / + CLI / HTTP UI, real SQLite DB). Two categories of test: @@ -95,8 +95,9 @@ function Resolve-Binary { $built = Join-Path $repoRoot "build\c\codebase-memory-mcp.exe" if (Test-Path $built) { return $built } Write-Host "Building $Target via Makefile.cbm ..." -ForegroundColor Cyan - & $Make "-j" "-f" "Makefile.cbm" $Target "SANITIZE=" "TMP=$tmp" "TEMP=$tmp" "TMPDIR=$tmp" - if ($LASTEXITCODE -ne 0) { throw "build failed (exit $LASTEXITCODE)" } + & $Make "-j" "-f" "Makefile.cbm" $Target "SANITIZE=" "TMP=$tmp" "TEMP=$tmp" "TMPDIR=$tmp" | Out-Host + $buildExit = $LASTEXITCODE + if ($buildExit -ne 0) { throw "build failed (exit $buildExit)" } if (-not (Test-Path $built)) { throw "binary not produced at $built" } return $built } @@ -107,8 +108,9 @@ function Resolve-Launcher { $built = Join-Path $repoRoot "build\c\codebase-memory-mcp-launcher.exe" if (Test-Path $built) { return $built } Write-Host "Building permanent launcher via Makefile.cbm ..." -ForegroundColor Cyan - & $Make "-j" "-f" "Makefile.cbm" "cbm-launcher" "SANITIZE=" "TMP=$tmp" "TEMP=$tmp" "TMPDIR=$tmp" - if ($LASTEXITCODE -ne 0) { throw "launcher build failed (exit $LASTEXITCODE)" } + & $Make "-j" "-f" "Makefile.cbm" "cbm-launcher" "SANITIZE=" "TMP=$tmp" "TEMP=$tmp" "TMPDIR=$tmp" | Out-Host + $buildExit = $LASTEXITCODE + if ($buildExit -ne 0) { throw "launcher build failed (exit $buildExit)" } if (-not (Test-Path $built)) { throw "launcher not produced at $built" } return $built } @@ -117,8 +119,9 @@ function Resolve-AbiMismatchLauncher { $built = Join-Path $repoRoot "build\c\codebase-memory-mcp-launcher-abi2.exe" if (Test-Path $built) { return $built } Write-Host "Building launcher ABI mismatch fixture via Makefile.cbm ..." -ForegroundColor Cyan - & $Make "-j" "-f" "Makefile.cbm" "build/c/codebase-memory-mcp-launcher-abi2.exe" "SANITIZE=" "TMP=$tmp" "TEMP=$tmp" "TMPDIR=$tmp" - if ($LASTEXITCODE -ne 0) { throw "launcher ABI fixture build failed (exit $LASTEXITCODE)" } + & $Make "-j" "-f" "Makefile.cbm" "build/c/codebase-memory-mcp-launcher-abi2.exe" "SANITIZE=" "TMP=$tmp" "TEMP=$tmp" "TMPDIR=$tmp" | Out-Host + $buildExit = $LASTEXITCODE + if ($buildExit -ne 0) { throw "launcher ABI fixture build failed (exit $buildExit)" } if (-not (Test-Path $built)) { throw "launcher ABI fixture not produced at $built" } return $built } @@ -130,8 +133,17 @@ Write-Host "Payload: $bin" -ForegroundColor Green Write-Host "Launcher: $launcherBin" -ForegroundColor Green Write-Host "ABI mismatch fixture: $abiMismatchLauncher" -ForegroundColor Green -$env:PYTHONUTF8 = "1" # encode argv/stdio as UTF-8 -$env:CBM_INDEX_SUPERVISOR = "0" # in-process indexing (see .DESCRIPTION) +$guardBundle = Join-Path $tmp ("cbm-windows-guards-" + [guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $guardBundle | Out-Null +$guardBin = Join-Path $guardBundle "codebase-memory-mcp.exe" +$guardPayload = Join-Path $guardBundle "codebase-memory-mcp.payload.exe" +Copy-Item -LiteralPath $launcherBin -Destination $guardBin +Copy-Item -LiteralPath $bin -Destination $guardPayload +Write-Host "Guard bundle: $guardBin" -ForegroundColor Green + +try { + $env:PYTHONUTF8 = "1" # encode argv/stdio as UTF-8 + $env:CBM_INDEX_SUPERVISOR = "0" # in-process indexing (see .DESCRIPTION) # Green regression guards - must stay GREEN (exit 0). RED (exit 1) = the fix for # the referenced issue regressed. The drive-picker guard needs the embedded HTTP @@ -158,14 +170,14 @@ Write-Host "`n--- Green guards ---" -ForegroundColor Cyan foreach ($t in $guards) { Write-Host "`n=== $t ===" -ForegroundColor Cyan if ($t -eq "tests\windows\test_windows_launcher.py") { - & $py $t $launcherBin $bin $abiMismatchLauncher + & $py $t $guardBin $guardPayload $abiMismatchLauncher } else { - & $py $t $bin + & $py $t $guardBin } $code = $LASTEXITCODE if ($code -eq 0) { Write-Host "GREEN ($t)" -ForegroundColor Green - } elseif ($code -eq 1) { + } elseif ($code -eq 1 -or $t -eq "tests\windows\test_windows_launcher.py") { Write-Host "RED ($t) - REGRESSION: a fixed Windows bug is broken again" -ForegroundColor Red $guardFailures += $t } else { @@ -178,7 +190,7 @@ if (-not $GuardsOnly) { Write-Host "`n--- Known reds (opt-in, expected red) ---" -ForegroundColor Cyan foreach ($t in $knownReds) { Write-Host "`n=== $t ===" -ForegroundColor Cyan - & $py $t $bin + & $py $t $guardBin $code = $LASTEXITCODE if ($code -eq 1) { Write-Host "RED ($t) - expected; the underlying Windows bug is still open" -ForegroundColor DarkYellow @@ -190,6 +202,9 @@ if (-not $GuardsOnly) { } } } +} finally { + Remove-Item -LiteralPath $guardBundle -Recurse -Force -ErrorAction SilentlyContinue +} Write-Host "" if ($guardSkips.Count -gt 0) { diff --git a/scripts/test_mcp_interactive.py b/scripts/test_mcp_interactive.py index 4db2a902c..47c7249e8 100644 --- a/scripts/test_mcp_interactive.py +++ b/scripts/test_mcp_interactive.py @@ -10,7 +10,9 @@ import argparse import json +import os import queue +import shutil import subprocess import sys import threading @@ -29,6 +31,23 @@ class SmokeFailure(RuntimeError): pass +def mcp_command(binary: str) -> list[str]: + """Select a direct product launch or an explicit Windows shebang runner.""" + if os.name != "nt": + return [binary] + try: + with open(binary, "rb") as stream: + shebang = stream.read(2) == b"#!" + except OSError: + return [binary] + if not shebang: + return [binary] + bash = shutil.which("bash") + if not bash: + raise SmokeFailure("Windows shebang MCP fixture requires bash on PATH") + return [bash, binary] + + def read_json_lines( stream: BinaryIO, responses: "queue.Queue[dict[str, Any]]", @@ -275,13 +294,18 @@ def main() -> int: parser.add_argument("--exit-timeout", type=float, default=15.0) args = parser.parse_args() - process = subprocess.Popen( - [args.binary], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - bufsize=0, - ) + try: + command = mcp_command(args.binary) + process = subprocess.Popen( + command, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + except (OSError, SmokeFailure) as error: + print(f"FAIL: could not start MCP server: {error}", file=sys.stderr) + return 1 assert process.stdout is not None assert process.stderr is not None responses: "queue.Queue[dict[str, Any]]" = queue.Queue() diff --git a/src/daemon/bootstrap.c b/src/daemon/bootstrap.c index 8a74aca6b..ff080114d 100644 --- a/src/daemon/bootstrap.c +++ b/src/daemon/bootstrap.c @@ -30,6 +30,7 @@ #include #include #ifdef __APPLE__ +#include #include extern char **environ; #endif @@ -519,6 +520,9 @@ typedef struct bootstrap_production_cohort { typedef struct { bootstrap_production_cohort_t *cohort; +#ifdef __APPLE__ + int spawn_error; +#endif } bootstrap_production_context_t; static cbm_daemon_bootstrap_probe_status_t bootstrap_production_probe( @@ -737,7 +741,7 @@ static bool bootstrap_darwin_spawn_state_init(posix_spawn_file_actions_t *action } sigset_t empty; (void)sigemptyset(&empty); - short flags = POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_CLOEXEC_DEFAULT; + short flags = POSIX_SPAWN_SETSIGMASK | POSIX_SPAWN_SETSID | POSIX_SPAWN_CLOEXEC_DEFAULT; if (posix_spawnattr_setsigmask(attributes, &empty) != 0 || posix_spawnattr_setflags(attributes, flags) != 0) { (void)posix_spawnattr_destroy(attributes); @@ -747,19 +751,50 @@ static bool bootstrap_darwin_spawn_state_init(posix_spawn_file_actions_t *action return true; } -static void bootstrap_daemon_grandchild(const cbm_daemon_bootstrap_launch_spec_t *spec, - const posix_spawn_file_actions_t *actions, - const posix_spawnattr_t *attributes) { - (void)umask(077); - pid_t daemon = 0; - int status = posix_spawn(&daemon, spec->executable_path, actions, attributes, - (char *const *)spec->argv, environ); - _exit(status == 0 ? 0 : 127); +typedef struct { + pid_t pid; +} bootstrap_darwin_reaper_t; + +static void *bootstrap_darwin_reap(void *opaque) { + bootstrap_darwin_reaper_t *reaper = opaque; + int status = 0; + pid_t waited; + do { + waited = waitpid(reaper->pid, &status, 0); + } while (waited < 0 && errno == EINTR); + free(reaper); + return NULL; +} + +static bool bootstrap_darwin_reaper_start(pid_t daemon) { + bootstrap_darwin_reaper_t *reaper = malloc(sizeof(*reaper)); + if (!reaper) { + return false; + } + reaper->pid = daemon; + pthread_attr_t attributes; + bool attributes_ready = pthread_attr_init(&attributes) == 0; + bool detached = + attributes_ready && pthread_attr_setdetachstate(&attributes, PTHREAD_CREATE_DETACHED) == 0; + pthread_t thread; + int thread_status = + detached ? pthread_create(&thread, &attributes, bootstrap_darwin_reap, reaper) : -1; + if (attributes_ready) { + (void)pthread_attr_destroy(&attributes); + } + if (thread_status != 0) { + free(reaper); + return false; + } + return true; } static bool bootstrap_production_spawn(void *context, const cbm_daemon_bootstrap_launch_spec_t *spec) { - (void)context; + bootstrap_production_context_t *production = context; + if (production) { + production->spawn_error = 0; + } if (!spec || !spec->detached || spec->inherit_standard_handles || spec->use_shell) { return false; } @@ -768,38 +803,33 @@ static bool bootstrap_production_spawn(void *context, if (!bootstrap_darwin_spawn_state_init(&actions, &attributes)) { return false; } - pid_t first = fork(); - if (first < 0) { - (void)posix_spawnattr_destroy(&attributes); - (void)posix_spawn_file_actions_destroy(&actions); + /* Calling library-heavy process creation between fork() and exec is not a + * safe boundary once another thread may own libc state. Darwin's spawn + * primitive can create the detached session and close inherited FDs in + * one operation, so launch it from the original process and retain only a + * tiny detached waiter to prevent a crashed daemon from becoming a + * long-lived zombie owned by the first frontend. */ + pid_t daemon = 0; + int spawn_status = posix_spawn(&daemon, spec->executable_path, &actions, &attributes, + (char *const *)spec->argv, environ); + (void)posix_spawnattr_destroy(&attributes); + (void)posix_spawn_file_actions_destroy(&actions); + if (production) { + production->spawn_error = spawn_status; + } + if (spawn_status != 0) { return false; } - if (first == 0) { - if (setsid() < 0) { - _exit(127); - } - pid_t grandchild = fork(); - if (grandchild < 0) { - _exit(127); - } - if (grandchild > 0) { - _exit(0); + if (!bootstrap_darwin_reaper_start(daemon)) { + (void)kill(daemon, SIGKILL); + int status = 0; + while (waitpid(daemon, &status, 0) < 0 && errno == EINTR) {} + if (production) { + production->spawn_error = EAGAIN; } - /* Darwin commonly reports OPEN_MAX near one million. Iterating every - * possible descriptor after fork can consume the entire cold-start - * budget. CLOEXEC_DEFAULT closes the actual inherited set in the - * kernel while the double fork still provides daemon detachment. */ - bootstrap_daemon_grandchild(spec, &actions, &attributes); + return false; } - - int status = 0; - pid_t waited; - do { - waited = waitpid(first, &status, 0); - } while (waited < 0 && errno == EINTR); - (void)posix_spawnattr_destroy(&attributes); - (void)posix_spawn_file_actions_destroy(&actions); - return waited == first && WIFEXITED(status) && WEXITSTATUS(status) == 0; + return true; } #else static void bootstrap_child_close_fds(void) { @@ -864,7 +894,18 @@ static bool bootstrap_production_spawn(void *context, #endif static void bootstrap_production_diagnostic(void *context, const char *message) { - (void)context; + bootstrap_production_context_t *production = context; +#ifdef __APPLE__ + if (production && production->spawn_error != 0) { + (void)fprintf(stderr, "codebase-memory-mcp: %s (daemon launch: %s)\n", + message ? message : "daemon startup failed", + strerror(production->spawn_error)); + (void)fflush(stderr); + return; + } +#else + (void)production; +#endif (void)fprintf(stderr, "codebase-memory-mcp: %s\n", message ? message : "daemon startup failed"); (void)fflush(stderr); } diff --git a/src/launcher/windows_launcher.c b/src/launcher/windows_launcher.c index 4b5c09b48..d2d9ee0c8 100644 --- a/src/launcher/windows_launcher.c +++ b/src/launcher/windows_launcher.c @@ -279,8 +279,18 @@ static HANDLE launcher_open_directory_private(const wchar_t *path) { static bool launcher_path_tree_plain(const wchar_t *file_path) { size_t length = wcslen(file_path); - if (length < 4U || length >= CBM_WINDOWS_LAUNCHER_PATH_CAP || file_path[1] != L':' || - (file_path[2] != L'\\' && file_path[2] != L'/')) { + size_t root_length = 0U; + if (length >= 4U && file_path[1] == L':' && (file_path[2] == L'\\' || file_path[2] == L'/')) { + root_length = 3U; + } else if (length >= 8U && file_path[0] == L'\\' && file_path[1] == L'\\' && + file_path[2] == L'?' && file_path[3] == L'\\' && file_path[5] == L':' && + (file_path[6] == L'\\' || file_path[6] == L'/')) { + /* GetModuleFileNameW preserves the path format used to launch the + * process. Keep an extended DOS path extended while walking it so + * every ACL/reparse check names the exact object that was executed. */ + root_length = 7U; + } + if (root_length == 0U || length <= root_length || length >= CBM_WINDOWS_LAUNCHER_PATH_CAP) { return false; } wchar_t *path = malloc((length + 1U) * sizeof(*path)); @@ -292,14 +302,14 @@ static bool launcher_path_tree_plain(const wchar_t *file_path) { path[index] = L'\\'; } wchar_t *last = wcsrchr(path, L'\\'); - if (!last || last <= path + 2) { + if (!last || last < path + root_length) { free(path); return false; } *last = L'\0'; size_t directory_length = wcslen(path); bool valid = true; - for (size_t index = 3U; valid && index <= directory_length; index++) { + for (size_t index = root_length; valid && index <= directory_length; index++) { if (index < directory_length && path[index] != L'\\') continue; wchar_t saved = path[index]; diff --git a/src/main.c b/src/main.c index d76546993..d269957a0 100644 --- a/src/main.c +++ b/src/main.c @@ -74,6 +74,7 @@ enum { #include #ifndef _WIN32 #include +#include #include #include #endif @@ -1315,6 +1316,11 @@ int main(int argc, char **argv) { (void)fprintf(stderr, "codebase-memory-mcp: invalid internal process arguments\n"); return EXIT_FAILURE; } +#ifndef _WIN32 + if (role == CBM_DAEMON_PROCESS_DAEMON) { + (void)umask(077); + } +#endif cbm_cli_set_version(CBM_VERSION); cbm_profile_init(); diff --git a/tests/test_daemon_bootstrap.c b/tests/test_daemon_bootstrap.c index a6369a321..dbd90ce4f 100644 --- a/tests/test_daemon_bootstrap.c +++ b/tests/test_daemon_bootstrap.c @@ -650,6 +650,35 @@ TEST(daemon_bootstrap_concurrent_first_clients_spawn_one_daemon) { PASS(); } +#ifdef __APPLE__ +/* RED: the old double-fork returned success before its grandchild attempted + * posix_spawn, hiding an immediate launch error behind the full timeout. */ +TEST(daemon_bootstrap_darwin_launch_failure_is_synchronous) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "darwin-missing")); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A); + char missing[BOOTSTRAP_TEST_PATH_CAP]; + int written = snprintf(missing, sizeof(missing), "%s/definitely-missing-cbm", fixture.parent); + ASSERT(written > 0 && written < (int)sizeof(missing)); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = missing, + .connect_timeout_ms = 1, + .startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS, + }; + cbm_daemon_bootstrap_result_t result; + uint64_t started = cbm_now_ms(); + ASSERT_EQ(cbm_daemon_bootstrap_execute(&config, &result), CBM_DAEMON_BOOTSTRAP_FAILED); + uint64_t elapsed = cbm_now_ms() - started; + ASSERT_FALSE(result.daemon_spawned); + ASSERT(elapsed < BOOTSTRAP_TEST_TIMEOUT_MS / 2U); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} +#endif + SUITE(daemon_bootstrap) { RUN_TEST(daemon_bootstrap_classifies_default_and_ui_as_mcp_clients); RUN_TEST(daemon_bootstrap_classifies_stateless_commands_without_client); @@ -670,4 +699,7 @@ SUITE(daemon_bootstrap) { RUN_TEST(daemon_bootstrap_reserved_then_absent_spawns_replacement); RUN_TEST(daemon_bootstrap_rejected_connect_is_reserved_and_never_unavailable); RUN_TEST(daemon_bootstrap_concurrent_first_clients_spawn_one_daemon); +#ifdef __APPLE__ + RUN_TEST(daemon_bootstrap_darwin_launch_failure_is_synchronous); +#endif } diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index d85e21978..4839e4eda 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -327,12 +327,42 @@ require( "test_cli_activation_helper.py" not in windows_test_driver, "scripts/test-windows.ps1 must not run the legacy transient activation-helper test", ) +require( + all( + needle in windows_test_driver + for needle in ( + 'Copy-Item -LiteralPath $launcherBin -Destination $guardBin', + 'Copy-Item -LiteralPath $bin -Destination $guardPayload', + '& $py $t $guardBin', + '& $py $t $guardBin $guardPayload $abiMismatchLauncher', + ) + ), + "ordinary native Windows guards must execute a staged launcher/payload release pair", +) +require( + windows_test_driver.count("| Out-Host") >= 3 + and windows_test_driver.count("$buildExit = $LASTEXITCODE") >= 3, + "Windows build helpers must not leak compiler output into returned artifact paths", +) +require( + '$code -eq 1 -or $t -eq "tests\\windows\\test_windows_launcher.py"' + in windows_test_driver, + "the permanent-launcher guard must fail instead of skip on driver/precondition errors", +) # Launcher supervision has two distinct failure directions: killing the # launcher must kill its payload job, and killing only the launcher's immediate # parent must terminate the launcher plus every descendant. Require the native # test to invoke both probes, not merely define helpers that never run. windows_launcher_test = read("tests/windows/test_windows_launcher.py") +unsafe_acl_test = windows_launcher_test[ + windows_launcher_test.find("def assert_untrusted_ancestor_acl_rejected(") : + windows_launcher_test.find("\ndef process_entries()") +] +require( + unsafe_acl_test.count('(extended_launcher, "extended DOS")') >= 2, + "native Windows coverage must reject unsafe ancestor ACLs through extended DOS paths", +) require( windows_launcher_test.count("assert_launcher_death_contains_payload(") >= 2 and "server.proc.kill()" in windows_launcher_test diff --git a/tests/windows/test_windows_launcher.py b/tests/windows/test_windows_launcher.py index 9adc86b48..d9834e864 100644 --- a/tests/windows/test_windows_launcher.py +++ b/tests/windows/test_windows_launcher.py @@ -335,6 +335,13 @@ def assert_untrusted_ancestor_acl_rejected( run([launcher, "--version"], env).returncode == 0, "portable launcher ACL control failed before the unsafe ACE was added", ) + extended_launcher = "\\\\?\\" + str(launcher.resolve()) + extended_result = run([extended_launcher, "--version"], env) + require( + extended_result.returncode == 0, + "launcher rejected its documented extended DOS module path: %s" + % output_text(extended_result)[-600:], + ) grant = run( ["icacls", unsafe_ancestor, "/grant", "*S-1-1-0:(M)"], env @@ -345,19 +352,31 @@ def assert_untrusted_ancestor_acl_rejected( % output_text(grant)[-600:], ) try: - result = run([launcher, "--version"], env) - diagnostic = output_text(result).lower() - require( - result.returncode != 0, - "launcher accepted an ancestor granting cross-account modify access", - ) - require( - any( - word in diagnostic - for word in ("unsafe", "security", "ownership", "access", "resolve") - ), - "unsafe ancestor ACL refusal was not explicit", - ) + for candidate, spelling in ( + (launcher, "normal"), + (extended_launcher, "extended DOS"), + ): + result = run([candidate, "--version"], env) + diagnostic = output_text(result).lower() + require( + result.returncode != 0, + "%s launcher path accepted an ancestor granting cross-account modify access" + % spelling, + ) + require( + any( + word in diagnostic + for word in ( + "unsafe", + "security", + "ownership", + "access", + "resolve", + ) + ), + "%s launcher path unsafe-ancestor refusal was not explicit" + % spelling, + ) finally: remove = run( ["icacls", unsafe_ancestor, "/remove:g", "*S-1-1-0"], env @@ -366,10 +385,15 @@ def assert_untrusted_ancestor_acl_rejected( remove.returncode == 0, "could not remove native Everyone-modify ancestor fixture", ) - require( - run([launcher, "--version"], env).returncode == 0, - "launcher did not recover after unsafe ancestor ACE removal", - ) + for candidate, spelling in ( + (launcher, "normal"), + (extended_launcher, "extended DOS"), + ): + require( + run([candidate, "--version"], env).returncode == 0, + "%s launcher path did not recover after unsafe ancestor ACE removal" + % spelling, + ) print("PASS: launcher rejected an untrusted mutation ACE on an ancestor") From ad58e71bea885efbf2289d56c401e5d68aa6aeaa Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 11:48:59 +0200 Subject: [PATCH 07/23] fix: stabilize cross-platform daemon coordination Signed-off-by: Martin Vogel --- .github/workflows/_smoke.yml | 23 +++-- .github/workflows/pr.yml | 10 +-- scripts/smoke-test.sh | 4 +- scripts/test-windows.ps1 | 17 +++- src/daemon/ipc.c | 15 ++-- src/daemon/runtime.c | 5 +- src/launcher/windows_launcher.c | 25 ++++-- tests/test_daemon_application.c | 1 + tests/test_daemon_runtime.c | 117 ++++++++++++++++++++++--- tests/test_daemon_runtime_contract.h | 10 +++ tests/test_main.c | 10 ++- tests/test_security_fuzz_harness.sh | 19 +++- tests/test_windows_bundle_contract.sh | 101 ++++++++++++++++++--- tests/windows/test_windows_launcher.py | 88 +++++++++++++++++++ 14 files changed, 386 insertions(+), 59 deletions(-) create mode 100644 tests/test_daemon_runtime_contract.h diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index 1104c4309..b4059e9b7 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -213,8 +213,12 @@ jobs: unzip -o "codebase-memory-mcp${SUFFIX}-windows-${ARCH}.zip" test -s codebase-memory-mcp.exe test -s codebase-memory-mcp.payload.exe - ./codebase-memory-mcp.payload.exe --version - ./codebase-memory-mcp.exe --version + PROFILE_ROOT="$(cygpath -u "$USERPROFILE")" + LAUNCH_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-version.XXXXXX")" + trap 'rm -rf "$LAUNCH_DIR"' EXIT + cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$LAUNCH_DIR/" + "$LAUNCH_DIR/codebase-memory-mcp.payload.exe" --version + "$LAUNCH_DIR/codebase-memory-mcp.exe" --version - name: Start artifact server shell: msys2 {0} @@ -240,8 +244,12 @@ jobs: - name: Smoke test shell: msys2 {0} run: | - SMOKE_TEMP_ROOT="$(cygpath -u "$RUNNER_TEMP")" \ - scripts/smoke-test.sh ./codebase-memory-mcp.exe + PROFILE_ROOT="$(cygpath -u "$USERPROFILE")" + SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-smoke.XXXXXX")" + trap 'rm -rf "$SMOKE_DIR"' EXIT + cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$SMOKE_DIR/" + SMOKE_TEMP_ROOT="$SMOKE_DIR" \ + scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" env: SMOKE_DOWNLOAD_URL: http://127.0.0.1:18080 SMOKE_UPDATE_FIXTURE_DIR: /tmp/smoke-server @@ -252,7 +260,12 @@ jobs: run: | scripts/security-strings.sh ./codebase-memory-mcp.exe scripts/security-strings.sh ./codebase-memory-mcp.payload.exe - scripts/security-install.sh ./codebase-memory-mcp.exe + PROFILE_ROOT="$(cygpath -u "$USERPROFILE")" + SECURITY_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-security.XXXXXX")" + trap 'rm -rf "$SECURITY_DIR"' EXIT + cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$SECURITY_DIR/" + TMPDIR="$SECURITY_DIR" \ + scripts/security-install.sh "$SECURITY_DIR/codebase-memory-mcp.exe" - name: Windows Defender scan shell: pwsh diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 969170384..a98a1d62b 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -115,16 +115,16 @@ jobs: run: | scripts/build.sh CC=clang CXX=clang++ # MSYS2 /tmp is intentionally shared. The launcher correctly rejects - # a bundle below that writable ancestor, so stage in the runner's - # account-private native temp directory instead. - SMOKE_ROOT="$(cygpath -u "$RUNNER_TEMP")" - SMOKE_DIR="$(mktemp -d "$SMOKE_ROOT/cbm-pr-smoke.XXXXXX")" + # a bundle below that writable ancestor. RUNNER_TEMP is also below + # the shared D:\a tree, so stage in the runner account's profile. + PROFILE_ROOT="$(cygpath -u "$USERPROFILE")" + SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-pr-smoke.XXXXXX")" trap 'rm -rf "$SMOKE_DIR"' EXIT cp build/c/codebase-memory-mcp-launcher.exe \ "$SMOKE_DIR/codebase-memory-mcp.exe" cp build/c/codebase-memory-mcp.exe \ "$SMOKE_DIR/codebase-memory-mcp.payload.exe" - SMOKE_TEMP_ROOT="$SMOKE_ROOT" \ + SMOKE_TEMP_ROOT="$SMOKE_DIR" \ scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" ci-ok: diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index abc7ae19f..3fcfece9a 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -3083,7 +3083,9 @@ elif [ -f "$REPO_ROOT/install.ps1" ] && command -v powershell.exe &>/dev/null; t # Pass the known-correct arch: powershell runs under x64 emulation on ARM64, so # install.ps1's own detection can't tell it's arm64. DL_ARCH is authoritative here. HOME="$PS1_TEST_HOME" CBM_DOWNLOAD_URL="$WIN_URL" CBM_ARCH="$DL_ARCH" \ - powershell.exe -ExecutionPolicy ByPass -File "$WIN_SCRIPT" "--dir=$WIN_DIR" 2>&1 || true + powershell.exe -NoProfile -ExecutionPolicy ByPass -Command \ + '$env:TEMP=$args[0]; $env:TMP=$args[0]; & $args[1] $args[2]' \ + "$WIN_HOME" "$WIN_SCRIPT" "--dir=$WIN_DIR" 2>&1 || true # 13g: binary placed PS1_BIN="$PS1_TEST_DIR/codebase-memory-mcp.exe" diff --git a/scripts/test-windows.ps1 b/scripts/test-windows.ps1 index ee1939ccc..a0d7451f4 100644 --- a/scripts/test-windows.ps1 +++ b/scripts/test-windows.ps1 @@ -133,7 +133,11 @@ Write-Host "Payload: $bin" -ForegroundColor Green Write-Host "Launcher: $launcherBin" -ForegroundColor Green Write-Host "ABI mismatch fixture: $abiMismatchLauncher" -ForegroundColor Green -$guardBundle = Join-Path $tmp ("cbm-windows-guards-" + [guid]::NewGuid().ToString("N")) +$localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) +if (-not $localAppData) { throw "could not resolve the current user's local application-data directory" } +$guardRoot = Join-Path $localAppData "Temp" +New-Item -ItemType Directory -Path $guardRoot -Force | Out-Null +$guardBundle = Join-Path $guardRoot ("cbm-windows-guards-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $guardBundle | Out-Null $guardBin = Join-Path $guardBundle "codebase-memory-mcp.exe" $guardPayload = Join-Path $guardBundle "codebase-memory-mcp.payload.exe" @@ -141,7 +145,15 @@ Copy-Item -LiteralPath $launcherBin -Destination $guardBin Copy-Item -LiteralPath $bin -Destination $guardPayload Write-Host "Guard bundle: $guardBin" -ForegroundColor Green +$previousTemp = $env:TEMP +$previousTmp = $env:TMP +$previousTmpDir = $env:TMPDIR try { + # The launcher deliberately rejects GitHub's shared D:\a ancestry. Keep + # launcher fixtures and Python-created descendants inside this account. + $env:TEMP = $guardRoot + $env:TMP = $guardRoot + $env:TMPDIR = $guardRoot $env:PYTHONUTF8 = "1" # encode argv/stdio as UTF-8 $env:CBM_INDEX_SUPERVISOR = "0" # in-process indexing (see .DESCRIPTION) @@ -204,6 +216,9 @@ if (-not $GuardsOnly) { } } finally { Remove-Item -LiteralPath $guardBundle -Recurse -Force -ErrorAction SilentlyContinue + $env:TEMP = $previousTemp + $env:TMP = $previousTmp + $env:TMPDIR = $previousTmpDir } Write-Host "" diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index a0cbe6e9d..f0653136d 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -1984,14 +1984,13 @@ static int posix_socket_record_publication_recover( static int posix_linkat_no_follow(int source_dir_fd, const char *source_name, int destination_dir_fd, const char *destination_name) { - int flags = 0; -#if defined(__APPLE__) && defined(AT_SYMLINK_NOFOLLOW_ANY) - /* Darwin documents that linkat(..., 0) may be rejected by some - * filesystems. This explicit flag preserves the intended no-follow - * semantics while making the operation portable to those filesystems. */ - flags = AT_SYMLINK_NOFOLLOW_ANY; -#endif - return linkat(source_dir_fd, source_name, destination_dir_fd, destination_name, flags); + /* linkat without AT_SYMLINK_FOLLOW links the source entry itself, which is + * the required no-follow behavior. Do not pass Darwin's broader + * AT_SYMLINK_NOFOLLOW_ANY constant: macOS 14 headers define it, but that + * kernel rejects it for linkat with EINVAL. The retained and validated + * source state plus post-link inode/link-count checks retain the + * fail-closed publication contract. */ + return linkat(source_dir_fd, source_name, destination_dir_fd, destination_name, 0); } static void posix_bound_socket_unlink_if_matches(int dir_fd, const char *socket_name, dev_t device, diff --git a/src/daemon/runtime.c b/src/daemon/runtime.c index 6eeb25012..7f08448de 100644 --- a/src/daemon/runtime.c +++ b/src/daemon/runtime.c @@ -1354,8 +1354,8 @@ static bool runtime_activation_peer_matches_claim(cbm_daemon_runtime_service_t * } bool active_image = runtime_process_image_reference_matches_process(&service->active_image, process_id); - if (active_image && strcmp(claimed_build, service->identity.build_fingerprint) == 0) { - return true; + if (active_image) { + return strcmp(claimed_build, service->identity.build_fingerprint) == 0; } char peer_fingerprint[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; return cbm_daemon_runtime_process_build_fingerprint(process_id, peer_fingerprint) && @@ -1569,7 +1569,6 @@ static void *runtime_connection_worker(void *opaque) { received = cbm_daemon_ipc_receive_frame(worker->connection, CBM_DAEMON_IPC_WAIT_FOREVER, &frame, &payload); if (received != 1 || frame.type != CBM_DAEMON_FRAME_REQUEST) { - free(payload); break; } switch (frame.flags) { diff --git a/src/launcher/windows_launcher.c b/src/launcher/windows_launcher.c index d2d9ee0c8..ba9ea41a8 100644 --- a/src/launcher/windows_launcher.c +++ b/src/launcher/windows_launcher.c @@ -176,7 +176,7 @@ static bool launcher_bounded_ace_sid_is_trusted(const ACE_HEADER *header, PSID c IsWellKnownSid((PSID)sid, WinCreatorOwnerSid))); } -static bool launcher_security_is_safe(HANDLE file, bool require_current_owner) { +static bool launcher_security_is_safe(HANDLE file, bool require_current_owner, DWORD mutation) { void *token_user = NULL; PSID user_sid = NULL; if (!launcher_current_user(&token_user, &user_sid)) { @@ -196,10 +196,6 @@ static bool launcher_security_is_safe(HANDLE file, bool require_current_owner) { : launcher_sid_is_trusted(owner, user_sid)) && dacl && IsValidAcl(dacl) && GetAclInformation(dacl, &information, sizeof(information), AclSizeInformation) != 0; - const DWORD mutation = GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | - FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | - FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | - WRITE_OWNER | ACCESS_SYSTEM_SECURITY; for (DWORD index = 0; secure && index < information.AceCount; index++) { void *opaque = NULL; if (!GetAce(dacl, index, &opaque) || !opaque) { @@ -239,8 +235,14 @@ static bool launcher_security_is_safe(HANDLE file, bool require_current_owner) { return secure; } +static DWORD launcher_private_mutation_rights(void) { + return GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_ADD_FILE | + FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | + DELETE | WRITE_DAC | WRITE_OWNER | ACCESS_SYSTEM_SECURITY; +} + static bool launcher_security_is_private(HANDLE file) { - return launcher_security_is_safe(file, true); + return launcher_security_is_safe(file, true, launcher_private_mutation_rights()); } static HANDLE launcher_open_regular(const wchar_t *path, DWORD access, bool require_private) { @@ -319,11 +321,20 @@ static bool launcher_path_tree_plain(const wchar_t *file_path) { FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); BY_HANDLE_FILE_INFORMATION information; + DWORD mutation = launcher_private_mutation_rights(); + if (index < directory_length) { + /* Default C:\\Users ACLs grant cross-account add-subdirectory on + * this intermediate component. That cannot replace the existing + * next component. No other write right is relaxed, and the final + * executable directory remains fully private so a peer cannot + * plant DLL or .exe.local redirection artifacts beside CBM. */ + mutation &= ~((DWORD)FILE_ADD_SUBDIRECTORY); + } valid = component != INVALID_HANDLE_VALUE && GetFileType(component) == FILE_TYPE_DISK && GetFileInformationByHandle(component, &information) != 0 && (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && - launcher_security_is_safe(component, false); + launcher_security_is_safe(component, false, mutation); if (component != INVALID_HANDLE_VALUE) (void)CloseHandle(component); path[index] = saved; diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index 04520586a..8ad5d933d 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -1323,6 +1323,7 @@ static void app_fake_worker_destroy(void *opaque, cbm_daemon_application_worker_ cbm_usleep(1000); } (void)app_project_lock_release(&worker->project_lock_lease); + cbm_index_worker_result_free(&worker->result); free(worker->project_key); free(handle); } diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index d1dda0ecb..0ceae97ad 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -7,6 +7,7 @@ */ #include "test_framework.h" #include "test_helpers.h" +#include "test_daemon_runtime_contract.h" #include "daemon/application.h" #include "daemon/host.h" @@ -53,6 +54,8 @@ enum { RUNTIME_TEST_PATH_CAP = 1024, RUNTIME_TEST_LOG_CAP = 4096, RUNTIME_TEST_TIMEOUT_MS = 2000, + RUNTIME_TEST_CLEANUP_TIMEOUT_MS = 60000, + RUNTIME_TEST_CLEANUP_FREE_ATTEMPTS = 3, /* Generation-zero rendezvous layout, deliberately repeated rather than * derived from production macros so an accidental resize fails loudly. */ RUNTIME_TEST_RENDEZVOUS_ABI = 1, @@ -245,6 +248,22 @@ static bool runtime_test_windows_copy_self(const char *destination) { return copied; } +static bool runtime_test_windows_wait_image_probe(HANDLE process) { + if (!process) { + return false; + } + DWORD wait_status = WaitForSingleObject(process, TF_RUNTIME_IMAGE_WATCHDOG_MS); + if (wait_status == WAIT_OBJECT_0) { + return true; + } + (void)TerminateProcess(process, 30); + if (WaitForSingleObject(process, RUNTIME_TEST_TIMEOUT_MS) != WAIT_OBJECT_0) { + fprintf(stderr, "daemon_runtime copied-image child could not be reaped\n"); + abort(); + } + return false; +} + static bool runtime_test_windows_posix_replace(const char *source, const char *destination) { wchar_t *source_wide = cbm_utf8_to_wide(source); wchar_t *destination_wide = cbm_utf8_to_wide(destination); @@ -657,7 +676,7 @@ static bool runtime_test_run_hello_image(const char *image_path, NULL, &startup, &process) != 0; free(command); free(application); - bool waited = started && WaitForSingleObject(process.hProcess, 10000) == WAIT_OBJECT_0; + bool waited = started && runtime_test_windows_wait_image_probe(process.hProcess); DWORD exit_code = 0; bool read = waited && GetExitCodeProcess(process.hProcess, &exit_code) != 0; if (started) { @@ -671,6 +690,7 @@ static bool runtime_test_run_hello_image(const char *image_path, #elif defined(__APPLE__) || defined(__linux__) pid_t child = fork(); if (child == 0) { + (void)alarm(TF_RUNTIME_IMAGE_WATCHDOG_SECONDS); execl(image_path, image_path, "__cbm_runtime_hello_client", fixture->parent, fixture->key, identity->semantic_version, identity->build_fingerprint, (char *)NULL); _exit(127); @@ -722,11 +742,7 @@ static bool runtime_test_run_activation_image(const char *image_path, NULL, &startup, &process) != 0; free(command); free(application); - bool waited = started && WaitForSingleObject(process.hProcess, 10000) == WAIT_OBJECT_0; - if (started && !waited) { - (void)TerminateProcess(process.hProcess, 30); - (void)WaitForSingleObject(process.hProcess, 5000); - } + bool waited = started && runtime_test_windows_wait_image_probe(process.hProcess); DWORD exit_code = 0; bool read = waited && GetExitCodeProcess(process.hProcess, &exit_code) != 0; if (started) { @@ -742,7 +758,7 @@ static bool runtime_test_run_activation_image(const char *image_path, int action_written = snprintf(action_text, sizeof(action_text), "%u", (unsigned int)action); pid_t child = action_written > 0 && action_written < (int)sizeof(action_text) ? fork() : -1; if (child == 0) { - (void)alarm(10); + (void)alarm(TF_RUNTIME_IMAGE_WATCHDOG_SECONDS); execl(image_path, image_path, "__cbm_runtime_activation_client", fixture->parent, fixture->key, identity->semantic_version, identity->build_fingerprint, action_text, (char *)NULL); @@ -1342,10 +1358,22 @@ static void runtime_test_fixture_finish(runtime_test_fixture_t *fixture) { if (fixture->service) { cbm_daemon_runtime_service_state_t state = cbm_daemon_runtime_service_state(fixture->service); - if (state != CBM_DAEMON_RUNTIME_SERVICE_EXITED) { - (void)cbm_daemon_runtime_service_stop(fixture->service, RUNTIME_TEST_TIMEOUT_MS); + bool stopped = + state == CBM_DAEMON_RUNTIME_SERVICE_EXITED || + cbm_daemon_runtime_service_stop(fixture->service, RUNTIME_TEST_CLEANUP_TIMEOUT_MS); + bool freed = false; + for (size_t attempt = 0; stopped && !freed && attempt < RUNTIME_TEST_CLEANUP_FREE_ATTEMPTS; + attempt++) { + freed = cbm_daemon_runtime_service_free(fixture->service); + if (!freed) { + cbm_usleep(1000); + } + } + if (!freed) { + fprintf(stderr, "daemon_runtime fixture teardown failed\n"); + abort(); } - (void)cbm_daemon_runtime_service_free(fixture->service); + fixture->service = NULL; } cbm_daemon_ipc_endpoint_free(fixture->endpoint); (void)cbm_unlink(fixture->rotated_log_path); @@ -1691,6 +1719,74 @@ TEST(daemon_runtime_exact_hello_issues_connection_bound_identity) { PASS(); } +TEST(daemon_runtime_unexpected_frame_payload_is_freed_once) { + static const uint8_t unexpected_payload[] = {0xde, 0xad, 0xbe, 0xef}; + cbm_daemon_build_identity_t identity = + runtime_test_identity("2.4.0", runtime_test_self_build()); + runtime_test_fixture_t fixture; + bool started = runtime_test_fixture_start(&fixture, "unexpected-frame", &identity); + cbm_daemon_runtime_connect_result_t owner_result = {0}; + cbm_daemon_runtime_client_t *owner = NULL; + cbm_daemon_ipc_connection_t *raw = NULL; + bool raw_connected = false; + bool unexpected_sent = false; + bool bad_peer_released = false; + bool bad_peer_closed = false; + bool owner_survived = false; + bool owner_closed = false; + bool exited = false; + + if (started) { + owner = cbm_daemon_runtime_client_connect(fixture.endpoint, &identity, + RUNTIME_TEST_TIMEOUT_MS, &owner_result); + } + if (owner) { + raw = runtime_test_raw_client_connect(fixture.endpoint, &identity); + raw_connected = raw != NULL; + } + if (raw) { + unexpected_sent = cbm_daemon_ipc_send_frame( + raw, CBM_DAEMON_FRAME_RESPONSE, CBM_DAEMON_RUNTIME_OP_HEARTBEAT, unexpected_payload, + (uint32_t)sizeof(unexpected_payload)); + } + if (unexpected_sent) { + bad_peer_released = cbm_daemon_runtime_service_wait_for_clients(fixture.service, 1, + RUNTIME_TEST_TIMEOUT_MS); + } + if (bad_peer_released) { + cbm_daemon_frame_t frame = {0}; + uint8_t *payload = NULL; + int received = cbm_daemon_ipc_receive_frame(raw, RUNTIME_TEST_TIMEOUT_MS, &frame, &payload); + bad_peer_closed = received != 1; + free(payload); + owner_survived = cbm_daemon_runtime_client_heartbeat(owner, RUNTIME_TEST_TIMEOUT_MS); + } + cbm_daemon_ipc_connection_close(raw); + raw = NULL; + if (owner) { + owner_closed = cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); + owner = NULL; + } + if (started) { + exited = cbm_daemon_runtime_service_wait_exited(fixture.service, RUNTIME_TEST_TIMEOUT_MS); + } + if (owner) { + (void)cbm_daemon_runtime_client_close(owner, RUNTIME_TEST_TIMEOUT_MS); + } + cbm_daemon_ipc_connection_close(raw); + runtime_test_fixture_finish(&fixture); + + ASSERT_TRUE(started); + ASSERT_TRUE(raw_connected); + ASSERT_TRUE(unexpected_sent); + ASSERT_TRUE(bad_peer_released); + ASSERT_TRUE(bad_peer_closed); + ASSERT_TRUE(owner_survived); + ASSERT_TRUE(owner_closed); + ASSERT_TRUE(exited); + PASS(); +} + TEST(daemon_runtime_activation_rejects_forged_and_malformed_without_stop) { cbm_daemon_build_identity_t identity = runtime_test_identity("2.4.0", runtime_test_self_build()); @@ -4087,6 +4183,7 @@ SUITE(daemon_runtime) { RUN_TEST(daemon_runtime_convenience_service_owns_participant_guard); RUN_TEST(daemon_runtime_rendezvous_layout_is_frozen_and_detailed_abi_independent); RUN_TEST(daemon_runtime_exact_hello_issues_connection_bound_identity); + RUN_TEST(daemon_runtime_unexpected_frame_payload_is_freed_once); RUN_TEST(daemon_runtime_activation_rejects_forged_and_malformed_without_stop); RUN_TEST(daemon_runtime_activation_ack_snapshots_then_interrupts_all_clients); #if defined(_WIN32) || defined(__APPLE__) || defined(__linux__) diff --git a/tests/test_daemon_runtime_contract.h b/tests/test_daemon_runtime_contract.h new file mode 100644 index 000000000..e2b192ddd --- /dev/null +++ b/tests/test_daemon_runtime_contract.h @@ -0,0 +1,10 @@ +#ifndef TEST_DAEMON_RUNTIME_CONTRACT_H +#define TEST_DAEMON_RUNTIME_CONTRACT_H + +/* A copied sanitizer executable can take tens of seconds to fingerprint. The + * parent watchdog covers both the HELLO exchange and its bounded close. */ +#define TF_RUNTIME_IMAGE_EXCHANGE_TIMEOUT_MS 60000U +#define TF_RUNTIME_IMAGE_WATCHDOG_MS (2U * TF_RUNTIME_IMAGE_EXCHANGE_TIMEOUT_MS + 5000U) +#define TF_RUNTIME_IMAGE_WATCHDOG_SECONDS ((TF_RUNTIME_IMAGE_WATCHDOG_MS + 999U) / 1000U) + +#endif /* TEST_DAEMON_RUNTIME_CONTRACT_H */ diff --git a/tests/test_main.c b/tests/test_main.c index bb1cb291c..88debb40e 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -10,6 +10,7 @@ int tf_skip_count = 0; #include "test_framework.h" #include "test_helpers.h" +#include "test_daemon_runtime_contract.h" #include "foundation/compat.h" /* cbm_setenv — #845 supervisor kill switch */ #include "foundation/compat_fs.h" /* cbm_fopen — worker response file */ #include "foundation/mem.h" /* cbm_mem_init — worker budget */ @@ -391,10 +392,13 @@ static int tf_maybe_run_runtime_hello_client(int argc, char **argv) { cbm_daemon_runtime_connect_result_t result; memset(&result, 0, sizeof(result)); cbm_daemon_runtime_client_t *client = - endpoint ? cbm_daemon_runtime_client_connect(endpoint, &identity, 5000, &result) : NULL; + endpoint ? cbm_daemon_runtime_client_connect(endpoint, &identity, + TF_RUNTIME_IMAGE_EXCHANGE_TIMEOUT_MS, &result) + : NULL; bool accepted = client && result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED && result.hello_status == CBM_DAEMON_HELLO_COMPATIBLE; - bool closed = !client || cbm_daemon_runtime_client_close(client, 5000); + bool closed = + !client || cbm_daemon_runtime_client_close(client, TF_RUNTIME_IMAGE_EXCHANGE_TIMEOUT_MS); cbm_daemon_ipc_endpoint_free(endpoint); if (!accepted) { return 26; @@ -425,7 +429,7 @@ static int tf_maybe_run_runtime_activation_client(int argc, char **argv) { bool exchanged = endpoint && cbm_daemon_runtime_request_activation_shutdown( endpoint, &identity, (cbm_daemon_runtime_activation_action_t)action_value, - 5000, &result); + TF_RUNTIME_IMAGE_EXCHANGE_TIMEOUT_MS, &result); cbm_daemon_ipc_endpoint_free(endpoint); return exchanged && result.accepted ? 0 : 29; } diff --git a/tests/test_security_fuzz_harness.sh b/tests/test_security_fuzz_harness.sh index f4ea1583d..f3904a1eb 100644 --- a/tests/test_security_fuzz_harness.sh +++ b/tests/test_security_fuzz_harness.sh @@ -82,12 +82,25 @@ if [[ ! -s "$ENV_LOG" ]]; then exit 1 fi -while IFS=$'\t' read -r child_home child_cache; do - if [[ -z "$child_home" || "$child_home" == "$CALLER_HOME" ]]; then +normalize_path() { + local path=${1%$'\r'} + if command -v cygpath >/dev/null 2>&1; then + cygpath -u "$path" 2>/dev/null && return 0 + fi + printf '%s\n' "${path//\\//}" +} + +CALLER_HOME_NORMALIZED=$(normalize_path "$CALLER_HOME") +CALLER_CACHE_NORMALIZED=$(normalize_path "$CALLER_CACHE") + +while IFS=$'\t' read -r child_home_raw child_cache_raw; do + child_home=$(normalize_path "$child_home_raw") + child_cache=$(normalize_path "$child_cache_raw") + if [[ -z "$child_home" || "$child_home" == "$CALLER_HOME_NORMALIZED" ]]; then echo "FAIL: security-fuzz exposed the caller HOME to a fuzz target" exit 1 fi - if [[ -z "$child_cache" || "$child_cache" == "$CALLER_CACHE" ]]; then + if [[ -z "$child_cache" || "$child_cache" == "$CALLER_CACHE_NORMALIZED" ]]; then echo "FAIL: security-fuzz exposed the caller CBM_CACHE_DIR to a fuzz target" exit 1 fi diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index 4839e4eda..49e5167f7 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -349,6 +349,20 @@ require( in windows_test_driver, "the permanent-launcher guard must fail instead of skip on driver/precondition errors", ) +require( + all( + needle in windows_test_driver + for needle in ( + "[Environment+SpecialFolder]::LocalApplicationData", + '$guardRoot = Join-Path $localAppData "Temp"', + '$env:TEMP = $guardRoot', + '$env:TMP = $guardRoot', + '$env:TMPDIR = $guardRoot', + ) + ), + "Windows launcher guards must keep staged and Python-created fixtures " + "beneath the current account profile", +) # Launcher supervision has two distinct failure directions: killing the # launcher must kill its payload job, and killing only the launcher's immediate @@ -423,7 +437,13 @@ ancestor_security_contracts = { "FILE_FLAG_OPEN_REPARSE_POINT", ), "src/launcher/windows_launcher.c": ( - "launcher_security_is_safe(component, false)", + "launcher_security_is_safe(component, false, mutation)", + "launcher_private_mutation_rights()", + "if (index < directory_length)", + "mutation &= ~((DWORD)FILE_ADD_SUBDIRECTORY)", + "FILE_ADD_FILE", + "FILE_DELETE_CHILD", + "DLL or .exe.local", "ACCESS_SYSTEM_SECURITY", "956008885U", "FILE_FLAG_OPEN_REPARSE_POINT", @@ -448,10 +468,7 @@ windows_match = re.search( windows_smoke = windows_match.group(1) if windows_match else "" require(bool(windows_smoke), "_smoke.yml must contain the smoke-windows job") require( - re.search( - r"scripts/smoke-test\.sh\s+(?:\"|')?\./codebase-memory-mcp\.exe(?:\"|')?", - windows_smoke, - ) is not None, + 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"' in windows_smoke, f"Windows release smoke must execute the canonical {launcher}", ) require( @@ -466,13 +483,62 @@ require( windows_release_smoke_blocks = [ re.sub(r"\s+", " ", re.sub(r"\\\s*\n\s*", " ", block)).strip() for block in smoke_blocks - if "scripts/smoke-test.sh ./codebase-memory-mcp.exe" in block + if 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"' in block ] require( len(windows_release_smoke_blocks) == 1 - and 'SMOKE_TEMP_ROOT="$(cygpath -u "$RUNNER_TEMP")" ' - 'scripts/smoke-test.sh ./codebase-memory-mcp.exe' in windows_release_smoke_blocks[0], - "Windows release smoke must keep every launcher fixture under runner-private temp", + and all( + needle in windows_release_smoke_blocks[0] + for needle in ( + 'PROFILE_ROOT="$(cygpath -u "$USERPROFILE")"', + 'SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-smoke.XXXXXX")"', + 'cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$SMOKE_DIR/"', + 'SMOKE_TEMP_ROOT="$SMOKE_DIR" ' + 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"', + ) + ), + "Windows release smoke must keep every launcher fixture beneath the current account profile", +) +windows_release_version_blocks = [ + re.sub(r"\s+", " ", re.sub(r"\\\s*\n\s*", " ", block)).strip() + for block in smoke_blocks + if 'LAUNCH_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-version.XXXXXX")"' in block +] +require( + len(windows_release_version_blocks) == 1 + and all( + needle in windows_release_version_blocks[0] + for needle in ( + 'PROFILE_ROOT="$(cygpath -u "$USERPROFILE")"', + 'cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$LAUNCH_DIR/"', + '"$LAUNCH_DIR/codebase-memory-mcp.payload.exe" --version', + '"$LAUNCH_DIR/codebase-memory-mcp.exe" --version', + ) + ), + "Windows release version checks must execute the pair beneath the current account profile", +) +require( + "$RUNNER_TEMP" not in windows_smoke, + "Windows release smoke must not treat GitHub's shared RUNNER_TEMP ancestry as private", +) +windows_release_security_blocks = [ + re.sub(r"\s+", " ", re.sub(r"\\\s*\n\s*", " ", block)).strip() + for block in smoke_blocks + if 'scripts/security-install.sh "$SECURITY_DIR/codebase-memory-mcp.exe"' in block +] +require( + len(windows_release_security_blocks) == 1 + and all( + needle in windows_release_security_blocks[0] + for needle in ( + 'PROFILE_ROOT="$(cygpath -u "$USERPROFILE")"', + 'SECURITY_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-security.XXXXXX")"', + 'cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$SECURITY_DIR/"', + 'TMPDIR="$SECURITY_DIR" ' + 'scripts/security-install.sh "$SECURITY_DIR/codebase-memory-mcp.exe"', + ) + ), + "Windows release install audit must execute the pair beneath the current account profile", ) # Native update transport remains HTTPS-only in production. Release smoke may @@ -492,6 +558,11 @@ require( and 'CBM_DOWNLOAD_URL="$UPDATE_DOWNLOAD_URL"' in smoke_script, "Phase 14 native update must use an explicit file:// fixture override", ) +require( + "'$env:TEMP=$args[0]; $env:TMP=$args[0]; & $args[1] $args[2]'" in smoke_script + and '"$WIN_HOME" "$WIN_SCRIPT" "--dir=$WIN_DIR"' in smoke_script, + "Windows install.ps1 smoke must set native TEMP/TMP inside PowerShell", +) require( 'CBM_DOWNLOAD_URL="$SMOKE_DOWNLOAD_URL"' in smoke_script and '"$SMOKE_DOWNLOAD_URL/$DL_ARCHIVE"' in smoke_script, @@ -562,21 +633,21 @@ if pr_windows_blocks: pr_windows_block = re.sub(r"\\\s*\n\s*", " ", pr_windows_blocks[0]) pr_windows_block = re.sub(r"\s+", " ", pr_windows_block).strip() staging_steps = ( - 'SMOKE_ROOT="$(cygpath -u "$RUNNER_TEMP")"', - 'SMOKE_DIR="$(mktemp -d "$SMOKE_ROOT/cbm-pr-smoke.XXXXXX")"', + 'PROFILE_ROOT="$(cygpath -u "$USERPROFILE")"', + 'SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-pr-smoke.XXXXXX")"', 'trap \'rm -rf "$SMOKE_DIR"\' EXIT', 'cp build/c/codebase-memory-mcp-launcher.exe ' '"$SMOKE_DIR/codebase-memory-mcp.exe"', 'cp build/c/codebase-memory-mcp.exe ' '"$SMOKE_DIR/codebase-memory-mcp.payload.exe"', - 'SMOKE_TEMP_ROOT="$SMOKE_ROOT" ' + 'SMOKE_TEMP_ROOT="$SMOKE_DIR" ' 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"', ) positions = [pr_windows_block.find(step) for step in staging_steps] require( all(position >= 0 for position in positions), "Windows PR smoke must stage launcher and payload under release names " - "in the runner-private native temp directory and invoke the canonical launcher", + "beneath the current account profile and invoke the canonical launcher", ) require( all(left < right for left, right in zip(positions, positions[1:])), @@ -586,6 +657,10 @@ if pr_windows_blocks: pr_windows_block.count("scripts/smoke-test.sh") == 1, "Windows PR smoke must invoke smoke-test exactly once through the launcher", ) + require( + "$RUNNER_TEMP" not in pr_windows_block, + "Windows PR smoke must not treat GitHub's shared RUNNER_TEMP ancestry as private", + ) if failures: print("Windows launcher bundle contract FAILED:", file=sys.stderr) diff --git a/tests/windows/test_windows_launcher.py b/tests/windows/test_windows_launcher.py index d9834e864..edf0086d8 100644 --- a/tests/windows/test_windows_launcher.py +++ b/tests/windows/test_windows_launcher.py @@ -397,6 +397,88 @@ def assert_untrusted_ancestor_acl_rejected( print("PASS: launcher rejected an untrusted mutation ACE on an ancestor") +def assert_add_only_ancestor_acl_allowed(source_launcher, source_payload, env, work): + ancestor = work / "cross-account add-only ancestor" + launcher, _ = copy_portable_pair(source_launcher, source_payload, ancestor / "bundle") + grant = run(["icacls", ancestor, "/grant", "*S-1-1-0:(AD)"], env) + require( + grant.returncode == 0, + "could not install native Everyone-add-subdirectory ancestor fixture: %s" + % output_text(grant)[-600:], + ) + try: + for candidate, spelling in ( + (launcher, "normal"), + ("\\\\?\\" + str(launcher.resolve()), "extended DOS"), + ): + result = run([candidate, "--version"], env) + require( + result.returncode == 0, + "%s launcher path treated sibling creation as replacement access: %s" + % (spelling, output_text(result)[-600:]), + ) + finally: + remove = run(["icacls", ancestor, "/remove:g", "*S-1-1-0"], env) + require( + remove.returncode == 0, + "could not remove native Everyone-add-subdirectory ancestor fixture", + ) + print("PASS: launcher allowed add-only sibling creation without weakening path integrity") + + +def assert_targeted_ancestor_acl_rejected(path, launcher, right, description, env): + grant = run(["icacls", path, "/grant", "*S-1-1-0:(%s)" % right], env) + require( + grant.returncode == 0, + "could not install native Everyone-%s fixture: %s" + % (description, output_text(grant)[-600:]), + ) + try: + for candidate, spelling in ( + (launcher, "normal"), + ("\\\\?\\" + str(launcher.resolve()), "extended DOS"), + ): + result = run([candidate, "--version"], env) + require( + result.returncode != 0, + "%s launcher accepted cross-account %s" + % (spelling, description), + ) + finally: + remove = run(["icacls", path, "/remove:g", "*S-1-1-0"], env) + require( + remove.returncode == 0, + "could not remove native Everyone-%s fixture" % description, + ) + require( + run([launcher, "--version"], env).returncode == 0, + "launcher did not recover after the %s ACE was removed" % description, + ) + + +def assert_file_add_and_executable_parent_acl_rejected( + source_launcher, source_payload, env, work +): + file_add_ancestor = work / "cross-account file-add ancestor" + launcher, _ = copy_portable_pair( + source_launcher, source_payload, file_add_ancestor / "bundle" + ) + assert_targeted_ancestor_acl_rejected( + file_add_ancestor, launcher, "WD", "file creation on an intermediate ancestor", env + ) + + executable_parent = work / "cross-account executable parent" + launcher, _ = copy_portable_pair(source_launcher, source_payload, executable_parent) + assert_targeted_ancestor_acl_rejected( + executable_parent, + launcher, + "AD", + "subdirectory creation beside the executable", + env, + ) + print("PASS: launcher kept file-add and executable-parent ACL boundaries strict") + + def process_entries(): kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.CreateToolhelp32Snapshot.argtypes = [wintypes.DWORD, wintypes.DWORD] @@ -979,6 +1061,12 @@ def main(): env, cache = isolated_environment(work) assert_release_descriptor(source_launcher, source_payload, env, cache) assert_portable_mutations_refuse(source_payload, env, cache, work) + assert_add_only_ancestor_acl_allowed( + source_launcher, source_payload, env, work + ) + assert_file_add_and_executable_parent_acl_rejected( + source_launcher, source_payload, env, work + ) assert_untrusted_ancestor_acl_rejected( source_launcher, source_payload, env, work ) From 89c7c98f00720dd15b3cdc362089a91d7316a606 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 12:26:17 +0200 Subject: [PATCH 08/23] fix: support standard Windows profile ACLs Signed-off-by: Martin Vogel --- src/cli/windows_launcher_state.c | 38 +++++++++++++---- src/daemon/ipc.c | 26 +++++++----- tests/test_cli.c | 37 +++++++---------- tests/test_daemon_ipc.c | 56 ++++++++++++++++++++++++-- tests/test_daemon_runtime.c | 2 + tests/test_lock_registry.c | 2 +- tests/test_platform.c | 2 +- tests/test_windows_bundle_contract.sh | 25 +++++++++--- tests/windows/test_windows_launcher.py | 37 ++++++++++++++++- 9 files changed, 174 insertions(+), 51 deletions(-) diff --git a/src/cli/windows_launcher_state.c b/src/cli/windows_launcher_state.c index 91af10f87..0c5746e45 100644 --- a/src/cli/windows_launcher_state.c +++ b/src/cli/windows_launcher_state.c @@ -585,7 +585,13 @@ static bool windows_owner_is_current(HANDLE file) { return windows_owner_secure(file, true); } -static bool windows_acl_secure(HANDLE file) { +static DWORD windows_private_mutation_rights(void) { + return GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_ADD_FILE | + FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | + DELETE | WRITE_DAC | WRITE_OWNER | ACCESS_SYSTEM_SECURITY; +} + +static bool windows_acl_secure_for_mutation(HANDLE file, DWORD mutation) { HANDLE token = NULL; DWORD token_size = 0U; PTOKEN_USER user = NULL; @@ -604,10 +610,6 @@ static bool windows_acl_secure(HANDLE file) { memset(&information, 0, sizeof(information)); secure = secure && status == ERROR_SUCCESS && descriptor && dacl && IsValidAcl(dacl) != 0 && GetAclInformation(dacl, &information, sizeof(information), AclSizeInformation) != 0; - const DWORD mutation = GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | - FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | - FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | - WRITE_OWNER | ACCESS_SYSTEM_SECURITY; enum { CBM_ACE_ALLOW = 0x00, CBM_ACE_DENY = 0x01, @@ -649,6 +651,10 @@ static bool windows_acl_secure(HANDLE file) { return secure; } +static bool windows_acl_secure(HANDLE file) { + return windows_acl_secure_for_mutation(file, windows_private_mutation_rights()); +} + static bool windows_path_tree_plain(const wchar_t *file_path) { size_t length = file_path ? wcslen(file_path) : 0U; if (length < 4U || length >= CBM_WINDOWS_LAUNCHER_PATH_CAP || file_path[1] != L':' || @@ -682,11 +688,19 @@ static bool windows_path_tree_plain(const wchar_t *file_path) { FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); BY_HANDLE_FILE_INFORMATION information; + DWORD mutation = windows_private_mutation_rights(); + if (index < directory_length) { + /* Default C:\\Users ACLs allow sibling-directory creation. That + * cannot replace the existing next component. The executable's + * immediate parent remains fully private. */ + mutation &= ~((DWORD)FILE_ADD_SUBDIRECTORY); + } valid = component != INVALID_HANDLE_VALUE && GetFileType(component) == FILE_TYPE_DISK && GetFileInformationByHandle(component, &information) != 0 && (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && - windows_owner_secure(component, false) && windows_acl_secure(component); + windows_owner_secure(component, false) && + windows_acl_secure_for_mutation(component, mutation); if (component != INVALID_HANDLE_VALUE) (void)CloseHandle(component); path[index] = saved; @@ -1693,15 +1707,23 @@ static bool windows_prepare_probe_directory(const wchar_t *target, DWORD attributes = GetFileAttributesW(cursor); if (attributes != INVALID_FILE_ATTRIBUTES) { HANDLE ancestor = CreateFileW( - cursor, FILE_READ_ATTRIBUTES, + cursor, FILE_READ_ATTRIBUTES | READ_CONTROL, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); BY_HANDLE_FILE_INFORMATION information; + DWORD mutation = windows_private_mutation_rights(); + if (created->count > 0U) { + /* Only a missing descendant will be created below this + * existing ancestor; sibling-directory creation is sufficient + * and cannot replace an existing component. */ + mutation &= ~((DWORD)FILE_ADD_SUBDIRECTORY); + } valid = ancestor != INVALID_HANDLE_VALUE && GetFileType(ancestor) == FILE_TYPE_DISK && GetFileInformationByHandle(ancestor, &information) != 0 && (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && - windows_owner_secure(ancestor, false) && windows_acl_secure(ancestor); + windows_owner_secure(ancestor, false) && + windows_acl_secure_for_mutation(ancestor, mutation); if (ancestor != INVALID_HANDLE_VALUE) (void)CloseHandle(ancestor); break; diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index f0653136d..abfb6d910 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -3840,7 +3840,13 @@ static bool win_file_owner_secure(win_security_t *security, HANDLE file, return secure; } -static bool win_file_acl_secure(win_security_t *security, HANDLE file) { +static DWORD win_private_mutation_rights(void) { + return GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_ADD_FILE | + FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | + DELETE | WRITE_DAC | WRITE_OWNER | ACCESS_SYSTEM_SECURITY; +} + +static bool win_file_acl_secure(win_security_t *security, HANDLE file, DWORD mutation) { PACL dacl = NULL; PSECURITY_DESCRIPTOR descriptor = NULL; DWORD status = security->get_security_info(file, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, @@ -3850,10 +3856,6 @@ static bool win_file_acl_secure(win_security_t *security, HANDLE file) { bool secure = status == ERROR_SUCCESS && descriptor && dacl && security->is_valid_acl(dacl) && security->get_acl_information(dacl, &information, sizeof(information), AclSizeInformation); - const DWORD mutation = GENERIC_ALL | GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | - FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | - FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | DELETE | WRITE_DAC | - WRITE_OWNER | ACCESS_SYSTEM_SECURITY; enum { WIN_FILE_ACE_ALLOW = 0x00, WIN_FILE_ACE_DENY = 0x01, @@ -3896,9 +3898,9 @@ static bool win_file_acl_secure(win_security_t *security, HANDLE file) { } static bool win_file_security_secure(win_security_t *security, HANDLE file, - bool require_current_user) { + bool require_current_user, DWORD mutation) { return win_file_owner_secure(security, file, require_current_user) && - win_file_acl_secure(security, file); + win_file_acl_secure(security, file, mutation); } static bool win_runtime_directory_secure(const wchar_t *runtime_dir) { @@ -3938,7 +3940,8 @@ static bool win_runtime_directory_secure(const wchar_t *runtime_dir) { NULL, NULL, security.acl, NULL); } bool final_private = - secure_result == ERROR_SUCCESS && win_file_security_secure(&security, directory, true); + secure_result == ERROR_SUCCESS && + win_file_security_secure(&security, directory, true, win_private_mutation_rights()); (void)CloseHandle(directory); win_security_destroy(&security); return valid_handle && owner_ok && final_private; @@ -3953,10 +3956,15 @@ static bool win_directory_component_secure(win_security_t *security, const wchar return false; } BY_HANDLE_FILE_INFORMATION info; + /* Default Windows profile ancestors grant cross-account add-subdirectory. + * That permits siblings but cannot replace the existing next path + * component. Keep every other mutation right forbidden; the final runtime + * directory is separately owner-validated and given a protected DACL. */ + DWORD mutation = win_private_mutation_rights() & ~((DWORD)FILE_ADD_SUBDIRECTORY); bool valid = GetFileInformationByHandle(directory, &info) != 0 && (info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && - win_file_security_secure(security, directory, false); + win_file_security_secure(security, directory, false, mutation); (void)CloseHandle(directory); return valid; } diff --git a/tests/test_cli.c b/tests/test_cli.c index 3e60fe103..cd517b72f 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -1390,34 +1390,12 @@ TEST(cli_update_agent_configs_finish_before_guard_release) { char codex_dir[512]; char codex_config[640]; char release_dir[512]; - char archive_path[768]; - char checksum_path[640]; snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir); snprintf(codex_config, sizeof(codex_config), "%s/config.toml", codex_dir); snprintf(release_dir, sizeof(release_dir), "%s/release", tmpdir); test_mkdirp(codex_dir); test_mkdirp(release_dir); -#if defined(__APPLE__) -#if defined(__aarch64__) || defined(__arm64__) - const char *asset_name = "codebase-memory-mcp-darwin-arm64.tar.gz"; -#else - const char *asset_name = "codebase-memory-mcp-darwin-amd64.tar.gz"; -#endif -#elif defined(_WIN32) -#if defined(_M_ARM64) - const char *asset_name = "codebase-memory-mcp-windows-arm64.zip"; -#else - const char *asset_name = "codebase-memory-mcp-windows-amd64.zip"; -#endif -#else -#if defined(__aarch64__) - const char *asset_name = "codebase-memory-mcp-linux-arm64-portable.tar.gz"; -#else - const char *asset_name = "codebase-memory-mcp-linux-amd64-portable.tar.gz"; -#endif -#endif - #ifdef _WIN32 /* The existing ZIP fixture helper is intentionally exercised elsewhere; * this activation-window regression uses the tar update path. */ @@ -1431,6 +1409,21 @@ TEST(cli_update_agent_configs_finish_before_guard_release) { test_rmdir_r(tmpdir); PASS(); #else + char archive_path[768]; + char checksum_path[640]; +#if defined(__APPLE__) +#if defined(__aarch64__) || defined(__arm64__) + const char *asset_name = "codebase-memory-mcp-darwin-arm64.tar.gz"; +#else + const char *asset_name = "codebase-memory-mcp-darwin-amd64.tar.gz"; +#endif +#else +#if defined(__aarch64__) + const char *asset_name = "codebase-memory-mcp-linux-arm64-portable.tar.gz"; +#else + const char *asset_name = "codebase-memory-mcp-linux-amd64-portable.tar.gz"; +#endif +#endif const char *native_fixture = #ifdef __APPLE__ "/usr/bin/true"; diff --git a/tests/test_daemon_ipc.c b/tests/test_daemon_ipc.c index 2bc2b074d..d1a0edadc 100644 --- a/tests/test_daemon_ipc.c +++ b/tests/test_daemon_ipc.c @@ -58,7 +58,9 @@ enum { TEST_PATH_CAP = 1024 }; +#ifndef _WIN32 static bool ipc_test_write_byte(const char *path, unsigned char byte); +#endif static bool ipc_test_parent_new(char out[TEST_PATH_CAP], const char *tag) { int n = snprintf(out, TEST_PATH_CAP, "%s/cbm-ipc-%s-XXXXXX", cbm_tmpdir(), tag); @@ -289,7 +291,7 @@ typedef struct { bool present; } ipc_test_win_env_t; -static bool ipc_test_win_grant_everyone_mutation(const char *path) { +static bool ipc_test_win_grant_everyone_rights(const char *path, DWORD rights) { wchar_t *wide_path = cbm_utf8_to_wide(path); BYTE world_buffer[SECURITY_MAX_SID_SIZE]; DWORD world_size = sizeof(world_buffer); @@ -301,8 +303,7 @@ static bool ipc_test_win_grant_everyone_mutation(const char *path) { NULL, &existing, NULL, &descriptor) == ERROR_SUCCESS; EXPLICIT_ACCESSW access; memset(&access, 0, sizeof(access)); - access.grfAccessPermissions = FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | FILE_DELETE_CHILD | - DELETE | WRITE_DAC | WRITE_OWNER; + access.grfAccessPermissions = rights; access.grfAccessMode = GRANT_ACCESS; access.grfInheritance = NO_INHERITANCE; access.Trustee.TrusteeForm = TRUSTEE_IS_SID; @@ -325,6 +326,12 @@ static bool ipc_test_win_grant_everyone_mutation(const char *path) { return ok; } +static bool ipc_test_win_grant_everyone_mutation(const char *path) { + return ipc_test_win_grant_everyone_rights(path, FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY | + FILE_DELETE_CHILD | DELETE | WRITE_DAC | + WRITE_OWNER); +} + static bool ipc_test_win_env_capture(const wchar_t *name, ipc_test_win_env_t *saved) { if (!name || !saved) { return false; @@ -737,6 +744,48 @@ TEST(daemon_ipc_windows_private_directory_rejects_untrusted_ancestor_acl) { PASS(); } +TEST(daemon_ipc_windows_private_directory_allows_add_subdirectory_only_ancestor) { + char parent[TEST_PATH_CAP] = {0}; + char add_only[TEST_PATH_CAP] = {0}; + char cache[TEST_PATH_CAP] = {0}; + bool paths_ok = false; + bool ancestor_created = false; + bool acl_injected = false; + bool secured = false; + bool cache_created = false; + + if (ipc_test_parent_new(parent, "win-add-only-ancestor")) { + int ancestor_written = snprintf(add_only, sizeof(add_only), "%s/add-only", parent); + int cache_written = snprintf(cache, sizeof(cache), "%s/cache", add_only); + paths_ok = ancestor_written > 0 && ancestor_written < (int)sizeof(add_only) && + cache_written > 0 && cache_written < (int)sizeof(cache); + } + if (paths_ok) { + ancestor_created = CreateDirectoryA(add_only, NULL) != 0; + } + if (ancestor_created) { + acl_injected = ipc_test_win_grant_everyone_rights(add_only, FILE_ADD_SUBDIRECTORY); + } + if (acl_injected) { + secured = cbm_daemon_ipc_private_directory_secure(cache); + DWORD attributes = GetFileAttributesA(cache); + cache_created = attributes != INVALID_FILE_ATTRIBUTES && + (attributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && + (attributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; + } + + (void)RemoveDirectoryA(cache); + (void)RemoveDirectoryA(add_only); + ipc_test_remove_flat_dir(parent); + + ASSERT_TRUE(paths_ok); + ASSERT_TRUE(ancestor_created); + ASSERT_TRUE(acl_injected); + ASSERT_TRUE(secured); + ASSERT_TRUE(cache_created); + PASS(); +} + TEST(daemon_ipc_windows_legacy_bridge_covers_handoff_and_lifetime) { static const char key[] = "91a2b3c4d5e6f708"; char parent[TEST_PATH_CAP] = {0}; @@ -4504,6 +4553,7 @@ SUITE(daemon_ipc) { #ifdef _WIN32 RUN_TEST(daemon_ipc_windows_default_endpoint_ignores_temp_environment); RUN_TEST(daemon_ipc_windows_private_directory_rejects_untrusted_ancestor_acl); + RUN_TEST(daemon_ipc_windows_private_directory_allows_add_subdirectory_only_ancestor); RUN_TEST(daemon_ipc_windows_legacy_bridge_covers_handoff_and_lifetime); RUN_TEST(daemon_ipc_windows_local_transition_atomically_reserves_legacy_pipe); RUN_TEST(daemon_ipc_windows_startup_retries_transient_rendezvous_reader); diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index 0ceae97ad..9985dc4c5 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -89,8 +89,10 @@ _Static_assert(CBM_DAEMON_RUNTIME_OP_ACTIVATION_SHUTDOWN == 8 && static const char RUNTIME_BUILD_B[] = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +#ifndef _WIN32 static const char RUNTIME_CACHE_A[] = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +#endif static char runtime_self_build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE]; static atomic_bool runtime_conflict_log_fallback_seen; static atomic_bool runtime_activation_shutdown_log_seen; diff --git a/tests/test_lock_registry.c b/tests/test_lock_registry.c index 4c9c29752..05c16d90d 100644 --- a/tests/test_lock_registry.c +++ b/tests/test_lock_registry.c @@ -415,6 +415,7 @@ static int lock_registry_abort_bookkeeping_failure_retains_cleanup( ASSERT_EQ(final_free, CBM_PRIVATE_FILE_LOCK_OK); PASS(); } +#endif TEST(lock_registry_terminal_close_error_finishes_pending_accounting) { #ifdef _WIN32 @@ -472,7 +473,6 @@ TEST(lock_registry_terminal_close_error_finishes_pending_accounting) { PASS(); #endif } -#endif TEST(lock_registry_abort_lock_failure_returns_waiter_cleanup_lease) { #ifdef _WIN32 diff --git a/tests/test_platform.c b/tests/test_platform.c index a1001579b..3e1fe2fbf 100644 --- a/tests/test_platform.c +++ b/tests/test_platform.c @@ -292,7 +292,7 @@ TEST(platform_setenv_preserves_utf8_in_wide_environment) { * partially initialized paths. */ TEST(platform_windows_empty_environment_is_read_and_unset_idempotently) { ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", "", 1), 0); - char observed[8] = "sentinel"; + char observed[9] = "sentinel"; ASSERT_NOT_NULL(cbm_safe_getenv("CBM_CACHE_DIR", observed, sizeof(observed), "fallback")); ASSERT_STR_EQ(observed, ""); ASSERT_EQ(cbm_unsetenv("CBM_CACHE_DIR"), 0); diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index 49e5167f7..caa5cfc31 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -418,20 +418,35 @@ require( # Every native path-tree trust boundary must validate opened ancestor handles, # reject reparse points, inspect mutation-capable allow ACEs, and recognize the # bounded privileged-principal set (current user, SYSTEM, Administrators, and -# the fixed TrustedInstaller service SID). The native test injects a real -# Everyone-modify ACE; these static markers keep all three independently linked -# implementations aligned even on non-Windows presubmit hosts. +# the fixed TrustedInstaller service SID). The normal C:\\Users ancestor grants +# cross-account add-subdirectory, so each implementation permits only that +# right on intermediate components while keeping its final private directory +# strict. The native tests inject real ACLs; these static markers preserve the +# narrow exception and its surrounding defenses on non-Windows presubmit hosts. ancestor_security_contracts = { "src/daemon/ipc.c": ( "win_directory_component_secure", - "win_file_security_secure(security, directory, false)", + "win_file_security_secure(security, directory, false, mutation)", + "win_private_mutation_rights()", + "~((DWORD)FILE_ADD_SUBDIRECTORY)", + "FILE_ADD_FILE", + "FILE_DELETE_CHILD", + "final runtime", "ACCESS_SYSTEM_SECURITY", "956008885U", "FILE_ATTRIBUTE_REPARSE_POINT", ), "src/cli/windows_launcher_state.c": ( "windows_owner_secure(component, false)", - "windows_acl_secure(component)", + "windows_acl_secure_for_mutation(component, mutation)", + "windows_private_mutation_rights()", + "if (index < directory_length)", + "if (created->count > 0U)", + "mutation &= ~((DWORD)FILE_ADD_SUBDIRECTORY)", + "FILE_ADD_FILE", + "FILE_DELETE_CHILD", + "immediate parent remains fully private", + "FILE_READ_ATTRIBUTES | READ_CONTROL", "ACCESS_SYSTEM_SECURITY", "956008885U", "FILE_FLAG_OPEN_REPARSE_POINT", diff --git a/tests/windows/test_windows_launcher.py b/tests/windows/test_windows_launcher.py index edf0086d8..13d4909ea 100644 --- a/tests/windows/test_windows_launcher.py +++ b/tests/windows/test_windows_launcher.py @@ -399,7 +399,9 @@ def assert_untrusted_ancestor_acl_rejected( def assert_add_only_ancestor_acl_allowed(source_launcher, source_payload, env, work): ancestor = work / "cross-account add-only ancestor" - launcher, _ = copy_portable_pair(source_launcher, source_payload, ancestor / "bundle") + launcher, portable_payload = copy_portable_pair( + source_launcher, source_payload, ancestor / "bundle" + ) grant = run(["icacls", ancestor, "/grant", "*S-1-1-0:(AD)"], env) require( grant.returncode == 0, @@ -417,13 +419,44 @@ def assert_add_only_ancestor_acl_allowed(source_launcher, source_payload, env, w "%s launcher path treated sibling creation as replacement access: %s" % (spelling, output_text(result)[-600:]), ) + + managed_dir = ancestor / "managed install under add-only ancestor" + install = run( + [ + portable_payload, + "install", + "--yes", + "--force", + "--skip-config", + "--dir", + managed_dir, + ], + env, + timeout=60, + ) + require( + install.returncode == 0, + "managed install rejected a standard add-subdirectory-only ancestor: %s" + % output_text(install)[-800:], + ) + managed_launcher = managed_dir / "codebase-memory-mcp.exe" + require(managed_launcher.is_file(), "add-only managed install produced no launcher") + uninstall = run([managed_launcher, "uninstall", "--yes"], env, timeout=60) + require( + uninstall.returncode == 0, + "add-only managed install could not be uninstalled: %s" + % output_text(uninstall)[-800:], + ) finally: remove = run(["icacls", ancestor, "/remove:g", "*S-1-1-0"], env) require( remove.returncode == 0, "could not remove native Everyone-add-subdirectory ancestor fixture", ) - print("PASS: launcher allowed add-only sibling creation without weakening path integrity") + print( + "PASS: launcher and managed install allowed add-only sibling creation " + "without weakening path integrity" + ) def assert_targeted_ancestor_acl_rejected(path, launcher, right, description, env): From 410c8d70c8f3c421b0d5ee00061de987ce3bba75 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 13:10:55 +0200 Subject: [PATCH 09/23] fix: stabilize Windows coordination startup Signed-off-by: Martin Vogel --- .github/workflows/_smoke.yml | 3 +- .github/workflows/pr.yml | 3 +- src/main.c | 87 ++++++++++++++++++++++----- tests/test_lock_registry.c | 8 +++ tests/test_windows_bundle_contract.sh | 2 + tests/windows/mcp_stdio.py | 8 ++- 6 files changed, 91 insertions(+), 20 deletions(-) diff --git a/.github/workflows/_smoke.yml b/.github/workflows/_smoke.yml index b4059e9b7..c635ebd4a 100644 --- a/.github/workflows/_smoke.yml +++ b/.github/workflows/_smoke.yml @@ -248,7 +248,8 @@ jobs: SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-smoke.XXXXXX")" trap 'rm -rf "$SMOKE_DIR"' EXIT cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$SMOKE_DIR/" - SMOKE_TEMP_ROOT="$SMOKE_DIR" \ + CBM_CACHE_DIR="$(cygpath -m "$SMOKE_DIR/cache")" \ + SMOKE_TEMP_ROOT="$SMOKE_DIR" \ scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" env: SMOKE_DOWNLOAD_URL: http://127.0.0.1:18080 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a98a1d62b..797a335bd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -124,7 +124,8 @@ jobs: "$SMOKE_DIR/codebase-memory-mcp.exe" cp build/c/codebase-memory-mcp.exe \ "$SMOKE_DIR/codebase-memory-mcp.payload.exe" - SMOKE_TEMP_ROOT="$SMOKE_DIR" \ + CBM_CACHE_DIR="$(cygpath -m "$SMOKE_DIR/cache")" \ + SMOKE_TEMP_ROOT="$SMOKE_DIR" \ scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe" ci-ok: diff --git a/src/main.c b/src/main.c index d269957a0..4f3599adf 100644 --- a/src/main.c +++ b/src/main.c @@ -1018,19 +1018,52 @@ static bool main_resolve_executable(const char *argv0, char out[MAIN_PATH_CAP]) cbm_canonical_path(resolved, out, MAIN_PATH_CAP); } -static bool main_build_identity(cbm_daemon_build_identity_t *identity) { - if (!identity || !cbm_index_supervisor_capture_build_fingerprint()) { - return false; +typedef enum { + MAIN_BUILD_IDENTITY_OK = 0, + MAIN_BUILD_IDENTITY_INVALID_OUTPUT, + MAIN_BUILD_IDENTITY_PROCESS_FINGERPRINT, + MAIN_BUILD_IDENTITY_CACHE_RESOLVE, + MAIN_BUILD_IDENTITY_CACHE_CANONICALIZE, + MAIN_BUILD_IDENTITY_CACHE_PRIVATE, + MAIN_BUILD_IDENTITY_CACHE_ENVIRONMENT, +} main_build_identity_status_t; + +static const char *main_build_identity_status_name(main_build_identity_status_t status) { + switch (status) { + case MAIN_BUILD_IDENTITY_OK: + return "ok"; + case MAIN_BUILD_IDENTITY_INVALID_OUTPUT: + return "identity-output"; + case MAIN_BUILD_IDENTITY_PROCESS_FINGERPRINT: + return "process-fingerprint"; + case MAIN_BUILD_IDENTITY_CACHE_RESOLVE: + return "cache-resolve"; + case MAIN_BUILD_IDENTITY_CACHE_CANONICALIZE: + return "cache-canonicalize"; + case MAIN_BUILD_IDENTITY_CACHE_PRIVATE: + return "cache-private"; + case MAIN_BUILD_IDENTITY_CACHE_ENVIRONMENT: + return "cache-environment"; + } + return "identity-unknown"; +} + +static main_build_identity_status_t main_build_identity(cbm_daemon_build_identity_t *identity) { + if (!identity) { + return MAIN_BUILD_IDENTITY_INVALID_OUTPUT; + } + if (!cbm_index_supervisor_capture_build_fingerprint()) { + return MAIN_BUILD_IDENTITY_PROCESS_FINGERPRINT; } const char *fingerprint = cbm_index_supervisor_build_fingerprint(); if (!fingerprint) { - return false; + return MAIN_BUILD_IDENTITY_PROCESS_FINGERPRINT; } const char *cache = cbm_resolve_cache_dir(); char canonical_cache[MAIN_PATH_CAP]; static char cache_fingerprint[CBM_SHA256_HEX_LEN + 1]; if (!cache || !cache[0]) { - return false; + return MAIN_BUILD_IDENTITY_CACHE_RESOLVE; } /* Preserve one intentional alias spelling at the process boundary: an * existing directory (including a symlink supplied by the user) is @@ -1043,7 +1076,7 @@ static bool main_build_identity(cbm_daemon_build_identity_t *identity) { cache_ready = cbm_canonical_path(cache, canonical_cache, sizeof(canonical_cache)); } if (!cache_ready || !cbm_is_dir(canonical_cache)) { - return false; + return MAIN_BUILD_IDENTITY_CACHE_CANONICALIZE; } cbm_normalize_path_sep(canonical_cache); /* Admission is account-scoped, so its storage authority must be too. @@ -1052,14 +1085,14 @@ static bool main_build_identity(cbm_daemon_build_identity_t *identity) { * the v1 threat boundary; cross-account and unsafe filesystem states fail * here before any daemon/cohort state is opened. */ if (!cbm_daemon_ipc_private_directory_secure(canonical_cache)) { - return false; + return MAIN_BUILD_IDENTITY_CACHE_PRIVATE; } /* Every cache consumer in this process must use the exact path whose * fingerprint joins the account-wide cohort. Keeping an original symlink * spelling in the environment would let a later retarget move storage * while the process still advertises the old canonical root. */ if (cbm_setenv("CBM_CACHE_DIR", canonical_cache, 1) != 0) { - return false; + return MAIN_BUILD_IDENTITY_CACHE_ENVIRONMENT; } cbm_sha256_hex(canonical_cache, strlen(canonical_cache), cache_fingerprint); *identity = (cbm_daemon_build_identity_t){ @@ -1070,7 +1103,7 @@ static bool main_build_identity(cbm_daemon_build_identity_t *identity) { .store_abi = 1, .feature_abi = 1, }; - return true; + return MAIN_BUILD_IDENTITY_OK; } static uint64_t main_deadline_after(uint32_t timeout_ms) { @@ -1378,14 +1411,27 @@ int main(int argc, char **argv) { cbm_daemon_maintenance_monitor_t *maintenance_monitor = NULL; cbm_daemon_conflict_t cohort_conflict; cbm_version_cohort_status_t cohort_status = CBM_VERSION_COHORT_IO; + main_build_identity_status_t local_identity_status = MAIN_BUILD_IDENTITY_OK; int result = CBM_NOT_FOUND; int exit_code = EXIT_FAILURE; bool cleanup_ok = true; - if (!local_endpoint || !project_locks || !cohort_manager || - !main_resolve_executable(argv[0], local_executable) || - !main_build_identity(&local_identity)) { - (void)fprintf(stderr, - "codebase-memory-mcp: secure CLI coordination could not be created\n"); + const char *coordination_failure = NULL; + if (!local_endpoint) { + coordination_failure = "endpoint"; + } else if (!project_locks) { + coordination_failure = "project-locks"; + } else if (!cohort_manager) { + coordination_failure = "version-cohort"; + } else if (!main_resolve_executable(argv[0], local_executable)) { + coordination_failure = "executable-path"; + } else if ((local_identity_status = main_build_identity(&local_identity)) != + MAIN_BUILD_IDENTITY_OK) { + coordination_failure = main_build_identity_status_name(local_identity_status); + } + if (coordination_failure) { + (void)fprintf( + stderr, "codebase-memory-mcp: secure CLI coordination could not be created (%s)\n", + coordination_failure); goto local_cli_cleanup; } cbm_http_server_set_binary_path(local_executable); @@ -1484,9 +1530,18 @@ int main(int argc, char **argv) { char executable_path[MAIN_PATH_CAP]; cbm_daemon_build_identity_t identity; - if (!main_resolve_executable(argv[0], executable_path) || !main_build_identity(&identity)) { + if (!main_resolve_executable(argv[0], executable_path)) { + (void)fprintf(stderr, + "codebase-memory-mcp: exact executable identity could not be verified " + "(executable-path)\n"); + return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS : EXIT_FAILURE; + } + main_build_identity_status_t identity_status = main_build_identity(&identity); + if (identity_status != MAIN_BUILD_IDENTITY_OK) { (void)fprintf(stderr, - "codebase-memory-mcp: exact executable identity could not be verified\n"); + "codebase-memory-mcp: exact executable identity could not be verified " + "(%s)\n", + main_build_identity_status_name(identity_status)); return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS : EXIT_FAILURE; } cbm_http_server_set_binary_path(executable_path); diff --git a/tests/test_lock_registry.c b/tests/test_lock_registry.c index 05c16d90d..c83fc6bf6 100644 --- a/tests/test_lock_registry.c +++ b/tests/test_lock_registry.c @@ -65,6 +65,7 @@ static cbm_private_lock_directory_t *lock_registry_test_directory_open(const cha } #endif +#ifndef _WIN32 static bool lock_registry_fixture_start(lock_registry_fixture_t *fixture) { memset(fixture, 0, sizeof(*fixture)); #ifdef _WIN32 @@ -109,6 +110,7 @@ static void lock_registry_fixture_finish(lock_registry_fixture_t *fixture) { #endif memset(fixture, 0, sizeof(*fixture)); } +#endif typedef struct { cbm_lock_registry_t *registry; @@ -120,6 +122,7 @@ typedef struct { cbm_lock_lease_t *lease; } lock_registry_waiter_t; +#ifndef _WIN32 static void *lock_registry_waiter_run(void *opaque) { lock_registry_waiter_t *waiter = opaque; waiter->status = cbm_lock_registry_acquire(waiter->registry, waiter->resource_key, waiter->mode, @@ -142,6 +145,7 @@ static void lock_registry_cancel_at_native_ready(void *opaque, cbm_private_file_ atomic_store_explicit(&fault->cancel_token, true, memory_order_release); } } +#endif TEST(lock_registry_cancelled_wait_rolls_back_and_does_not_barge) { #ifdef _WIN32 @@ -1023,6 +1027,7 @@ typedef struct { cbm_lock_lease_t *lease; } lock_registry_deadline_waiter_t; +#ifndef _WIN32 static void *lock_registry_deadline_waiter_run(void *opaque) { lock_registry_deadline_waiter_t *waiter = opaque; atomic_store_explicit(&waiter->ready, true, memory_order_release); @@ -1036,6 +1041,7 @@ static void *lock_registry_deadline_waiter_run(void *opaque) { atomic_store_explicit(&waiter->finished, true, memory_order_release); return NULL; } +#endif TEST(lock_registry_absolute_deadline_survives_repeated_wakes) { #ifdef _WIN32 @@ -1169,6 +1175,7 @@ typedef struct { atomic_int *violations; } lock_registry_stress_worker_t; +#ifndef _WIN32 static void *lock_registry_stress_run(void *opaque) { lock_registry_stress_worker_t *worker = opaque; (void)atomic_fetch_add_explicit(worker->ready, 1, memory_order_acq_rel); @@ -1215,6 +1222,7 @@ static void *lock_registry_stress_run(void *opaque) { } return NULL; } +#endif TEST(lock_registry_concurrent_shared_exclusive_stress) { #ifdef _WIN32 diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index caa5cfc31..823fedc75 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -508,6 +508,7 @@ require( 'PROFILE_ROOT="$(cygpath -u "$USERPROFILE")"', 'SMOKE_DIR="$(mktemp -d "$PROFILE_ROOT/cbm-release-smoke.XXXXXX")"', 'cp codebase-memory-mcp.exe codebase-memory-mcp.payload.exe "$SMOKE_DIR/"', + 'CBM_CACHE_DIR="$(cygpath -m "$SMOKE_DIR/cache")" ' 'SMOKE_TEMP_ROOT="$SMOKE_DIR" ' 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"', ) @@ -655,6 +656,7 @@ if pr_windows_blocks: '"$SMOKE_DIR/codebase-memory-mcp.exe"', 'cp build/c/codebase-memory-mcp.exe ' '"$SMOKE_DIR/codebase-memory-mcp.payload.exe"', + 'CBM_CACHE_DIR="$(cygpath -m "$SMOKE_DIR/cache")" ' 'SMOKE_TEMP_ROOT="$SMOKE_DIR" ' 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"', ) diff --git a/tests/windows/mcp_stdio.py b/tests/windows/mcp_stdio.py index 251cff8dd..055f8e069 100644 --- a/tests/windows/mcp_stdio.py +++ b/tests/windows/mcp_stdio.py @@ -45,7 +45,8 @@ def start(self): [self.binary], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=self.env, cwd=self.cwd, bufsize=0) - threading.Thread(target=self._drain_stderr, daemon=True).start() + self._stderr_thread = threading.Thread(target=self._drain_stderr, daemon=True) + self._stderr_thread.start() def _drain_stderr(self): try: @@ -80,7 +81,10 @@ def reader(): raise McpError("read error: %r" % result["exc"]) line = result.get("line", b"") if not line: - raise McpError("EOF / server closed stdout") + self._stderr_thread.join(timeout=1) + stderr = self.stderr_text().strip() + detail = ": %s" % stderr[-1200:] if stderr else "" + raise McpError("EOF / server closed stdout%s" % detail) # strict: an invalid-UTF-8 JSON-RPC response is itself a failure. return json.loads(line.decode("utf-8", "strict")) From f35b10b79bfe99439c9de8a370da60e42d89e9bc Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 14:38:07 +0200 Subject: [PATCH 10/23] fix: harden cross-platform daemon startup smoke Signed-off-by: Martin Vogel --- scripts/test-windows.ps1 | 34 ++++++----- scripts/test_mcp_interactive.py | 71 +++++++++++++++++---- src/daemon/bootstrap.c | 32 +++++++++- src/daemon/host.c | 24 +++++--- tests/test_daemon_runtime.c | 88 +++++++++++++++++++++++++++ tests/test_lock_registry.c | 7 +-- tests/test_windows_bundle_contract.sh | 14 ++++- 7 files changed, 224 insertions(+), 46 deletions(-) diff --git a/scripts/test-windows.ps1 b/scripts/test-windows.ps1 index a0d7451f4..8fb975f91 100644 --- a/scripts/test-windows.ps1 +++ b/scripts/test-windows.ps1 @@ -133,24 +133,26 @@ Write-Host "Payload: $bin" -ForegroundColor Green Write-Host "Launcher: $launcherBin" -ForegroundColor Green Write-Host "ABI mismatch fixture: $abiMismatchLauncher" -ForegroundColor Green -$localAppData = [Environment]::GetFolderPath([Environment+SpecialFolder]::LocalApplicationData) -if (-not $localAppData) { throw "could not resolve the current user's local application-data directory" } -$guardRoot = Join-Path $localAppData "Temp" -New-Item -ItemType Directory -Path $guardRoot -Force | Out-Null -$guardBundle = Join-Path $guardRoot ("cbm-windows-guards-" + [guid]::NewGuid().ToString("N")) -New-Item -ItemType Directory -Path $guardBundle | Out-Null -$guardBin = Join-Path $guardBundle "codebase-memory-mcp.exe" -$guardPayload = Join-Path $guardBundle "codebase-memory-mcp.payload.exe" -Copy-Item -LiteralPath $launcherBin -Destination $guardBin -Copy-Item -LiteralPath $bin -Destination $guardPayload -Write-Host "Guard bundle: $guardBin" -ForegroundColor Green - $previousTemp = $env:TEMP $previousTmp = $env:TMP $previousTmpDir = $env:TMPDIR +$guardRoot = $null try { - # The launcher deliberately rejects GitHub's shared D:\a ancestry. Keep - # launcher fixtures and Python-created descendants inside this account. + $userProfile = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile) + if (-not $userProfile) { throw "could not resolve the current user's profile directory" } + $guardRoot = Join-Path $userProfile ("cbm-windows-guards-root-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $guardRoot | Out-Null + $guardBundle = Join-Path $guardRoot ("cbm-windows-guards-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $guardBundle | Out-Null + $guardBin = Join-Path $guardBundle "codebase-memory-mcp.exe" + $guardPayload = Join-Path $guardBundle "codebase-memory-mcp.payload.exe" + Copy-Item -LiteralPath $launcherBin -Destination $guardBin + Copy-Item -LiteralPath $bin -Destination $guardPayload + Write-Host "Guard bundle: $guardBin" -ForegroundColor Green + + # The launcher deliberately rejects GitHub's shared D:\a ancestry and the + # hosted runner's inherited LocalAppData\Temp ACL. Keep launcher fixtures + # and Python-created descendants below the accepted profile ancestry. $env:TEMP = $guardRoot $env:TMP = $guardRoot $env:TMPDIR = $guardRoot @@ -215,10 +217,12 @@ if (-not $GuardsOnly) { } } } finally { - Remove-Item -LiteralPath $guardBundle -Recurse -Force -ErrorAction SilentlyContinue $env:TEMP = $previousTemp $env:TMP = $previousTmp $env:TMPDIR = $previousTmpDir + if ($guardRoot) { + Remove-Item -LiteralPath $guardRoot -Recurse -Force -ErrorAction SilentlyContinue + } } Write-Host "" diff --git a/scripts/test_mcp_interactive.py b/scripts/test_mcp_interactive.py index 47c7249e8..d37e2d57c 100644 --- a/scripts/test_mcp_interactive.py +++ b/scripts/test_mcp_interactive.py @@ -17,7 +17,7 @@ import sys import threading import time -from typing import Any, BinaryIO +from typing import Any, BinaryIO, Optional INITIALIZE_PARAMS = { @@ -68,6 +68,21 @@ def drain(stream: BinaryIO, chunks: list[bytes]) -> None: chunks.append(chunk) +def daemon_log_tail(limit: int = 16384) -> str: + cache_dir = os.environ.get("CBM_CACHE_DIR") + if not cache_dir: + return "" + path = os.path.join(cache_dir, "logs", "cbm-daemon.log") + try: + with open(path, "rb") as stream: + stream.seek(0, os.SEEK_END) + size = stream.tell() + stream.seek(max(0, size - limit), os.SEEK_SET) + return stream.read(limit).decode("utf-8", errors="replace") + except OSError: + return "" + + def send(process: subprocess.Popen[bytes], message: dict[str, Any]) -> None: if process.stdin is None: raise SmokeFailure("MCP stdin is unavailable") @@ -143,6 +158,39 @@ def request( return wait_response(process, responses, request_id, timeout, accept_tool_error) +def grouped_search_qualified_name( + structured: Any, expected_name: str +) -> Optional[str]: + """Extract a qualified name from search_graph's grouped JSON tree.""" + if not isinstance(structured, dict): + return None + columns = structured.get("cols") + groups = structured.get("groups") + if not isinstance(columns, list) or not isinstance(groups, list): + return None + try: + name_column = columns.index("name") + except ValueError: + return None + + for group in groups: + if not isinstance(group, dict): + return None + prefix = group.get("qn_prefix") + rows = group.get("rows") + if not isinstance(prefix, str) or not isinstance(rows, list): + return None + for row in rows: + if not isinstance(row, list) or name_column >= len(row): + return None + name = row[name_column] + if not isinstance(name, str): + return None + if name == expected_name: + return f"{prefix}.{name}" if prefix else name + return None + + def run_scenario( process: subprocess.Popen[bytes], responses: "queue.Queue[dict[str, Any]]", @@ -245,17 +293,8 @@ def run_scenario( if isinstance(discovery_result, dict) else None ) - matches = ( - discovery_structured.get("results") - if isinstance(discovery_structured, dict) - else None - ) - qualified_name = ( - matches[0].get("qualified_name") - if isinstance(matches, list) and matches and isinstance(matches[0], dict) - else None - ) - if not isinstance(qualified_name, str) or not qualified_name: + qualified_name = grouped_search_qualified_name(discovery_structured, "compute") + if not qualified_name: raise SmokeFailure("search_graph did not discover compute's qualified name") request( process, @@ -346,10 +385,18 @@ def main() -> int: except SmokeFailure as error: stop_process(process) stderr = b"".join(stderr_chunks).decode("utf-8", errors="replace") + daemon_log = daemon_log_tail() print(f"FAIL: {error}", file=sys.stderr) if stderr: print("--- MCP stderr ---", file=sys.stderr) print(stderr, file=sys.stderr, end="" if stderr.endswith("\n") else "\n") + if daemon_log: + print("--- daemon log (tail) ---", file=sys.stderr) + print( + daemon_log, + file=sys.stderr, + end="" if daemon_log.endswith("\n") else "\n", + ) return 1 diff --git a/src/daemon/bootstrap.c b/src/daemon/bootstrap.c index ff080114d..f41eba8fb 100644 --- a/src/daemon/bootstrap.c +++ b/src/daemon/bootstrap.c @@ -520,7 +520,9 @@ typedef struct bootstrap_production_cohort { typedef struct { bootstrap_production_cohort_t *cohort; -#ifdef __APPLE__ +#ifdef _WIN32 + DWORD spawn_error; +#elif defined(__APPLE__) int spawn_error; #endif } bootstrap_production_context_t; @@ -674,17 +676,29 @@ static bool bootstrap_production_handoff(void *context, cbm_daemon_bootstrap_loc #ifdef _WIN32 static bool bootstrap_production_spawn(void *context, const cbm_daemon_bootstrap_launch_spec_t *spec) { - (void)context; + bootstrap_production_context_t *production = context; + if (production) { + production->spawn_error = ERROR_SUCCESS; + } if (!spec || !spec->detached || spec->inherit_standard_handles || spec->use_shell) { + if (production) { + production->spawn_error = ERROR_INVALID_PARAMETER; + } return false; } char command_line[BOOTSTRAP_PATH_CAP * 2]; if (!cbm_build_win_cmdline(command_line, sizeof(command_line), spec->argv)) { + if (production) { + production->spawn_error = ERROR_INVALID_PARAMETER; + } return false; } wchar_t *application = cbm_utf8_to_wide(spec->executable_path); wchar_t *command = cbm_utf8_to_wide(command_line); if (!application || !command) { + if (production) { + production->spawn_error = ERROR_NOT_ENOUGH_MEMORY; + } free(application); free(command); return false; @@ -712,8 +726,12 @@ static bool bootstrap_production_spawn(void *context, } BOOL created = CreateProcessW(application, command, NULL, NULL, FALSE, flags, NULL, NULL, &startup, &child); + DWORD spawn_error = created ? ERROR_SUCCESS : GetLastError(); free(application); free(command); + if (production) { + production->spawn_error = spawn_error; + } if (!created) { return false; } @@ -895,7 +913,15 @@ static bool bootstrap_production_spawn(void *context, static void bootstrap_production_diagnostic(void *context, const char *message) { bootstrap_production_context_t *production = context; -#ifdef __APPLE__ +#ifdef _WIN32 + if (production && production->spawn_error != ERROR_SUCCESS) { + (void)fprintf(stderr, "codebase-memory-mcp: %s (daemon launch error %lu)\n", + message ? message : "daemon startup failed", + (unsigned long)production->spawn_error); + (void)fflush(stderr); + return; + } +#elif defined(__APPLE__) if (production && production->spawn_error != 0) { (void)fprintf(stderr, "codebase-memory-mcp: %s (daemon launch: %s)\n", message ? message : "daemon startup failed", diff --git a/src/daemon/host.c b/src/daemon/host.c index 7c96f518d..e7cde9cb0 100644 --- a/src/daemon/host.c +++ b/src/daemon/host.c @@ -757,6 +757,12 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { !config->identity.semantic_version || !config->identity.build_fingerprint) { return -1; } + cbm_ui_log_init(); + char conflict_log[HOST_PATH_CAP]; + if (!host_log_open(conflict_log)) { + (void)fprintf(stderr, "codebase-memory: daemon log path is not private or safe\n"); + return -1; + } cbm_version_cohort_manager_t *cohort_manager = cbm_version_cohort_manager_new(config->endpoint); cbm_version_cohort_lease_t *cohort_lease = NULL; cbm_daemon_conflict_t cohort_conflict; @@ -770,6 +776,7 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { &cohort_lease, &cohort_conflict) : CBM_VERSION_COHORT_IO; if (cohort_status != CBM_VERSION_COHORT_OK) { + cbm_log_error("daemon.start_failed", "component", "cohort"); char message[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; bool formatted = cohort_status == CBM_VERSION_COHORT_CONFLICT && cbm_daemon_conflict_format(&cohort_conflict, message, sizeof(message)); @@ -779,41 +786,38 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { (void)fprintf(stderr, "codebase-memory: %s\n", formatted ? message : "daemon exact-build admission failed"); host_cohort_close(&cohort_lease, &cohort_manager); + host_log_close(); return -1; } cbm_daemon_ipc_participant_guard_t *participant_guard = NULL; if (cbm_daemon_ipc_participant_guard_try_join(config->endpoint, &participant_guard) != 1) { + cbm_log_error("daemon.start_failed", "component", "participant"); (void)fprintf(stderr, "codebase-memory: daemon participant admission failed\n"); host_participant_guard_close(&participant_guard); host_cohort_close(&cohort_lease, &cohort_manager); + host_log_close(); return -1; } cbm_daemon_ipc_lifetime_reservation_t *lifetime_reservation = NULL; if (host_lifetime_reservation_acquire(config->endpoint, &lifetime_reservation) != 1) { + cbm_log_error("daemon.start_failed", "component", "lifetime"); host_participant_guard_close(&participant_guard); host_cohort_close(&cohort_lease, &cohort_manager); + host_log_close(); return -1; } cbm_version_cohort_daemon_claim_t *daemon_claim = NULL; if (cbm_version_cohort_daemon_claim_acquire(cohort_manager, &daemon_claim) != CBM_VERSION_COHORT_OK) { + cbm_log_error("daemon.start_failed", "component", "claim"); cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation); host_daemon_claim_close(&daemon_claim); host_participant_guard_close(&participant_guard); host_cohort_close(&cohort_lease, &cohort_manager); + host_log_close(); return -1; } cbm_mem_init(cbm_mem_ram_fraction_for_total(cbm_system_info().total_ram)); - cbm_ui_log_init(); - char conflict_log[HOST_PATH_CAP]; - if (!host_log_open(conflict_log)) { - (void)fprintf(stderr, "codebase-memory: daemon log path is not private or safe\n"); - cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation); - host_daemon_claim_close(&daemon_claim); - host_participant_guard_close(&participant_guard); - host_cohort_close(&cohort_lease, &cohort_manager); - return -1; - } cbm_http_server_set_binary_path(config->executable_path); cbm_index_supervisor_mark_host(); diff --git a/tests/test_daemon_runtime.c b/tests/test_daemon_runtime.c index 9985dc4c5..ff54b2b1c 100644 --- a/tests/test_daemon_runtime.c +++ b/tests/test_daemon_runtime.c @@ -15,6 +15,7 @@ #include "daemon/ipc.h" #include "daemon/runtime.h" #include "daemon/service.h" +#include "daemon/version_cohort.h" #include "foundation/compat.h" #include "foundation/compat_fs.h" #include "foundation/compat_thread.h" @@ -1398,6 +1399,92 @@ static bool runtime_test_read_log(const char *path, char out[RUNTIME_TEST_LOG_CA return complete; } +/* A detached daemon loses its inherited stderr by design. Failures before the + * runtime listener exists must therefore reach the owner-private operation log + * or users and smoke tests see only a generic bootstrap timeout. An existing + * daemon claim deterministically fails before runtime startup. */ +TEST(daemon_host_early_coordination_failure_is_durable) { + const char *old_cache = getenv("CBM_CACHE_DIR"); + bool had_cache = old_cache != NULL; + char *saved_cache = old_cache ? cbm_strdup(old_cache) : NULL; + bool snapshot_ok = !had_cache || saved_cache; + + char parent[RUNTIME_TEST_PATH_CAP] = {0}; + char cache[RUNTIME_TEST_PATH_CAP] = {0}; + char log_path[RUNTIME_TEST_PATH_CAP] = {0}; + int parent_written = + snprintf(parent, sizeof(parent), "%s/cbm-host-early-log-XXXXXX", cbm_tmpdir()); + bool parent_created = snapshot_ok && parent_written > 0 && + parent_written < (int)sizeof(parent) && cbm_mkdtemp(parent) != NULL; + int cache_written = parent_created ? snprintf(cache, sizeof(cache), "%s/cache", parent) : -1; + int log_written = parent_created + ? snprintf(log_path, sizeof(log_path), "%s/logs/cbm-daemon.log", cache) + : -1; + bool environment_ready = cache_written > 0 && cache_written < (int)sizeof(cache) && + log_written > 0 && log_written < (int)sizeof(log_path) && + cbm_mkdir_p(cache, 0700) && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + cbm_daemon_ipc_endpoint_t *endpoint = + environment_ready ? cbm_daemon_ipc_endpoint_new("0123456789abcdef", parent) : NULL; + cbm_version_cohort_manager_t *active_manager = + endpoint ? cbm_version_cohort_manager_new(endpoint) : NULL; + cbm_version_cohort_lease_t *active_lease = NULL; + cbm_version_cohort_daemon_claim_t *active_claim = NULL; + cbm_daemon_conflict_t active_conflict; + cbm_daemon_build_identity_t active_identity = + runtime_test_identity("active-host", runtime_test_self_build()); + active_identity.cache_fingerprint = RUNTIME_BUILD_B; + cbm_version_cohort_status_t active_status = + active_manager + ? cbm_version_cohort_acquire(active_manager, &active_identity, UINT64_MAX, + &active_lease, &active_conflict) + : CBM_VERSION_COHORT_IO; + cbm_version_cohort_status_t claim_status = + active_status == CBM_VERSION_COHORT_OK + ? cbm_version_cohort_daemon_claim_acquire(active_manager, &active_claim) + : CBM_VERSION_COHORT_IO; + atomic_int stop_requested = ATOMIC_VAR_INIT(0); + cbm_daemon_host_config_t config = { + .endpoint = endpoint, + .identity = active_identity, + .executable_path = "/host-early-log-test", + .stop_requested = &stop_requested, + }; + int run_result = claim_status == CBM_VERSION_COHORT_OK ? cbm_daemon_host_run(&config) : 0; + + char log[RUNTIME_TEST_LOG_CAP] = {0}; + bool durable = runtime_test_read_log(log_path, log) && + strstr(log, "daemon.start_failed") != NULL && + strstr(log, "claim") != NULL; + bool endpoint_created = endpoint != NULL; + while (active_claim && + cbm_version_cohort_daemon_claim_release(&active_claim) != CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + while (active_lease && + cbm_version_cohort_lease_release(&active_lease) != CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + while (active_manager && + cbm_version_cohort_manager_free(&active_manager) != CBM_PRIVATE_FILE_LOCK_OK) { + cbm_usleep(1000); + } + cbm_daemon_ipc_endpoint_free(endpoint); + runtime_test_restore_environment("CBM_CACHE_DIR", saved_cache, had_cache); + free(saved_cache); + bool cleaned = !parent_created || th_rmtree(parent) == 0; + + ASSERT_TRUE(snapshot_ok); + ASSERT_TRUE(parent_created); + ASSERT_TRUE(environment_ready); + ASSERT_TRUE(endpoint_created); + ASSERT_EQ(active_status, CBM_VERSION_COHORT_OK); + ASSERT_EQ(claim_status, CBM_VERSION_COHORT_OK); + ASSERT_EQ(run_result, -1); + ASSERT_TRUE(durable); + ASSERT_TRUE(cleaned); + PASS(); +} + /* RED on the former host preparation contract: a failed _config.db open was * silently converted into default settings, so startup continued and could * enable background behavior the user had explicitly disabled. */ @@ -4166,6 +4253,7 @@ TEST(daemon_runtime_process_fingerprint_never_hashes_replacement_path) { #endif SUITE(daemon_runtime) { + RUN_TEST(daemon_host_early_coordination_failure_is_durable); RUN_TEST(daemon_host_refuses_unopenable_runtime_config_database); RUN_TEST(daemon_host_http_reconcile_rate_limits_and_retries_transient_failures); RUN_TEST(daemon_host_http_retry_backoff_is_bounded); diff --git a/tests/test_lock_registry.c b/tests/test_lock_registry.c index c83fc6bf6..c1f823696 100644 --- a/tests/test_lock_registry.c +++ b/tests/test_lock_registry.c @@ -42,13 +42,12 @@ typedef struct { cbm_lock_registry_t *registry; } lock_registry_fixture_t; +/* The stress fixtures that use this helper are POSIX-only. */ +#ifndef _WIN32 static void lock_registry_test_yield(void) { -#ifdef _WIN32 - (void)SwitchToThread(); -#else (void)sched_yield(); -#endif } +#endif #ifndef _WIN32 static cbm_private_lock_directory_t *lock_registry_test_directory_open(const char *root) { diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index 823fedc75..53b524ea7 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -353,16 +353,26 @@ require( all( needle in windows_test_driver for needle in ( - "[Environment+SpecialFolder]::LocalApplicationData", - '$guardRoot = Join-Path $localAppData "Temp"', + "[Environment+SpecialFolder]::UserProfile", + '$guardRoot = Join-Path $userProfile ' + '("cbm-windows-guards-root-" + [guid]::NewGuid().ToString("N"))', '$env:TEMP = $guardRoot', '$env:TMP = $guardRoot', '$env:TMPDIR = $guardRoot', + 'Remove-Item -LiteralPath $guardRoot -Recurse -Force', ) ), "Windows launcher guards must keep staged and Python-created fixtures " "beneath the current account profile", ) +require( + '$guardRoot = $null\ntry {\n $userProfile = ' + '[Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile)' + in windows_test_driver + and 'if ($guardRoot) {\n Remove-Item -LiteralPath $guardRoot -Recurse -Force' + in windows_test_driver, + "Windows launcher guard setup must be covered by profile-fixture cleanup", +) # Launcher supervision has two distinct failure directions: killing the # launcher must kill its payload job, and killing only the launcher's immediate From ad2874bcff605834b9b72092e0f675be53fcb478 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 15:26:38 +0200 Subject: [PATCH 11/23] fix: close remaining daemon coordination races Signed-off-by: Martin Vogel --- scripts/test-windows.ps1 | 21 +++ src/daemon/bootstrap.c | 17 ++- src/daemon/host.c | 27 ++-- src/foundation/subprocess.c | 199 +++++++++++++++++++------- src/foundation/subprocess.h | 23 ++- src/mcp/mcp.c | 4 +- src/pipeline/pass_cross_repo.c | 12 ++ src/pipeline/pipeline_internal.h | 8 ++ tests/test_cross_repo.c | 102 +++---------- tests/test_daemon_bootstrap.c | 37 +++++ tests/test_daemon_frontend.c | 30 +++- tests/test_daemon_ipc.c | 13 +- tests/test_subprocess.c | 178 ++++++++++++++++++++--- tests/test_windows_bundle_contract.sh | 35 +++++ 14 files changed, 513 insertions(+), 193 deletions(-) diff --git a/scripts/test-windows.ps1 b/scripts/test-windows.ps1 index 8fb975f91..ee28f7e95 100644 --- a/scripts/test-windows.ps1 +++ b/scripts/test-windows.ps1 @@ -142,6 +142,27 @@ try { if (-not $userProfile) { throw "could not resolve the current user's profile directory" } $guardRoot = Join-Path $userProfile ("cbm-windows-guards-root-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $guardRoot | Out-Null + + # GitHub-hosted runner profile children can inherit mutation-capable ACEs + # even though the profile ancestry itself passes the launcher's bounded + # trust policy. Replace that inheritance before creating any executable or + # Python temporary descendant. Use SIDs rather than localized account names. + $currentSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + if (-not $currentSid) { throw "could not resolve the current user's SID" } + $guardAcl = [System.Security.AccessControl.DirectorySecurity]::new() + $guardAcl.SetOwner($currentSid) + $guardAcl.SetAccessRuleProtection($true, $false) + $guardRule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $currentSid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor + [System.Security.AccessControl.InheritanceFlags]::ObjectInherit), + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow + ) + $guardAcl.AddAccessRule($guardRule) | Out-Null + Set-Acl -LiteralPath $guardRoot -AclObject $guardAcl + $guardBundle = Join-Path $guardRoot ("cbm-windows-guards-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $guardBundle | Out-Null $guardBin = Join-Path $guardBundle "codebase-memory-mcp.exe" diff --git a/src/daemon/bootstrap.c b/src/daemon/bootstrap.c index f41eba8fb..8115572d2 100644 --- a/src/daemon/bootstrap.c +++ b/src/daemon/bootstrap.c @@ -445,16 +445,27 @@ cbm_daemon_bootstrap_status_t cbm_daemon_bootstrap_execute_with_ops( } result_out->daemon_spawned = true; - /* Keep startup ownership until the child becomes observable. A - * RESERVED response here belongs to the generation we just launched, - * so it remains a wait state rather than a reason to release/spawn. */ + /* Keep startup ownership only until the child becomes observable. + * Windows participant teardown reacquires the startup transition; + * retaining it while probing an observable generation can deadlock the + * bootstrap against a daemon that is trying to cleanly stand down. */ do { bootstrap_pause(deadline); probe = bootstrap_probe(config, ops, &client, &connect_result); + if (probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED || + probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL) { + generation_observed = true; + bootstrap_startup_lock_release_complete(ops, &startup_lock); + lock_acquired = false; + break; + } if (!bootstrap_probe_is_waitable(probe)) { break; } } while (cbm_now_ms() < deadline); + if (!lock_acquired) { + continue; + } break; } diff --git a/src/daemon/host.c b/src/daemon/host.c index e7cde9cb0..c19f5519e 100644 --- a/src/daemon/host.c +++ b/src/daemon/host.c @@ -798,21 +798,22 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { host_log_close(); return -1; } - cbm_daemon_ipc_lifetime_reservation_t *lifetime_reservation = NULL; - if (host_lifetime_reservation_acquire(config->endpoint, &lifetime_reservation) != 1) { - cbm_log_error("daemon.start_failed", "component", "lifetime"); + cbm_version_cohort_daemon_claim_t *daemon_claim = NULL; + if (cbm_version_cohort_daemon_claim_acquire(cohort_manager, &daemon_claim) != + CBM_VERSION_COHORT_OK) { + cbm_log_error("daemon.start_failed", "component", "claim"); host_participant_guard_close(&participant_guard); + host_daemon_claim_close(&daemon_claim); host_cohort_close(&cohort_lease, &cohort_manager); host_log_close(); return -1; } - cbm_version_cohort_daemon_claim_t *daemon_claim = NULL; - if (cbm_version_cohort_daemon_claim_acquire(cohort_manager, &daemon_claim) != - CBM_VERSION_COHORT_OK) { - cbm_log_error("daemon.start_failed", "component", "claim"); + cbm_daemon_ipc_lifetime_reservation_t *lifetime_reservation = NULL; + if (host_lifetime_reservation_acquire(config->endpoint, &lifetime_reservation) != 1) { + cbm_log_error("daemon.start_failed", "component", "lifetime"); cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation); - host_daemon_claim_close(&daemon_claim); host_participant_guard_close(&participant_guard); + host_daemon_claim_close(&daemon_claim); host_cohort_close(&cohort_lease, &cohort_manager); host_log_close(); return -1; @@ -827,8 +828,8 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { host_state_free(&host); host_log_close(); cbm_daemon_ipc_lifetime_reservation_release(lifetime_reservation); - host_daemon_claim_close(&daemon_claim); host_participant_guard_close(&participant_guard); + host_daemon_claim_close(&daemon_claim); host_cohort_close(&cohort_lease, &cohort_manager); return -1; } @@ -853,8 +854,8 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { cbm_log_error("daemon.start_failed", "component", "runtime"); host_state_free(&host); host_log_close(); - host_daemon_claim_close(&daemon_claim); host_participant_guard_close(&participant_guard); + host_daemon_claim_close(&daemon_claim); host_cohort_close(&cohort_lease, &cohort_manager); return -1; } @@ -870,8 +871,8 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { } host_state_free(&host); host_log_close(); - host_daemon_claim_close(&daemon_claim); host_participant_guard_close(&participant_guard); + host_daemon_claim_close(&daemon_claim); host_cohort_close(&cohort_lease, &cohort_manager); return -1; } @@ -914,11 +915,11 @@ int cbm_daemon_host_run(const cbm_daemon_host_config_t *config) { host_log_close(); /* Keep the exact-build lifetime lease through listener/lifetime teardown, * application destruction, diagnostics shutdown, the durable stop record, - * daemon-claim release, and participant release. An activation transaction + * participant release, and daemon-claim release. An activation transaction * may treat exclusive acquisition as proof that coordinated cleanup for * this generation has completed. */ - host_daemon_claim_close(&daemon_claim); host_participant_guard_close(&participant_guard); + host_daemon_claim_close(&daemon_claim); host_cohort_close(&cohort_lease, &cohort_manager); return 0; } diff --git a/src/foundation/subprocess.c b/src/foundation/subprocess.c index 36eb38c9b..6c2c9ad43 100644 --- a/src/foundation/subprocess.c +++ b/src/foundation/subprocess.c @@ -5,7 +5,9 @@ */ #include "subprocess.h" -#include "compat.h" /* cbm_nanosleep */ +#include "compat.h" /* cbm_nanosleep */ +#include "compat_fs.h" +#include "log.h" #include "platform.h" /* cbm_now_ms */ #include @@ -94,16 +96,28 @@ const char *cbm_proc_outcome_str(cbm_proc_outcome_t o) { } } -/* Tail newly-appended complete lines from the child log, starting at *tail_pos. - * A partial (non-newline-terminated) final line is left buffered: *tail_pos is - * not advanced past it, so it is re-read once completed. Returns true if any - * complete line was consumed (i.e. there was progress). */ -static bool cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb cb, void *ud) { - if (!log_file) { - return false; +typedef enum { + CBM_TAIL_MORE = 0, + CBM_TAIL_CAUGHT_UP, + CBM_TAIL_ERROR, +} cbm_tail_status_t; + +typedef struct { + cbm_tail_status_t status; + bool progressed; +} cbm_tail_result_t; + +/* Tail one bounded batch from the child log. While the owned tree can still + * write, a partial final line remains buffered. Once the tree is quiescent, + * final=true delivers that last fragment exactly once. */ +static cbm_tail_result_t cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb cb, + void *ud, bool final) { + cbm_tail_result_t result = {.status = CBM_TAIL_ERROR, .progressed = false}; + if (!log_file || !tail_pos) { + return result; } #ifdef _WIN32 - FILE *lf = fopen(log_file, "r"); + FILE *lf = cbm_fopen(log_file, "r"); #else int open_flags = O_RDONLY | O_NONBLOCK; #ifdef O_CLOEXEC @@ -114,12 +128,12 @@ static bool cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb c #endif int log_fd = open(log_file, open_flags); if (log_fd < 0) { - return false; + return result; } struct stat log_status; if (fstat(log_fd, &log_status) != 0 || !S_ISREG(log_status.st_mode)) { (void)close(log_fd); - return false; + return result; } FILE *lf = fdopen(log_fd, "r"); #endif @@ -127,9 +141,8 @@ static bool cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb c #ifndef _WIN32 (void)close(log_fd); #endif - return false; + return result; } - bool progressed = false; if (fseek(lf, *tail_pos, SEEK_SET) == 0) { char line[1024]; size_t delivered_lines = 0; @@ -142,6 +155,7 @@ static bool cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb c delivered_bytes < CBM_TAIL_MAX_BYTES_PER_POLL) { long before = ftell(lf); if (!fgets(line, sizeof(line), lf)) { + result.status = ferror(lf) ? CBM_TAIL_ERROR : CBM_TAIL_CAUGHT_UP; break; } size_t l = strlen(line); @@ -150,7 +164,7 @@ static bool cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb c if (complete) { line[l - 1] = '\0'; *tail_pos = ftell(lf); - progressed = true; + result.progressed = true; delivered_lines++; if (line[0] && cb) { cb(line, ud); @@ -159,20 +173,37 @@ static bool cbm_tail_log(const char *log_file, long *tail_pos, cbm_proc_log_cb c /* Oversized line filled the buffer without a newline — consume it * anyway (counts as progress) so we never stall on one long line. */ *tail_pos = ftell(lf); - progressed = true; + result.progressed = true; delivered_lines++; if (cb) { cb(line, ud); } - } else { + } else if (!final) { /* Genuine partial final line — keep it buffered for next poll. */ *tail_pos = before; + result.status = CBM_TAIL_MORE; + break; + } else { + /* No writer remains: deliver the final unterminated fragment. */ + *tail_pos = ftell(lf); + result.progressed = true; + if (line[0] && cb) { + cb(line, ud); + } + result.status = CBM_TAIL_CAUGHT_UP; break; } } + if (result.status == CBM_TAIL_ERROR && !ferror(lf)) { + /* Reaching either work cap is conservatively MORE. An exact batch + * boundary needs one empty follow-up poll to prove EOF. */ + result.status = CBM_TAIL_MORE; + } + } + if (fclose(lf) != 0) { + result.status = CBM_TAIL_ERROR; } - fclose(lf); - return progressed; + return result; } /* ── Windows command-line quoting (pure; unit-tested on every platform) ─────── */ @@ -268,6 +299,13 @@ bool cbm_build_win_cmdline(char *buf, size_t cap, const char *const *argv) { enum { CBM_SUBPROCESS_ARGV_LIMIT = 4096 }; +typedef enum { + CBM_SUBPROCESS_ACTIVE = 0, + CBM_SUBPROCESS_CANCEL_REQUESTED, + CBM_SUBPROCESS_DRAINING, + CBM_SUBPROCESS_TERMINAL, +} cbm_subprocess_lifecycle_t; + struct cbm_subprocess { char *bin; char **argv; @@ -282,12 +320,11 @@ struct cbm_subprocess { long tail_pos; uint64_t last_activity_ms; uint64_t termination_started_ms; - atomic_bool cancellation_requested; + atomic_int lifecycle; bool timed_out; bool termination_started; bool force_sent; bool root_reaped; - atomic_bool terminal; uint64_t force_started_ms; bool containment_failed; cbm_proc_result_t result; @@ -379,17 +416,23 @@ static cbm_subprocess_t *cbm_subprocess_copy_opts(const cbm_proc_opts_t *opts) { process->cancel_grace_ms = CBM_SUBPROCESS_MAX_CANCEL_GRACE_MS; } process->delete_log_on_exit = opts->delete_log_on_exit; - atomic_init(&process->cancellation_requested, false); - atomic_init(&process->terminal, false); + atomic_init(&process->lifecycle, CBM_SUBPROCESS_ACTIVE); cbm_subprocess_result_init(&process->result); return process; } -static void cbm_subprocess_poll_log(cbm_subprocess_t *process) { - if (cbm_tail_log(process->log_file, &process->tail_pos, process->on_log_line, - process->log_ud)) { +static cbm_tail_result_t cbm_subprocess_poll_log(cbm_subprocess_t *process, bool final) { + cbm_tail_result_t result = cbm_tail_log(process->log_file, &process->tail_pos, + process->on_log_line, process->log_ud, final); + if (result.progressed) { process->last_activity_ms = cbm_now_ms(); } + return result; +} + +static bool cbm_subprocess_cancellation_requested(const cbm_subprocess_t *process) { + return atomic_load_explicit(&process->lifecycle, memory_order_acquire) == + CBM_SUBPROCESS_CANCEL_REQUESTED; } static void cbm_subprocess_delete_log(cbm_subprocess_t *process) { @@ -407,26 +450,53 @@ static void cbm_subprocess_delete_log(cbm_subprocess_t *process) { #endif } -static cbm_proc_poll_t cbm_subprocess_finish(cbm_subprocess_t *process, cbm_proc_result_t *out) { - /* Capture lines written between the first tail in this poll and process exit. */ - cbm_subprocess_poll_log(process); - process->result.cancellation_requested = - atomic_load_explicit(&process->cancellation_requested, memory_order_acquire); - process->result.tree_quiesced = true; - process->result.supervision_failed = false; - atomic_store_explicit(&process->terminal, true, memory_order_release); - cbm_subprocess_delete_log(process); +static bool cbm_subprocess_begin_terminal_transition(cbm_subprocess_t *process) { + int lifecycle = atomic_load_explicit(&process->lifecycle, memory_order_acquire); + for (;;) { + if (lifecycle == CBM_SUBPROCESS_DRAINING) { + return true; + } + if (lifecycle == CBM_SUBPROCESS_TERMINAL) { + return false; + } + int desired = CBM_SUBPROCESS_DRAINING; + if (atomic_compare_exchange_weak_explicit(&process->lifecycle, &lifecycle, desired, + memory_order_acq_rel, memory_order_acquire)) { + process->result.cancellation_requested = lifecycle == CBM_SUBPROCESS_CANCEL_REQUESTED; + return true; + } + } +} + +static cbm_proc_poll_t cbm_subprocess_publish_terminal(cbm_subprocess_t *process, + cbm_proc_result_t *out, bool delete_log) { + if (delete_log) { + cbm_subprocess_delete_log(process); + } + atomic_store_explicit(&process->lifecycle, CBM_SUBPROCESS_TERMINAL, memory_order_release); if (out) { *out = process->result; } return CBM_PROC_POLL_TERMINAL; } +static cbm_proc_poll_t cbm_subprocess_finish(cbm_subprocess_t *process, cbm_proc_result_t *out) { + if (!cbm_subprocess_begin_terminal_transition(process)) { + return CBM_PROC_POLL_ERROR; + } + process->result.tree_quiesced = true; + process->result.supervision_failed = false; + if (process->log_file && process->on_log_line) { + return CBM_PROC_POLL_RUNNING; + } + return cbm_subprocess_publish_terminal(process, out, true); +} + static cbm_proc_poll_t cbm_subprocess_finish_failed(cbm_subprocess_t *process, cbm_proc_result_t *out) { - cbm_subprocess_poll_log(process); - process->result.cancellation_requested = - atomic_load_explicit(&process->cancellation_requested, memory_order_acquire); + if (!cbm_subprocess_begin_terminal_transition(process)) { + return CBM_PROC_POLL_ERROR; + } process->result.tree_quiesced = false; process->result.supervision_failed = true; process->result.forced = true; @@ -436,11 +506,7 @@ static cbm_proc_poll_t cbm_subprocess_finish_failed(cbm_subprocess_t *process, process->result.outcome = CBM_PROC_KILLED; } process->containment_failed = true; - atomic_store_explicit(&process->terminal, true, memory_order_release); - if (out) { - *out = process->result; - } - return CBM_PROC_POLL_TERMINAL; + return cbm_subprocess_publish_terminal(process, out, false); } #ifdef _WIN32 @@ -634,8 +700,7 @@ static void cbm_win_capture_root(cbm_subprocess_t *process, DWORD code) { if (process->timed_out) { process->result.outcome = CBM_PROC_HANG; } else if (process->root_forced || - (atomic_load_explicit(&process->cancellation_requested, memory_order_acquire) && - code == CBM_WIN_CONTROL_C_EXIT)) { + (cbm_subprocess_cancellation_requested(process) && code == CBM_WIN_CONTROL_C_EXIT)) { process->result.outcome = CBM_PROC_KILLED; } else { process->result.outcome = cbm_proc_classify(true, (int)code, 0, false); @@ -643,7 +708,6 @@ static void cbm_win_capture_root(cbm_subprocess_t *process, DWORD code) { } static cbm_proc_poll_t cbm_subprocess_poll_win(cbm_subprocess_t *process, cbm_proc_result_t *out) { - cbm_subprocess_poll_log(process); uint64_t now = cbm_now_ms(); if (!process->root_reaped) { @@ -652,7 +716,6 @@ static cbm_proc_poll_t cbm_subprocess_poll_win(cbm_subprocess_t *process, cbm_pr DWORD code = 1; (void)GetExitCodeProcess(process->process, &code); cbm_win_capture_root(process, code); - cbm_subprocess_poll_log(process); } else if (waited == WAIT_FAILED) { DWORD code = STILL_ACTIVE; if (GetExitCodeProcess(process->process, &code) && code != STILL_ACTIVE) { @@ -667,7 +730,7 @@ static cbm_proc_poll_t cbm_subprocess_poll_win(cbm_subprocess_t *process, cbm_pr bool job_known = false; bool job_active = cbm_win_job_active(process, &job_known); if (!process->termination_started) { - if (atomic_load_explicit(&process->cancellation_requested, memory_order_acquire)) { + if (cbm_subprocess_cancellation_requested(process)) { cbm_win_begin_termination(process, now); } else if (!process->root_reaped && process->quiet_timeout_ms > 0 && now - process->last_activity_ms >= (uint64_t)process->quiet_timeout_ms) { @@ -888,7 +951,6 @@ static void cbm_posix_capture_root(cbm_subprocess_t *process, int status) { static cbm_proc_poll_t cbm_subprocess_poll_posix(cbm_subprocess_t *process, cbm_proc_result_t *out) { - cbm_subprocess_poll_log(process); uint64_t now = cbm_now_ms(); if (!process->root_reaped) { @@ -896,7 +958,6 @@ static cbm_proc_poll_t cbm_subprocess_poll_posix(cbm_subprocess_t *process, pid_t waited = waitpid(process->pid, &status, WNOHANG); if (waited == process->pid) { cbm_posix_capture_root(process, status); - cbm_subprocess_poll_log(process); } else if (waited < 0 && errno != EINTR) { /* ECHILD means another reaper consumed the status. Other permanent * wait failures are treated the same: retain containment, stop the @@ -911,7 +972,7 @@ static cbm_proc_poll_t cbm_subprocess_poll_posix(cbm_subprocess_t *process, bool group_active = cbm_posix_group_active(process); if (!process->termination_started) { - if (atomic_load_explicit(&process->cancellation_requested, memory_order_acquire)) { + if (cbm_subprocess_cancellation_requested(process)) { cbm_posix_begin_termination(process, now); } else if (!process->root_reaped && process->quiet_timeout_ms > 0 && now - process->last_activity_ms >= (uint64_t)process->quiet_timeout_ms) { @@ -967,12 +1028,27 @@ cbm_proc_poll_t cbm_subprocess_poll(cbm_subprocess_t *process, cbm_proc_result_t if (!process) { return CBM_PROC_POLL_ERROR; } - if (atomic_load_explicit(&process->terminal, memory_order_acquire)) { + int lifecycle = atomic_load_explicit(&process->lifecycle, memory_order_acquire); + if (lifecycle == CBM_SUBPROCESS_TERMINAL) { if (out) { *out = process->result; } return CBM_PROC_POLL_TERMINAL; } + if (lifecycle == CBM_SUBPROCESS_DRAINING) { + cbm_tail_result_t tail = cbm_subprocess_poll_log(process, true); + if (tail.status == CBM_TAIL_MORE) { + return CBM_PROC_POLL_RUNNING; + } + if (tail.status == CBM_TAIL_ERROR) { + cbm_log_error("subprocess.log_drain_failed", "reason", "io_error"); + return cbm_subprocess_publish_terminal(process, out, false); + } + return cbm_subprocess_publish_terminal(process, out, true); + } + /* The one owner-thread tail batch for this public poll. Platform-specific + * reap paths never tail again, preserving the exact per-poll work cap. */ + (void)cbm_subprocess_poll_log(process, false); #ifdef _WIN32 return cbm_subprocess_poll_win(process, out); #else @@ -981,15 +1057,28 @@ cbm_proc_poll_t cbm_subprocess_poll(cbm_subprocess_t *process, cbm_proc_result_t } bool cbm_subprocess_request_cancel(cbm_subprocess_t *process) { - if (!process || atomic_load_explicit(&process->terminal, memory_order_acquire)) { + if (!process) { return false; } - atomic_store_explicit(&process->cancellation_requested, true, memory_order_release); - return true; + int lifecycle = atomic_load_explicit(&process->lifecycle, memory_order_acquire); + for (;;) { + if (lifecycle == CBM_SUBPROCESS_CANCEL_REQUESTED) { + return true; + } + if (lifecycle != CBM_SUBPROCESS_ACTIVE) { + return false; + } + int desired = CBM_SUBPROCESS_CANCEL_REQUESTED; + if (atomic_compare_exchange_weak_explicit(&process->lifecycle, &lifecycle, desired, + memory_order_acq_rel, memory_order_acquire)) { + return true; + } + } } void cbm_subprocess_destroy(cbm_subprocess_t *process) { - if (!process || !atomic_load_explicit(&process->terminal, memory_order_acquire)) { + if (!process || atomic_load_explicit(&process->lifecycle, memory_order_acquire) != + CBM_SUBPROCESS_TERMINAL) { return; } #ifdef _WIN32 diff --git a/src/foundation/subprocess.h b/src/foundation/subprocess.h index b3900b660..df02f1e86 100644 --- a/src/foundation/subprocess.h +++ b/src/foundation/subprocess.h @@ -14,8 +14,8 @@ * tree-sitter scanners that infinite-loop (a hang, not a crash). * * The reap loop is EINTR-safe. Line tailing keeps a partial final line buffered - * (an incomplete, un-newline-terminated line is not yet "progress" and is not - * mis-read as a completed marker). + * while the child tree can still write, then delivers that final fragment once + * the tree is quiescent. */ #ifndef CBM_SUBPROCESS_H #define CBM_SUBPROCESS_H @@ -44,8 +44,12 @@ typedef struct { bool supervision_failed; /* the bounded containment deadline expired; tree_quiesced is false */ } cbm_proc_result_t; -/* Called for each newly-completed (newline-terminated) log line while the child - * runs. A completed line also resets the quiet-timeout (it is progress). */ +/* Called synchronously for each newly-completed log chunk while the child runs. + * Newline-terminated lines are delivered without their newline; oversized lines + * may be split into 1023-byte chunks, and the final unterminated remainder is + * delivered after the tree is quiescent. Each delivered chunk resets the quiet + * timeout. Poll bounds callback work by chunk/byte count, not elapsed time, so + * callbacks must return promptly. */ typedef void (*cbm_proc_log_cb)(const char *line, void *ud); typedef struct { @@ -98,6 +102,12 @@ int cbm_subprocess_spawn(const cbm_proc_opts_t *opts, cbm_subprocess_t **out); * true and tree_quiesced is false; callers must log this as a critical teardown * failure. *out is optional and is not modified for RUNNING or ERROR. * + * Each poll delivers at most 64 log chunks / 64 KiB. On the normal callback + * delivery path, RUNNING may continue after the process tree is quiescent while + * remaining log batches drain, and TERMINAL follows successful catch-up. A + * final-drain I/O error instead terminates without changing process-tree + * classification and does not attempt to delete the log. + * * Poll performs the graceful->force state transition: after explicit cancel (or * quiet-timeout), it requests graceful termination once, then force-terminates the * tree when cancel_grace_ms elapses. Callers must keep polling to make progress. */ @@ -106,8 +116,9 @@ cbm_proc_poll_t cbm_subprocess_poll(cbm_subprocess_t *process, cbm_proc_result_t /* Record an explicit cancellation request without waiting. Safe to repeat and * safe to call from a cancellation thread while one owner thread polls. The * owner must stop cancellation producers before destroying the handle. true - * means the process is live and cancellation is now/already pending; false means - * process is NULL or already terminal. Poll performs signal delivery/escalation. */ + * means the process tree is live and cancellation is now/already pending; false + * means process is NULL, draining terminal logs, or already terminal. Poll + * performs signal delivery/escalation. */ bool cbm_subprocess_request_cancel(cbm_subprocess_t *process); /* Release a terminal handle. This never waits or implicitly cancels; passing a diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 8709bb74a..39a8b3708 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -6306,10 +6306,10 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { * attached so data_flow args resolve for boundary nodes whose incoming * edge originated on an earlier page. */ cbm_traverse_result_t view_out = tr_out; - view_out.visited += out_start; + view_out.visited = tr_out.visited ? tr_out.visited + out_start : NULL; view_out.visited_count = out_len; cbm_traverse_result_t view_in = tr_in; - view_in.visited += in_start; + view_in.visited = tr_in.visited ? tr_in.visited + in_start : NULL; view_in.visited_count = in_len; /* Totals must count what the caller can actually enumerate: when diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 285be212d..c606719d3 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -58,6 +58,15 @@ typedef struct { bool mutated; } cr_run_context_t; +static CBM_TLS cbm_cross_repo_after_insert_test_hook_t cr_after_insert_test_hook = NULL; +static CBM_TLS void *cr_after_insert_test_context = NULL; + +void cbm_cross_repo_set_after_insert_hook_for_tests(cbm_cross_repo_after_insert_test_hook_t hook, + void *context) { + cr_after_insert_test_hook = hook; + cr_after_insert_test_context = context; +} + typedef struct { int count; cr_run_status_t status; @@ -240,6 +249,9 @@ static bool insert_cross_edge(cbm_store_t *store, const char *project, int64_t f bool inserted = cbm_store_insert_edge(store, &edge) > 0; if (inserted) { ctx->mutated = true; + if (cr_after_insert_test_hook) { + cr_after_insert_test_hook(project, edge_type, cr_after_insert_test_context); + } } return inserted; } diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index b6ac6575b..e6c211f44 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -639,6 +639,14 @@ void cbm_pipeline_set_rename_hook_for_tests(cbm_pipeline_t *p, int (*hook)(const char *, const char *, void *), void *ctx); +/* Synchronous thread-local seam for deterministic cross-repo cancellation + * tests. The callback runs immediately after a CROSS_* edge is committed and + * is never retained; it must not re-enter cross-repo matching. */ +typedef void (*cbm_cross_repo_after_insert_test_hook_t)(const char *project, const char *edge_type, + void *context); +void cbm_cross_repo_set_after_insert_hook_for_tests(cbm_cross_repo_after_insert_test_hook_t hook, + void *context); + /* Parse a gRPC stub call "." into the canonical proto * service name + method. Returns true ONLY when a recognized gRPC stub/client * suffix is present (the stub-type signal that gates Route emission, #294). diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index 1150c2a73..084dae9ff 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -6,8 +6,8 @@ #include "test_helpers.h" #include "foundation/compat.h" -#include "foundation/compat_thread.h" #include "pipeline/pass_cross_repo.h" +#include "pipeline/pipeline_internal.h" #include #include @@ -382,77 +382,18 @@ TEST(cross_repo_failed_bidirectional_insert_is_not_counted) { PASS(); } -/* Keep the source scan active after the target-b match is published. This - * gives the watcher a deterministic observable hand-off point without adding - * a test hook to the production pass. */ -static bool cross_repo_seed_cancel_scan_tail(const cross_repo_fixture_t *fixture, - const char *source_project) { - enum { CANCEL_SCAN_TOTAL_ROWS = 4096, SEEDED_MATCH_ROWS = 3 }; - char source_path[512]; - if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path))) { - return false; - } - cbm_store_t *source = cbm_store_open_path_existing(source_path); - if (!source) { - return false; - } - bool ok = - sqlite3_exec(cbm_store_get_db(source), "BEGIN IMMEDIATE", NULL, NULL, NULL) == SQLITE_OK; - cbm_node_t caller = {.project = source_project, - .label = "Function", - .name = "cancel_tail_caller", - .qualified_name = "cancel.source.tail-caller", - .file_path = "cancel-tail.c"}; - int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0; - ok = ok && caller_id > 0; - for (int i = SEEDED_MATCH_ROWS; ok && i < CANCEL_SCAN_TOTAL_ROWS; i++) { - char name[64]; - char qn[96]; - snprintf(name, sizeof(name), "cancel_tail_route_%d", i); - snprintf(qn, sizeof(qn), "%s.cancel-tail.%d", source_project, i); - cbm_node_t route = {.project = source_project, - .label = "Route", - .name = name, - .qualified_name = qn, - .file_path = "cancel-tail.c"}; - int64_t route_id = cbm_store_upsert_node(source, &route); - cbm_edge_t edge = {.project = source_project, - .source_id = caller_id, - .target_id = route_id, - .type = "HTTP_CALLS", - .properties_json = "{}"}; - ok = route_id > 0 && caller_id > 0 && cbm_store_insert_edge(source, &edge) > 0; - } - if (ok) { - ok = sqlite3_exec(cbm_store_get_db(source), "COMMIT", NULL, NULL, NULL) == SQLITE_OK; - } else { - (void)sqlite3_exec(cbm_store_get_db(source), "ROLLBACK", NULL, NULL, NULL); - } - cbm_store_close(source); - return ok; -} - typedef struct { - const cross_repo_fixture_t *fixture; atomic_int *cancelled; - atomic_bool observed_target_write; -} cross_repo_cancel_watcher_t; - -static void *cross_repo_cancel_after_target_write(void *arg) { - enum { CANCEL_POLL_LIMIT = 10000 }; - cross_repo_cancel_watcher_t *watcher = arg; - for (int i = 0; i < CANCEL_POLL_LIMIT; i++) { - int count = cross_repo_count_edges(watcher->fixture, "cancel-target-b", "CROSS_HTTP_CALLS"); - if (count > 0) { - atomic_store_explicit(&watcher->observed_target_write, true, memory_order_release); - atomic_store_explicit(watcher->cancelled, 1, memory_order_release); - return NULL; - } - cbm_usleep(1000); + int fired; +} cross_repo_cancel_hook_t; + +static void cross_repo_cancel_after_target_write(const char *project, const char *edge_type, + void *opaque) { + cross_repo_cancel_hook_t *hook = opaque; + if (strcmp(project, "cancel-target-b") == 0 && strcmp(edge_type, "CROSS_HTTP_CALLS") == 0) { + hook->fired++; + atomic_store_explicit(hook->cancelled, 1, memory_order_release); } - /* Bound a broken implementation too: the test should fail, not hang. */ - atomic_store_explicit(watcher->cancelled, 1, memory_order_release); - return NULL; } TEST(cross_repo_cancel_mid_run_keeps_completed_target_and_stops_before_later_target) { @@ -461,8 +402,7 @@ TEST(cross_repo_cancel_mid_run_keeps_completed_target_and_stops_before_later_tar cross_repo_fixture_begin(&fixture) && cross_repo_seed_http_pair(&fixture, "cancel-source", "cancel-target-a", "/cancel-a", "a") && cross_repo_seed_http_pair(&fixture, "cancel-source", "cancel-target-b", "/cancel-b", "b") && - cross_repo_seed_http_pair(&fixture, "cancel-source", "cancel-target-c", "/cancel-c", "c") && - cross_repo_seed_cancel_scan_tail(&fixture, "cancel-source"); + cross_repo_seed_http_pair(&fixture, "cancel-source", "cancel-target-c", "/cancel-c", "c"); if (!setup) { cross_repo_fixture_end(&fixture); FAIL("failed to seed cancellation fixture"); @@ -470,23 +410,16 @@ TEST(cross_repo_cancel_mid_run_keeps_completed_target_and_stops_before_later_tar atomic_int cancelled; atomic_init(&cancelled, 0); - cross_repo_cancel_watcher_t watcher = { - .fixture = &fixture, + cross_repo_cancel_hook_t hook = { .cancelled = &cancelled, }; - atomic_init(&watcher.observed_target_write, false); - cbm_thread_t watcher_thread; - bool watcher_started = - cbm_thread_create(&watcher_thread, 0, cross_repo_cancel_after_target_write, &watcher) == 0; const char *targets[] = {"cancel-target-c", "cancel-target-a", "cancel-target-b"}; - cbm_cross_repo_result_t result = {0}; - if (watcher_started) { - result = cbm_cross_repo_match_cancellable("cancel-source", targets, 3, &cancelled); - (void)cbm_thread_join(&watcher_thread); - } + cbm_cross_repo_set_after_insert_hook_for_tests(cross_repo_cancel_after_target_write, &hook); + cbm_cross_repo_result_t result = + cbm_cross_repo_match_cancellable("cancel-source", targets, 3, &cancelled); + cbm_cross_repo_set_after_insert_hook_for_tests(NULL, NULL); - bool observed = atomic_load_explicit(&watcher.observed_target_write, memory_order_acquire); int completed_target_edges = cross_repo_count_edges(&fixture, "cancel-target-a", "CROSS_HTTP_CALLS"); int interrupted_target_edges = @@ -495,8 +428,7 @@ TEST(cross_repo_cancel_mid_run_keeps_completed_target_and_stops_before_later_tar cross_repo_count_edges(&fixture, "cancel-target-c", "CROSS_HTTP_CALLS"); cross_repo_fixture_end(&fixture); - ASSERT_TRUE(watcher_started); - ASSERT_TRUE(observed); + ASSERT_EQ(hook.fired, 1); ASSERT_TRUE(result.cancelled); ASSERT_TRUE(result.partial_results); ASSERT_FALSE(result.failed); diff --git a/tests/test_daemon_bootstrap.c b/tests/test_daemon_bootstrap.c index dbd90ce4f..f95b1900b 100644 --- a/tests/test_daemon_bootstrap.c +++ b/tests/test_daemon_bootstrap.c @@ -44,6 +44,7 @@ typedef struct { atomic_int terminal_probes_remaining; atomic_bool available; atomic_bool connect_after_reserved; + atomic_bool connect_requires_unlocked; cbm_daemon_bootstrap_probe_status_t forced_probe; cbm_version_cohort_status_t forced_cohort; char diagnostic[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; @@ -140,6 +141,10 @@ static cbm_daemon_bootstrap_probe_status_t bootstrap_fake_probe( if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL) { return fake->forced_probe; } + if (atomic_load(&fake->available) && atomic_load(&fake->connect_requires_unlocked) && + atomic_load(&fake->lock_held) != 0) { + return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED; + } if (atomic_load(&fake->available)) { result_out->status = CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED; result_out->hello_status = CBM_DAEMON_HELLO_COMPATIBLE; @@ -584,6 +589,37 @@ TEST(daemon_bootstrap_reserved_then_absent_spawns_replacement) { PASS(); } +/* RED for the native Windows lock order: after spawn, the generation claim or + * lifetime reservation becomes visible before the client can connect. Daemon + * participant teardown needs startup ownership, so bootstrap must release its + * handoff once that generation is observable. */ +TEST(daemon_bootstrap_releases_handoff_when_spawned_generation_is_reserved) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "spawn-admission")); + bootstrap_fake_ops_t fake = {0}; + atomic_store(&fake.connect_requires_unlocked, true); + cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = "/tmp/cbm", + .connect_timeout_ms = 1, + .startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS, + }; + cbm_daemon_bootstrap_result_t result; + ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result), + CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_TRUE(result.daemon_spawned); + ASSERT_NOT_NULL(result.client); + ASSERT_EQ(atomic_load(&fake.spawn_count), 1); + ASSERT_EQ(atomic_load(&fake.lock_held), 0); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} + TEST(daemon_bootstrap_rejected_connect_is_reserved_and_never_unavailable) { cbm_daemon_runtime_connect_result_t capacity = {0}; capacity.status = CBM_DAEMON_RUNTIME_CONNECT_REJECTED; @@ -697,6 +733,7 @@ SUITE(daemon_bootstrap) { RUN_TEST(daemon_bootstrap_terminal_then_absent_spawns_replacement); RUN_TEST(daemon_bootstrap_reserved_generation_becomes_connectable_without_spawn); RUN_TEST(daemon_bootstrap_reserved_then_absent_spawns_replacement); + RUN_TEST(daemon_bootstrap_releases_handoff_when_spawned_generation_is_reserved); RUN_TEST(daemon_bootstrap_rejected_connect_is_reserved_and_never_unavailable); RUN_TEST(daemon_bootstrap_concurrent_first_clients_spawn_one_daemon); #ifdef __APPLE__ diff --git a/tests/test_daemon_frontend.c b/tests/test_daemon_frontend.c index c24639be2..2e3f35fac 100644 --- a/tests/test_daemon_frontend.c +++ b/tests/test_daemon_frontend.c @@ -66,8 +66,7 @@ static const char FRONTEND_TEST_CACHE[] = enum { FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS = 2000, - FRONTEND_EOF_TEST_OVERFLOW_TIMEOUT_S = 4, - FRONTEND_EOF_TEST_DRAIN_TIMEOUT_S = 10, + FRONTEND_EOF_TEST_CATASTROPHIC_TIMEOUT_S = 30, FRONTEND_BACKPRESSURE_MESSAGE_BYTES = 2 * 1024 * 1024, FRONTEND_BACKPRESSURE_FRONTEND_TIMEOUT_S = 12, FRONTEND_BACKPRESSURE_DAEMON_TIMEOUT_S = 20, @@ -427,8 +426,10 @@ static bool frontend_eof_run_isolated(const char *tag, bool overflow) { pid_t child = fork(); if (child == 0) { (void)signal(SIGALRM, SIG_DFL); - (void)alarm(overflow ? FRONTEND_EOF_TEST_OVERFLOW_TIMEOUT_S - : FRONTEND_EOF_TEST_DRAIN_TIMEOUT_S); + /* This alarm bounds a broken isolated harness, not product behavior. + * Valid startup, drain/watchdog, close, and fixture teardown can exceed + * ten seconds under sanitizers while remaining within their own bounds. */ + (void)alarm(FRONTEND_EOF_TEST_CATASTROPHIC_TIMEOUT_S); _exit(frontend_eof_child_run(parent, overflow)); } int status = 0; @@ -437,7 +438,26 @@ static bool frontend_eof_run_isolated(const char *tag, bool overflow) { waited = child > 0 ? waitpid(child, &status, 0) : -1; } while (waited < 0 && errno == EINTR); bool cleaned = th_rmtree(parent) == 0; - return child > 0 && waited == child && WIFEXITED(status) && WEXITSTATUS(status) == 0 && cleaned; + bool child_ok = child > 0 && waited == child && WIFEXITED(status) && WEXITSTATUS(status) == 0; + if (!child_ok) { + if (child <= 0) { + (void)fprintf(stderr, "frontend EOF fixture %s: fork failed\n", tag); + } else if (waited != child) { + (void)fprintf(stderr, "frontend EOF fixture %s: waitpid failed errno=%d\n", tag, errno); + } else if (WIFSIGNALED(status)) { + (void)fprintf(stderr, "frontend EOF fixture %s: child signal=%d\n", tag, + WTERMSIG(status)); + } else if (WIFEXITED(status)) { + (void)fprintf(stderr, "frontend EOF fixture %s: child exit=%d\n", tag, + WEXITSTATUS(status)); + } else { + (void)fprintf(stderr, "frontend EOF fixture %s: unexpected child status\n", tag); + } + } + if (!cleaned) { + (void)fprintf(stderr, "frontend EOF fixture %s: cleanup failed\n", tag); + } + return child_ok && cleaned; } static void frontend_test_release_lease(cbm_version_cohort_lease_t **lease) { diff --git a/tests/test_daemon_ipc.c b/tests/test_daemon_ipc.c index d1a0edadc..045d7202b 100644 --- a/tests/test_daemon_ipc.c +++ b/tests/test_daemon_ipc.c @@ -821,10 +821,15 @@ TEST(daemon_ipc_windows_legacy_bridge_covers_handoff_and_lifetime) { ? cbm_daemon_ipc_lifetime_reservation_try_acquire(endpoint, &lifetime) : -1; int visible_during_lifetime = lifetime ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; - cbm_daemon_ipc_startup_lock_release(&startup); - startup = NULL; cbm_daemon_ipc_lifetime_reservation_release(lifetime); lifetime = NULL; + /* Native Windows teardown is deliberately ordered through startup-v2. + * A participant must remain retryable while a bootstrap handoff owner + * retains that transition; releasing the bootstrap then unblocks it. */ + bool participant_blocked_by_startup = + participant && !cbm_daemon_ipc_participant_guard_release(&participant); + bool participant_retained = participant != NULL; + bool startup_released = cbm_daemon_ipc_startup_lock_release(&startup); bool participant_released = cbm_daemon_ipc_participant_guard_release(&participant); int absent_after_lifetime = endpoint ? cbm_daemon_ipc_legacy_generation_probe(endpoint) : -1; @@ -894,6 +899,10 @@ TEST(daemon_ipc_windows_legacy_bridge_covers_handoff_and_lifetime) { ASSERT_EQ(participant_status, 1); ASSERT_EQ(lifetime_status, 1); ASSERT_EQ(visible_during_lifetime, 1); + ASSERT_TRUE(participant_blocked_by_startup); + ASSERT_TRUE(participant_retained); + ASSERT_TRUE(startup_released); + ASSERT_NULL(startup); ASSERT_EQ(absent_after_lifetime, 0); ASSERT_TRUE(participant_released); ASSERT_NULL(participant); diff --git a/tests/test_subprocess.c b/tests/test_subprocess.c index 4cecbb335..c8cf009cc 100644 --- a/tests/test_subprocess.c +++ b/tests/test_subprocess.c @@ -307,12 +307,42 @@ static int spawn_ignoring_tree(const char *pid_path, int quiet_timeout_ms, int c return cbm_subprocess_spawn(&opts, out); } -static void slow_log_callback(const char *line, void *opaque) { - (void)line; - int *count = opaque; - (*count)++; - const struct timespec delay = {0, 2000000L}; /* 2 ms per delivered line */ - (void)cbm_nanosleep(&delay, NULL); +typedef struct { + cbm_subprocess_t *process; + int count; + bool ordered; + bool late_cancel_attempted; + bool late_cancel_accepted; +} subprocess_log_capture_t; + +static void ordered_log_callback(const char *line, void *opaque) { + subprocess_log_capture_t *capture = opaque; + int index = -1; + char trailing = '\0'; + bool parsed = sscanf(line, "line-%d%c", &index, &trailing) == 1; + capture->ordered = capture->ordered && parsed && index == capture->count; + capture->count++; + if (index == 399) { + capture->late_cancel_attempted = true; + capture->late_cancel_accepted = cbm_subprocess_request_cancel(capture->process); + } +} + +static bool wait_for_log_marker(const char *path, const char *marker, uint64_t deadline_ms) { + char contents[8192]; + do { + FILE *file = fopen(path, "rb"); + if (file) { + size_t used = fread(contents, 1, sizeof(contents) - 1, file); + contents[used] = '\0'; + (void)fclose(file); + if (strstr(contents, marker)) { + return true; + } + } + subprocess_test_pause(); + } while (cbm_now_ms() < deadline_ms); + return false; } #endif /* !_WIN32 */ @@ -521,7 +551,7 @@ TEST(subprocess_cancel_grace_is_hard_capped) { #endif } -TEST(subprocess_poll_has_a_strict_log_delivery_budget) { +TEST(subprocess_poll_log_delivery_is_bounded_and_terminal_is_lossless) { #ifdef _WIN32 SKIP_PLATFORM("POSIX shell log budget probe; native Windows coverage pending"); #else @@ -530,38 +560,141 @@ TEST(subprocess_poll_has_a_strict_log_delivery_budget) { ASSERT_TRUE(log_fd >= 0); (void)close(log_fd); - const char *script = "i=0; while [ $i -lt 400 ]; do echo line-$i; i=$((i+1)); done; sleep 4"; + const char *script = + "i=0; while [ $i -lt 399 ]; do echo line-$i; i=$((i+1)); done; printf line-399"; const char *argv[] = {"/bin/sh", "-c", script, NULL}; - int delivered = 0; + subprocess_log_capture_t capture = {.ordered = true}; cbm_proc_opts_t opts = {0}; opts.bin = "/bin/sh"; opts.argv = argv; opts.log_file = log_path; - opts.on_log_line = slow_log_callback; - opts.log_ud = &delivered; + opts.on_log_line = ordered_log_callback; + opts.log_ud = &capture; opts.cancel_grace_ms = 100; + opts.delete_log_on_exit = true; cbm_subprocess_t *process = NULL; ASSERT_EQ(cbm_subprocess_spawn(&opts, &process), 0); ASSERT_NOT_NULL(process); + capture.process = process; - const struct timespec fill_delay = {0, 150000000L}; - (void)cbm_nanosleep(&fill_delay, NULL); + bool backlog_ready = wait_for_log_marker(log_path, "line-399", cbm_now_ms() + 2000U); cbm_proc_result_t result = {0}; - uint64_t before = cbm_now_ms(); - cbm_proc_poll_t first = cbm_subprocess_poll(process, &result); - uint64_t elapsed = cbm_now_ms() - before; - bool cancel_accepted = cbm_subprocess_request_cancel(process); - bool terminal = poll_until_terminal(process, 3000, &result); + bool terminal = false; + int max_poll_delivery = 0; + uint64_t deadline = cbm_now_ms() + 5000U; + while (backlog_ready && cbm_now_ms() < deadline) { + int before = capture.count; + cbm_proc_poll_t state = cbm_subprocess_poll(process, &result); + int delivered = capture.count - before; + if (delivered > max_poll_delivery) { + max_poll_delivery = delivered; + } + if (state == CBM_PROC_POLL_TERMINAL) { + terminal = true; + break; + } + if (state == CBM_PROC_POLL_ERROR) { + break; + } + subprocess_test_pause(); + } + bool log_deleted = access(log_path, F_OK) != 0 && errno == ENOENT; + if (terminal) { + cbm_subprocess_destroy(process); + } else { + (void)cbm_subprocess_request_cancel(process); + cbm_proc_result_t cleanup_result; + if (poll_until_terminal(process, 1000, &cleanup_result)) { + cbm_subprocess_destroy(process); + } + } + (void)unlink(log_path); + + ASSERT_TRUE(backlog_ready); + ASSERT_TRUE(terminal); + ASSERT_EQ(max_poll_delivery, 64); + ASSERT_EQ(capture.count, 400); + ASSERT_TRUE(capture.ordered); + ASSERT_TRUE(capture.late_cancel_attempted); + ASSERT_FALSE(capture.late_cancel_accepted); + ASSERT_FALSE(result.cancellation_requested); + ASSERT_TRUE(log_deleted); + PASS(); +#endif +} + +TEST(subprocess_final_log_drain_error_is_terminal_and_preserves_classification) { +#ifdef _WIN32 + SKIP_PLATFORM("POSIX log-rename final-drain probe; UTF-8 Windows open uses cbm_fopen"); +#else + char log_path[] = "/tmp/cbm-subprocess-log-drain-XXXXXX"; + int log_fd = cbm_mkstemp(log_path); + ASSERT_TRUE(log_fd >= 0); + (void)close(log_fd); + char saved_path[sizeof(log_path) + 16]; + int saved_written = snprintf(saved_path, sizeof(saved_path), "%s.saved", log_path); + ASSERT_TRUE(saved_written > 0 && (size_t)saved_written < sizeof(saved_path)); + + const char *script = "i=0; while [ $i -lt 130 ]; do echo line-$i; i=$((i+1)); done"; + const char *argv[] = {"/bin/sh", "-c", script, NULL}; + subprocess_log_capture_t capture = {.ordered = true}; + cbm_proc_opts_t opts = {0}; + opts.bin = "/bin/sh"; + opts.argv = argv; + opts.log_file = log_path; + opts.on_log_line = ordered_log_callback; + opts.log_ud = &capture; + opts.cancel_grace_ms = 100; + opts.delete_log_on_exit = true; + cbm_subprocess_t *process = NULL; + ASSERT_EQ(cbm_subprocess_spawn(&opts, &process), 0); + ASSERT_NOT_NULL(process); + capture.process = process; + + bool backlog_ready = wait_for_log_marker(log_path, "line-129", cbm_now_ms() + 2000U); + cbm_proc_result_t result = {0}; + cbm_proc_poll_t first = + backlog_ready ? cbm_subprocess_poll(process, &result) : CBM_PROC_POLL_ERROR; + int first_delivery = capture.count; + bool moved = first == CBM_PROC_POLL_RUNNING && rename(log_path, saved_path) == 0; + bool terminal = moved && poll_until_terminal(process, 2000, &result); + int callbacks_at_terminal = capture.count; + cbm_proc_result_t cached = {0}; + cbm_proc_poll_t cached_state = + terminal ? cbm_subprocess_poll(process, &cached) : CBM_PROC_POLL_ERROR; + bool callbacks_stable = capture.count == callbacks_at_terminal; + bool saved_preserved = access(saved_path, F_OK) == 0; + bool original_absent = access(log_path, F_OK) != 0 && errno == ENOENT; if (terminal) { cbm_subprocess_destroy(process); + } else { + (void)cbm_subprocess_request_cancel(process); + cbm_proc_result_t cleanup_result; + if (poll_until_terminal(process, 1000, &cleanup_result)) { + cbm_subprocess_destroy(process); + } } (void)unlink(log_path); + (void)unlink(saved_path); + ASSERT_TRUE(backlog_ready); ASSERT_EQ(first, CBM_PROC_POLL_RUNNING); - ASSERT_LT(elapsed, 500); - ASSERT_LT(delivered, 400); - ASSERT_TRUE(cancel_accepted); + ASSERT_EQ(first_delivery, 64); + ASSERT_TRUE(moved); ASSERT_TRUE(terminal); + ASSERT_TRUE(capture.ordered); + ASSERT_EQ(callbacks_at_terminal, 64); + ASSERT_EQ(cached_state, CBM_PROC_POLL_TERMINAL); + ASSERT_TRUE(callbacks_stable); + ASSERT_EQ(result.outcome, CBM_PROC_CLEAN); + ASSERT_EQ(result.exit_code, 0); + ASSERT_TRUE(result.tree_quiesced); + ASSERT_FALSE(result.supervision_failed); + ASSERT_EQ(cached.outcome, result.outcome); + ASSERT_EQ(cached.exit_code, result.exit_code); + ASSERT_EQ(cached.tree_quiesced, result.tree_quiesced); + ASSERT_TRUE(saved_preserved); + ASSERT_TRUE(original_absent); PASS(); #endif } @@ -823,7 +956,8 @@ SUITE(subprocess) { RUN_TEST(subprocess_cancel_is_idempotent_and_kills_ignoring_tree); RUN_TEST(subprocess_quiet_timeout_kills_ignoring_tree); RUN_TEST(subprocess_cancel_grace_is_hard_capped); - RUN_TEST(subprocess_poll_has_a_strict_log_delivery_budget); + RUN_TEST(subprocess_poll_log_delivery_is_bounded_and_terminal_is_lossless); + RUN_TEST(subprocess_final_log_drain_error_is_terminal_and_preserves_classification); RUN_TEST(subprocess_posix_child_closes_unrelated_descriptors); RUN_TEST(subprocess_root_exit_drains_surviving_descendant); RUN_TEST(win_cmdline_index_worker_json); diff --git a/tests/test_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh index 53b524ea7..32dfa46cb 100644 --- a/tests/test_windows_bundle_contract.sh +++ b/tests/test_windows_bundle_contract.sh @@ -374,6 +374,41 @@ require( "Windows launcher guard setup must be covered by profile-fixture cleanup", ) +# The hosted runner profile can itself be trusted while newly-created children +# still inherit mutation-capable principals. Require the guard root to replace +# that inherited DACL with a protected, current-account-owned ACL before any +# launcher bundle or Python temporary descendants are created below it. +guard_root_creation = ( + 'New-Item -ItemType Directory -Path $guardRoot | Out-Null' +) +guard_bundle_creation = '$guardBundle = Join-Path $guardRoot ' +acl_start = windows_test_driver.find(guard_root_creation) +acl_end = windows_test_driver.find(guard_bundle_creation, acl_start + 1) +guard_acl_setup = ( + windows_test_driver[acl_start:acl_end] + if acl_start >= 0 and acl_end > acl_start + else "" +) +require( + all( + needle in guard_acl_setup + for needle in ( + '[System.Security.Principal.WindowsIdentity]::GetCurrent().User', + '[System.Security.AccessControl.DirectorySecurity]::new()', + '$guardAcl.SetOwner($currentSid)', + '$guardAcl.SetAccessRuleProtection($true, $false)', + '[System.Security.AccessControl.FileSystemRights]::FullControl', + '[System.Security.AccessControl.InheritanceFlags]::ContainerInherit', + '[System.Security.AccessControl.InheritanceFlags]::ObjectInherit', + '[System.Security.AccessControl.PropagationFlags]::None', + '[System.Security.AccessControl.AccessControlType]::Allow', + 'Set-Acl -LiteralPath $guardRoot -AclObject $guardAcl', + ) + ), + "Windows launcher guards must protect the guard-root DACL and grant only " + "the current account inheritable full control before creating descendants", +) + # Launcher supervision has two distinct failure directions: killing the # launcher must kill its payload job, and killing only the launcher's immediate # parent must terminate the launcher plus every descendant. Require the native From 7696b1486a10f19e231f5b8285abe6eea71b9a3c Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 15:41:57 +0200 Subject: [PATCH 12/23] fix: resolve remaining platform CI regressions Signed-off-by: Martin Vogel --- scripts/vendored-checksums.txt | 2 +- tests/test_cli.c | 15 ++++++++++++--- vendored/sqlite3/sqlite3.c | 2 +- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/scripts/vendored-checksums.txt b/scripts/vendored-checksums.txt index df2ad490b..c72c13f75 100644 --- a/scripts/vendored-checksums.txt +++ b/scripts/vendored-checksums.txt @@ -40,7 +40,7 @@ a96a1acc75fd6595811b28420cf0495b87fac98c4dbc73aa0d742b6824f607c8 vendored/mimal 49be69a4b674e09690a63870830f3ffb05698ed4210949ef5add11957a08325b vendored/mimalloc/src/threadlocal.c dab3009c0d76b0c5e05ad8abc7c9f8f6effca547fea3c0394be96418a85c1081 vendored/nomic/code_tokens.h 494d329d06e33904e6264b1f9d1cc82de2c6bb212f8d78ba281b7e1eb1179b61 vendored/nomic/code_vectors.h -9512509b1bccb7461f79bea8aad6280ae4699e925fa4804381b71f59e7efb0c5 vendored/sqlite3/sqlite3.c +190d82e7899c6cf74260f122c4a737f77c68e98e8d516750df8d56e7fc00ad57 vendored/sqlite3/sqlite3.c 19585c8b5230e9d4f223bf31b709ece7b6a0bb3faf00d8310625d8e58cda1b1d vendored/sqlite3/sqlite3.h ea81fb7bd05882e0e0b92c4d60f677b205f7f1fbf085f218b12f0b5b3f0b9e48 vendored/sqlite3/sqlite3ext.h 7efd127c0fc4fe26a07684345cce9287762346abeab665e2fe72711c6fc118bd vendored/tre/regcomp.c diff --git a/tests/test_cli.c b/tests/test_cli.c index ec32c189c..f814ff944 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +34,9 @@ #ifndef _WIN32 #include #endif +#ifdef __APPLE__ +#include +#endif #include #include #include @@ -1425,11 +1429,16 @@ TEST(cli_update_agent_configs_finish_before_guard_release) { const char *asset_name = "codebase-memory-mcp-linux-amd64-portable.tar.gz"; #endif #endif - const char *native_fixture = #ifdef __APPLE__ - "/usr/bin/true"; + /* Use the structurally real test runner rather than re-signing an Apple + * platform binary. Turning /usr/bin/true's arm64e/platform signature into + * an ad-hoc signature is OS-policy-dependent on older macOS runners. */ + char native_fixture_path[CBM_SZ_4K] = {0}; + uint32_t native_fixture_size = (uint32_t)sizeof(native_fixture_path); + ASSERT_EQ(_NSGetExecutablePath(native_fixture_path, &native_fixture_size), 0); + const char *native_fixture = native_fixture_path; #else - "/bin/true"; + const char *native_fixture = "/bin/true"; #endif FILE *native_file = fopen(native_fixture, "rb"); ASSERT_NOT_NULL(native_file); diff --git a/vendored/sqlite3/sqlite3.c b/vendored/sqlite3/sqlite3.c index 851a2504c..e99bc3524 100644 --- a/vendored/sqlite3/sqlite3.c +++ b/vendored/sqlite3/sqlite3.c @@ -39378,7 +39378,7 @@ SQLITE_PRIVATE int sqlite3KvvfsInit(void){ /* ** Maximum supported path-length. */ -#define MAX_PATHNAME 512 +#define MAX_PATHNAME 4096 /* ** Maximum supported symbolic links From f7e23d8483fd80d847a4f9e8067da59463dbd4f9 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 16:44:56 +0200 Subject: [PATCH 13/23] docs(vendored): record the SQLite MAX_PATHNAME patch The Unix-VFS MAX_PATHNAME bump from 512 to 4096 shipped with the daemon work but left no paper trail, so the next amalgamation refresh would silently revert it. Document it in vendored/sqlite3/PATCHES.md with the rationale and re-apply procedure, and point to it from THIRD_PARTY.md. Signed-off-by: Martin Vogel --- THIRD_PARTY.md | 6 ++++++ vendored/sqlite3/PATCHES.md | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 vendored/sqlite3/PATCHES.md diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md index 1bbb42e18..e0a1fc82e 100644 --- a/THIRD_PARTY.md +++ b/THIRD_PARTY.md @@ -83,6 +83,12 @@ License summary: | Verstable | `internal/cbm/vendored/verstable/` | MIT | [JacksonAllan/Verstable](https://github.com/JacksonAllan/Verstable) | | wyhash | `internal/cbm/vendored/wyhash/` | Unlicense (public domain) | [wangyi-fudan/wyhash](https://github.com/wangyi-fudan/wyhash) | +Local modifications to these libraries are documented next to the +vendored sources (currently only SQLite: `vendored/sqlite3/PATCHES.md`, +raising the Unix VFS `MAX_PATHNAME` ceiling from 512 to 4096 to match +CBM's 4 KiB path support). Patches must be reapplied on every upstream +refresh and are covered by `scripts/vendored-checksums.txt`. + The graph-UI HTTP server is a first-party implementation (`src/ui/httpd.c` + `src/ui/http_server.c`) — no third-party HTTP library is used. diff --git a/vendored/sqlite3/PATCHES.md b/vendored/sqlite3/PATCHES.md new file mode 100644 index 000000000..ae8fde1b6 --- /dev/null +++ b/vendored/sqlite3/PATCHES.md @@ -0,0 +1,18 @@ +# Local patches to the vendored SQLite amalgamation + +Reapply every patch below after refreshing the amalgamation, then update +`scripts/vendored-checksums.txt` (`shasum -a 256 vendored/sqlite3/sqlite3.c`). + +## 1. Unix VFS path ceiling: `MAX_PATHNAME` 512 → 4096 + +```c +#define MAX_PATHNAME 4096 +``` + +Upstream's Unix VFS caps full database paths at 512 bytes, while CBM +supports 4 KiB paths everywhere else (cache roots under deep home +directories, long project paths on Linux). With the upstream value, +opening a store whose absolute path exceeds 512 bytes fails with +`SQLITE_CANTOPEN`. 4096 matches `PATH_MAX` on Linux and CBM's own path +buffers. Windows and the other VFS layers are unaffected (they do not +use `MAX_PATHNAME`). From 712d689efba2bed5c897944124babd7703ef7615 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 16:45:06 +0200 Subject: [PATCH 14/23] fix(test): serialize the daemon-family suites in the parallel runner The six suites that spawn coordinated worker subprocesses or bind local endpoints (index_supervisor, daemon_application, daemon_runtime, daemon_frontend, daemon_bootstrap, daemon_ipc) ran in the parallel wave, where the saturated 3-4-core CI runners starve their fixed readiness deadlines: index_supervisor waits at most 3 s for a worker marker while the worker is a full re-exec of the ASan runner plus the admission handshake. Both macOS legs failed the same four readiness assertions deterministically and ubuntu-latest added four daemon_frontend and one daemon_runtime timing failures, while an idle machine passes the same suites 6/6 in the wave. Moving them to the quiet serial tail follows the runner's existing rule for this class (cli/subprocess/watcher/...) and also keeps the shared per-account coordination namespace free of cross-suite admission traffic. Same suites, same tests, same union-guarded totals - only the schedule changes. Locally re-verified: 6742 passed, 0 failed, 2 skipped (120 suites, 17 serial-tail). Signed-off-by: Martin Vogel --- scripts/run-tests-parallel.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/run-tests-parallel.sh b/scripts/run-tests-parallel.sh index b86e2147a..e6dcae6e6 100644 --- a/scripts/run-tests-parallel.sh +++ b/scripts/run-tests-parallel.sh @@ -62,8 +62,17 @@ fi # when co-STARTED with a large wave on Apple Silicon (2s staggered vs ~230s # simultaneous — a local scheduler/zone quirk, not contention: job count # does not change it). Staggered in the tail they cost seconds. +# The daemon-family suites spawn coordinated worker subprocesses (a re-exec +# of this ASan runner plus the full admission handshake) and bind local +# endpoints under fixed readiness deadlines (3 s marker waits in +# index_supervisor); the saturated 3-core macOS CI runners starve those +# deadlines into deterministic failures while an idle machine passes 6/6. +# They also all rendezvous through the shared per-account runtime namespace, +# which the quiet tail keeps free of cross-suite admission traffic. SERIAL_SUITES="cli subprocess watcher incremental httpd ui index_resilience mcp \ - stack_overflow_a stack_overflow_b stack_overflow_c" + stack_overflow_a stack_overflow_b stack_overflow_c \ + index_supervisor daemon_application daemon_runtime daemon_frontend \ + daemon_bootstrap daemon_ipc" is_serial() { case " $SERIAL_SUITES " in *" $1 "*) return 0 ;; *) return 1 ;; esac } From 5dfce38daa927739ce540878129f69085578c7f8 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 16:57:58 +0200 Subject: [PATCH 15/23] fix(launcher): name the object and check behind every security refusal The launcher answered every ownership/ACL problem with one generic "launcher ownership or access policy is unsafe", which made the CI guard failures (and any field report) undiagnosable: the same message covers an untrusted owner SID, a mutation-capable ACE, a reparse point, a hard-link clone, and a plain open failure across the whole ancestry walk. Record the failing check at its site - offending path, owner or ACE SID in SDDL string form, rights mask, Win32 error - and print it once alongside the refusal. No policy changes; refusals stay refusals. The interactive MCP smoke now also dumps daemon-conflicts.ndjson next to the daemon log tail on failure, so an admission conflict names itself instead of surfacing as a bare 30 s client timeout. Signed-off-by: Martin Vogel --- scripts/test_mcp_interactive.py | 14 ++++- src/launcher/windows_launcher.c | 108 ++++++++++++++++++++++++++++---- 2 files changed, 107 insertions(+), 15 deletions(-) diff --git a/scripts/test_mcp_interactive.py b/scripts/test_mcp_interactive.py index d37e2d57c..aecdf7d85 100644 --- a/scripts/test_mcp_interactive.py +++ b/scripts/test_mcp_interactive.py @@ -68,11 +68,11 @@ def drain(stream: BinaryIO, chunks: list[bytes]) -> None: chunks.append(chunk) -def daemon_log_tail(limit: int = 16384) -> str: +def cache_log_tail(name: str, limit: int = 16384) -> str: cache_dir = os.environ.get("CBM_CACHE_DIR") if not cache_dir: return "" - path = os.path.join(cache_dir, "logs", "cbm-daemon.log") + path = os.path.join(cache_dir, "logs", name) try: with open(path, "rb") as stream: stream.seek(0, os.SEEK_END) @@ -385,7 +385,8 @@ def main() -> int: except SmokeFailure as error: stop_process(process) stderr = b"".join(stderr_chunks).decode("utf-8", errors="replace") - daemon_log = daemon_log_tail() + daemon_log = cache_log_tail("cbm-daemon.log") + conflicts = cache_log_tail("daemon-conflicts.ndjson") print(f"FAIL: {error}", file=sys.stderr) if stderr: print("--- MCP stderr ---", file=sys.stderr) @@ -397,6 +398,13 @@ def main() -> int: file=sys.stderr, end="" if daemon_log.endswith("\n") else "\n", ) + if conflicts: + print("--- daemon-conflicts.ndjson (tail) ---", file=sys.stderr) + print( + conflicts, + file=sys.stderr, + end="" if conflicts.endswith("\n") else "\n", + ) return 1 diff --git a/src/launcher/windows_launcher.c b/src/launcher/windows_launcher.c index ba9ea41a8..cb9acd547 100644 --- a/src/launcher/windows_launcher.c +++ b/src/launcher/windows_launcher.c @@ -25,6 +25,7 @@ int main(void) { #include #include #include +#include #include #include @@ -32,6 +33,7 @@ int main(void) { #define PIPE_REJECT_REMOTE_CLIENTS 0x00000008 #endif +#include #include #include #include @@ -80,9 +82,43 @@ static uint64_t launcher_filetime_value(const FILETIME *time) { return (uint64_t)time->dwLowDateTime | ((uint64_t)time->dwHighDateTime << 32U); } +/* Last refusal detail, set at the exact check that failed and printed once by + * launcher_failure. A bare "policy is unsafe" is undiagnosable in the field; + * naming the object and rule costs nothing and leaks nothing an owner of the + * process could not query themselves. Last writer wins. */ +static wchar_t launcher_refusal_detail[320]; + +static void launcher_refusal_set(const wchar_t *format, ...) { + va_list arguments; + va_start(arguments, format); + (void)_vsnwprintf(launcher_refusal_detail, + sizeof(launcher_refusal_detail) / sizeof(wchar_t) - 1U, format, arguments); + va_end(arguments); + launcher_refusal_detail[sizeof(launcher_refusal_detail) / sizeof(wchar_t) - 1U] = L'\0'; +} + +static void launcher_refusal_set_sid(const wchar_t *what, PSID sid, DWORD rights) { + wchar_t *sid_text = NULL; + bool converted = sid && ConvertSidToStringSidW(sid, &sid_text) != 0; + if (rights) { + launcher_refusal_set(L"%ls %ls (rights 0x%08lx)", what, + converted ? sid_text : L"", (unsigned long)rights); + } else { + launcher_refusal_set(L"%ls %ls", what, converted ? sid_text : L""); + } + if (converted) { + (void)LocalFree(sid_text); + } +} + static int launcher_failure(const wchar_t *message) { if (message) { - (void)fwprintf(stderr, L"codebase-memory-mcp: %ls\n", message); + if (launcher_refusal_detail[0]) { + (void)fwprintf(stderr, L"codebase-memory-mcp: %ls [%ls]\n", message, + launcher_refusal_detail); + } else { + (void)fwprintf(stderr, L"codebase-memory-mcp: %ls\n", message); + } (void)fflush(stderr); } return 1; @@ -96,11 +132,30 @@ static bool launcher_same_identity(const BY_HANDLE_FILE_INFORMATION *first, } static bool launcher_file_information(HANDLE file, BY_HANDLE_FILE_INFORMATION *information) { - return file && file != INVALID_HANDLE_VALUE && GetFileType(file) == FILE_TYPE_DISK && - GetFileInformationByHandle(file, information) != 0 && - (information->dwFileAttributes & - (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) == 0 && - information->nNumberOfLinks == 1U; + if (!file || file == INVALID_HANDLE_VALUE) { + launcher_refusal_set(L"open failed (error %lu)", (unsigned long)GetLastError()); + return false; + } + if (GetFileType(file) != FILE_TYPE_DISK) { + launcher_refusal_set(L"not a local disk file"); + return false; + } + if (GetFileInformationByHandle(file, information) == 0) { + launcher_refusal_set(L"attribute query failed (error %lu)", (unsigned long)GetLastError()); + return false; + } + if ((information->dwFileAttributes & + (FILE_ATTRIBUTE_DIRECTORY | FILE_ATTRIBUTE_REPARSE_POINT)) != 0) { + launcher_refusal_set(L"directory or reparse point (attributes 0x%08lx)", + (unsigned long)information->dwFileAttributes); + return false; + } + if (information->nNumberOfLinks != 1U) { + launcher_refusal_set(L"hard-link count %lu, expected 1", + (unsigned long)information->nNumberOfLinks); + return false; + } + return true; } static bool launcher_current_user(void **token_user_out, PSID *sid_out) { @@ -180,6 +235,7 @@ static bool launcher_security_is_safe(HANDLE file, bool require_current_owner, D void *token_user = NULL; PSID user_sid = NULL; if (!launcher_current_user(&token_user, &user_sid)) { + launcher_refusal_set(L"could not resolve the process token user"); return false; } PSID owner = NULL; @@ -190,15 +246,25 @@ static bool launcher_security_is_safe(HANDLE file, bool require_current_owner, D NULL, &dacl, NULL, &descriptor); ACL_SIZE_INFORMATION information; memset(&information, 0, sizeof(information)); - bool secure = - status == ERROR_SUCCESS && owner && IsValidSid(owner) && - (require_current_owner ? EqualSid(owner, user_sid) != 0 - : launcher_sid_is_trusted(owner, user_sid)) && - dacl && IsValidAcl(dacl) && - GetAclInformation(dacl, &information, sizeof(information), AclSizeInformation) != 0; + bool secure = status == ERROR_SUCCESS && owner && IsValidSid(owner); + if (!secure) { + launcher_refusal_set(L"security query failed (status %lu)", (unsigned long)status); + } else if (require_current_owner ? EqualSid(owner, user_sid) == 0 + : !launcher_sid_is_trusted(owner, user_sid)) { + launcher_refusal_set_sid(require_current_owner ? L"owner is not the current user:" + : L"owner is not a trusted identity:", + owner, 0U); + secure = false; + } else if (!dacl || !IsValidAcl(dacl) || + GetAclInformation(dacl, &information, sizeof(information), AclSizeInformation) == + 0) { + launcher_refusal_set(L"missing or invalid DACL"); + secure = false; + } for (DWORD index = 0; secure && index < information.AceCount; index++) { void *opaque = NULL; if (!GetAce(dacl, index, &opaque) || !opaque) { + launcher_refusal_set(L"DACL entry %lu is unreadable", (unsigned long)index); secure = false; break; } @@ -218,6 +284,8 @@ static bool launcher_security_is_safe(HANDLE file, bool require_current_owner, D if (header->AceType != LAUNCHER_ACE_ALLOW || header->AceSize < offsetof(ACCESS_ALLOWED_ACE, SidStart) + offsetof(SID, SubAuthority)) { + launcher_refusal_set(L"DACL entry %lu has unsupported type 0x%02x", + (unsigned long)index, (unsigned int)header->AceType); secure = false; break; } @@ -226,6 +294,8 @@ static bool launcher_security_is_safe(HANDLE file, bool require_current_owner, D continue; } if (!launcher_bounded_ace_sid_is_trusted(header, user_sid)) { + launcher_refusal_set_sid(L"mutation right granted to untrusted identity:", + (PSID)&ace->SidStart, ace->Mask & mutation); secure = false; } } @@ -330,11 +400,25 @@ static bool launcher_path_tree_plain(const wchar_t *file_path) { * plant DLL or .exe.local redirection artifacts beside CBM. */ mutation &= ~((DWORD)FILE_ADD_SUBDIRECTORY); } + DWORD open_error = component == INVALID_HANDLE_VALUE ? GetLastError() : ERROR_SUCCESS; valid = component != INVALID_HANDLE_VALUE && GetFileType(component) == FILE_TYPE_DISK && GetFileInformationByHandle(component, &information) != 0 && (information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (information.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0 && launcher_security_is_safe(component, false, mutation); + if (!valid) { + /* Name the ancestry component that failed while it is still + * NUL-terminated at this walk position. */ + if (open_error != ERROR_SUCCESS) { + launcher_refusal_set(L"%ls: open failed (error %lu)", path, + (unsigned long)open_error); + } else { + wchar_t inner[320]; + memcpy(inner, launcher_refusal_detail, sizeof(inner)); + inner[sizeof(inner) / sizeof(wchar_t) - 1U] = L'\0'; + launcher_refusal_set(L"%ls: %ls", path, inner); + } + } if (component != INVALID_HANDLE_VALUE) (void)CloseHandle(component); path[index] = saved; From c634c86d1a68a21a05452d357d187919a6cb9210 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 16:58:10 +0200 Subject: [PATCH 16/23] ci(windows): protected per-user temp root + explicit owner on staged guards Completes the handoff task for the Windows harness: the native test job ran with the MSYS-shared /tmp and the runner's inherited LocalAppData\Temp ACLs, which the daemon's strict private-filesystem checks reject by design. The test-windows job now creates a per-user root under the profile with an owner-stamped, protected current-SID DACL and routes TEMP/TMP (native form) and TMPDIR (POSIX form) through it before scripts/test.sh runs. The guards script already hardened its root DACL but ownership is never inherited on Windows: bundle copies created by the admin-group runner token can come out Administrators-owned, and the launcher's exe policy requires the exact current user as owner. Stamp the current SID on the staged bundle dir and both executables explicitly. Job topology, gates, and triggers are unchanged; the new step is a sub-second pwsh block in the existing test-windows job. Signed-off-by: Martin Vogel --- .github/workflows/_test.yml | 33 ++++++++++++++++++++++++++++++++- scripts/test-windows.ps1 | 10 ++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index de6e049e4..e366166ef 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -122,6 +122,30 @@ jobs: make git + - name: Create protected per-user temp root + # The daemon suites fail closed on the MSYS-shared /tmp and on the + # runner's inherited LocalAppData\Temp ACLs. Give the whole harness a + # per-user root under the profile with an owner-stamped, protected + # current-SID DACL, matching the launcher/daemon trust policy. + shell: pwsh + run: | + $root = Join-Path $env:USERPROFILE ("cbm-ci-tmp-" + [guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $root | Out-Null + $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $acl = [System.Security.AccessControl.DirectorySecurity]::new() + $acl.SetOwner($sid) + $acl.SetAccessRuleProtection($true, $false) + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor + [System.Security.AccessControl.InheritanceFlags]::ObjectInherit), + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow) + $acl.AddAccessRule($rule) | Out-Null + Set-Acl -LiteralPath $root -AclObject $acl + "CBM_CI_TEMP_ROOT=$root" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8 + - name: Test shell: msys2 {0} # AddressSanitizer is unavailable on native ARM64 Windows (LLVM ships no @@ -130,7 +154,14 @@ jobs: # native ARM64 build with SANITIZE= (no sanitizer) — still a real # functional gate. ASan/UBSan coverage comes from the other 9 legs, # including native-ARM Linux/macOS. x86-64 Windows keeps full sanitizers. - run: scripts/test.sh CC=clang CXX=clang++ ${{ matrix.os == 'windows-11-arm' && 'SANITIZE=' || '' }} + run: | + # Native tests resolve temp via TEMP/TMP (cbm_tmpdir); shell tools use + # TMPDIR. Route both through the protected root created above so no + # fixture lands under a shared or ACL-inherited ancestry. + export TEMP="$(cygpath -m "$CBM_CI_TEMP_ROOT")" + export TMP="$TEMP" + export TMPDIR="$(cygpath -u "$CBM_CI_TEMP_ROOT")" + scripts/test.sh CC=clang CXX=clang++ ${{ matrix.os == 'windows-11-arm' && 'SANITIZE=' || '' }} env: CBM_SKIP_PERF: ${{ inputs.skip_perf && '1' || '' }} diff --git a/scripts/test-windows.ps1 b/scripts/test-windows.ps1 index ee28f7e95..06d0f79b5 100644 --- a/scripts/test-windows.ps1 +++ b/scripts/test-windows.ps1 @@ -169,6 +169,16 @@ try { $guardPayload = Join-Path $guardBundle "codebase-memory-mcp.payload.exe" Copy-Item -LiteralPath $launcherBin -Destination $guardBin Copy-Item -LiteralPath $bin -Destination $guardPayload + + # Ownership is never inherited on Windows: descendants created under the + # hardened root by an admin-group token can default to the Administrators + # SID, and the launcher's exe policy demands the exact current user as + # owner. Stamp the current SID explicitly on everything staged here. + foreach ($staged in @($guardBundle, $guardBin, $guardPayload)) { + $stagedAcl = Get-Acl -LiteralPath $staged + $stagedAcl.SetOwner($currentSid) + Set-Acl -LiteralPath $staged -AclObject $stagedAcl + } Write-Host "Guard bundle: $guardBin" -ForegroundColor Green # The launcher deliberately rejects GitHub's shared D:\a ancestry and the From d1761e40cb5cb6b29f5535deca9e7fcdbfaaea93 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 17:19:50 +0200 Subject: [PATCH 17/23] fix(daemon): stamp the token-user SID as owner on private-namespace objects The Windows SECURITY_ATTRIBUTES used for every private-namespace creation (runtime dirs, rendezvous/startup/participant/lifetime/cohort lock files, log files, pipes) carried a DACL but no owner, so created objects took the token DEFAULT owner. Admin-group tokens can default that to BUILTIN\Administrators - standard policy on Windows Server, including CI runners - while every strict validation gate (win_file_owner_secure(require_current_user=true), private_win_owner_only_dacl) demands the exact token-user SID. Result: the daemon rejected objects it had just created itself; the client refused the daemon's rendezvous record, retried silently for its full 30 s window, and each clientless generation clean-stopped, looping until the frontend gave up. The legacy kernel mutex was the one object that already force-set its owner post-create - it is the template this change generalizes. Both descriptor builders now set the owner explicitly at creation, so objects are born satisfying the same strict gates on every default- owner policy. The gates themselves are unchanged. The Windows client connect path also stops failing mute: terminal rendezvous refusals, deadline expiry (with last status), and pipe server-identity rejections now log their reason once, matching the launcher-side refusal diagnostics. Signed-off-by: Martin Vogel --- src/daemon/ipc.c | 34 +++++++++++++++++++++++++++++- src/foundation/private_file_lock.c | 11 ++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index abfb6d910..970b9579a 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -3225,6 +3225,7 @@ typedef BOOL(WINAPI *initialize_acl_fn)(PACL, DWORD, DWORD); typedef BOOL(WINAPI *add_access_allowed_ace_fn)(PACL, DWORD, DWORD, PSID); typedef BOOL(WINAPI *initialize_security_descriptor_fn)(PSECURITY_DESCRIPTOR, DWORD); typedef BOOL(WINAPI *set_security_descriptor_dacl_fn)(PSECURITY_DESCRIPTOR, BOOL, PACL, BOOL); +typedef BOOL(WINAPI *set_security_descriptor_owner_fn)(PSECURITY_DESCRIPTOR, PSID, BOOL); typedef BOOL(WINAPI *get_acl_information_fn)(PACL, LPVOID, DWORD, ACL_INFORMATION_CLASS); typedef BOOL(WINAPI *get_ace_fn)(PACL, DWORD, LPVOID *); typedef DWORD(WINAPI *get_security_info_fn)(HANDLE, SE_OBJECT_TYPE, SECURITY_INFORMATION, PSID *, @@ -3251,6 +3252,7 @@ typedef struct { add_access_allowed_ace_fn add_access_allowed_ace; initialize_security_descriptor_fn initialize_security_descriptor; set_security_descriptor_dacl_fn set_security_descriptor_dacl; + set_security_descriptor_owner_fn set_security_descriptor_owner; get_acl_information_fn get_acl_information; get_ace_fn get_ace; get_security_info_fn get_security_info; @@ -3519,6 +3521,8 @@ static bool win_security_init(win_security_t *security) { initialize_security_descriptor_fn, "InitializeSecurityDescriptor"); RESOLVE_ADVAPI_MEMBER(security, set_security_descriptor_dacl, set_security_descriptor_dacl_fn, "SetSecurityDescriptorDacl"); + RESOLVE_ADVAPI_MEMBER(security, set_security_descriptor_owner, set_security_descriptor_owner_fn, + "SetSecurityDescriptorOwner"); RESOLVE_ADVAPI_MEMBER(security, get_acl_information, get_acl_information_fn, "GetAclInformation"); RESOLVE_ADVAPI_MEMBER(security, get_ace, get_ace_fn, "GetAce"); @@ -3564,7 +3568,15 @@ static bool win_security_init(win_security_t *security) { security->user_sid) || !security->initialize_security_descriptor(security->descriptor, SECURITY_DESCRIPTOR_REVISION) || - !security->set_security_descriptor_dacl(security->descriptor, TRUE, security->acl, FALSE)) { + !security->set_security_descriptor_dacl(security->descriptor, TRUE, security->acl, FALSE) || + /* The owner must be stamped explicitly at creation: members of the + * Administrators group can carry a default-owner policy of BUILTIN\ + * Administrators (standard on Windows Server, including CI runners), + * and every private-namespace validation demands the exact token-user + * SID as owner. Relying on the token default makes the daemon reject + * objects it created itself. */ + !security->set_security_descriptor_owner(security->descriptor, security->user_sid, + FALSE)) { win_security_destroy(security); return false; } @@ -5229,17 +5241,36 @@ cbm_daemon_ipc_connection_t *cbm_daemon_ipc_connect(const cbm_daemon_ipc_endpoin } uint64_t deadline_ms = ipc_deadline_after(timeout_ms); HANDLE pipe = INVALID_HANDLE_VALUE; + bool wait_logged = false; for (;;) { win_rendezvous_status_t rendezvous = win_endpoint_refresh_rendezvous(endpoint); if (rendezvous == WIN_RENDEZVOUS_CORRUPT || rendezvous == WIN_RENDEZVOUS_ERROR) { + /* Terminal refusal: without a reason here the caller can only + * report a generic connect timeout, which made owner-policy + * failures on the rendezvous namespace undiagnosable. */ + cbm_log_warn("daemon.client.rendezvous_unreadable", "status", + rendezvous == WIN_RENDEZVOUS_CORRUPT ? "corrupt" : "unsafe_or_io"); return NULL; } win_generation_address_t *generation = rendezvous == WIN_RENDEZVOUS_VALID ? win_endpoint_generation_snapshot(endpoint) : NULL; if (!generation) { if (win_retry_pause(deadline_ms)) { + if (!wait_logged) { + wait_logged = true; + cbm_log_warn("daemon.client.rendezvous_wait", "status", + rendezvous == WIN_RENDEZVOUS_ABSENT ? "absent" + : rendezvous == WIN_RENDEZVOUS_BUSY ? "busy" + : rendezvous == WIN_RENDEZVOUS_VALID ? "no_generation" + : "unknown"); + } continue; } + cbm_log_warn("daemon.client.connect_deadline", "status", + rendezvous == WIN_RENDEZVOUS_ABSENT ? "absent" + : rendezvous == WIN_RENDEZVOUS_BUSY ? "busy" + : rendezvous == WIN_RENDEZVOUS_VALID ? "no_generation" + : "unknown"); return NULL; } pipe = CreateFileW(generation->pipe_name, GENERIC_READ | GENERIC_WRITE, 0, NULL, @@ -5272,6 +5303,7 @@ cbm_daemon_ipc_connection_t *cbm_daemon_ipc_connect(const cbm_daemon_ipc_endpoin DWORD mode = PIPE_READMODE_BYTE; if (!SetNamedPipeHandleState(pipe, &mode, NULL, NULL) || !win_pipe_server_is_current_user(pipe)) { + cbm_log_warn("daemon.client.pipe_rejected", "stage", "server_identity"); (void)CloseHandle(pipe); return NULL; } diff --git a/src/foundation/private_file_lock.c b/src/foundation/private_file_lock.c index c8251354d..9338ffe6f 100644 --- a/src/foundation/private_file_lock.c +++ b/src/foundation/private_file_lock.c @@ -623,6 +623,7 @@ typedef BOOL(WINAPI *private_is_valid_sid_fn)(PSID); typedef BOOL(WINAPI *private_initialize_acl_fn)(PACL, DWORD, DWORD); typedef BOOL(WINAPI *private_add_access_allowed_ace_fn)(PACL, DWORD, DWORD, PSID); typedef BOOL(WINAPI *private_initialize_security_descriptor_fn)(PSECURITY_DESCRIPTOR, DWORD); +typedef BOOL(WINAPI *private_set_security_descriptor_owner_fn)(PSECURITY_DESCRIPTOR, PSID, BOOL); typedef BOOL(WINAPI *private_set_security_descriptor_dacl_fn)(PSECURITY_DESCRIPTOR, BOOL, PACL, BOOL); typedef BOOL(WINAPI *private_set_security_descriptor_control_fn)(PSECURITY_DESCRIPTOR, @@ -649,6 +650,7 @@ typedef struct { private_add_access_allowed_ace_fn add_access_allowed_ace; private_initialize_security_descriptor_fn initialize_security_descriptor; private_set_security_descriptor_dacl_fn set_security_descriptor_dacl; + private_set_security_descriptor_owner_fn set_security_descriptor_owner; private_set_security_descriptor_control_fn set_security_descriptor_control; private_get_security_descriptor_control_fn get_security_descriptor_control; private_get_acl_information_fn get_acl_information; @@ -764,6 +766,8 @@ static bool private_win_security_init(private_win_security_t *security) { "InitializeSecurityDescriptor"); PRIVATE_RESOLVE_ADVAPI(security, set_security_descriptor_dacl, private_set_security_descriptor_dacl_fn, "SetSecurityDescriptorDacl"); + PRIVATE_RESOLVE_ADVAPI(security, set_security_descriptor_owner, + private_set_security_descriptor_owner_fn, "SetSecurityDescriptorOwner"); PRIVATE_RESOLVE_ADVAPI(security, set_security_descriptor_control, private_set_security_descriptor_control_fn, "SetSecurityDescriptorControl"); @@ -814,6 +818,13 @@ static bool private_win_security_init(private_win_security_t *security) { !security->initialize_security_descriptor(security->descriptor, SECURITY_DESCRIPTOR_REVISION) || !security->set_security_descriptor_dacl(security->descriptor, TRUE, security->acl, FALSE) || + /* Stamp the exact token-user SID as owner at creation: admin-group + * tokens can default new objects to BUILTIN\Administrators (standard + * on Windows Server), and private_win_owner_only_dacl demands the + * exact user SID — without this every lock file the process creates + * fails its own validation. */ + !security->set_security_descriptor_owner(security->descriptor, security->user_sid, + FALSE) || !security->set_security_descriptor_control(security->descriptor, SE_DACL_PROTECTED, SE_DACL_PROTECTED)) { private_win_security_destroy(security); From 5414ba479e5d52c4b857e611ee73fe6d49be96a9 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 17:23:18 +0200 Subject: [PATCH 18/23] fix(daemon): normalize trusted directory owners at the private-dir gate Stamping the owner at creation covers objects born through the daemon's own security descriptors, but the cache root is created by plain mkdir_p before the gate runs, and harness- or user-created directories arrive the same way. On an Administrators-default-owner token those directories are born owned by BUILTIN\Administrators and the exact-user gate refused them permanently - including a real elevated admin's own cache directory. win_runtime_directory_secure (the final component of both the runtime dir and the cache-root validation) now treats a TRUSTED owner - the same SYSTEM/Administrators/TrustedInstaller set the launcher already accepts for directories - as repairable: the owner is re-stamped to the exact token user inside the same set_security_info call that already re-protects the DACL. Any other owner is refused exactly as before, the post-repair validation still demands the exact user, and the handle falls back to the old access mask when WRITE_OWNER is unavailable so no previously-passing configuration can newly fail. Signed-off-by: Martin Vogel --- src/daemon/ipc.c | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index 970b9579a..0b732b040 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -3932,9 +3932,16 @@ static bool win_runtime_directory_secure(const wchar_t *runtime_dir) { return false; } HANDLE directory = - CreateFileW(runtime_dir, READ_CONTROL | WRITE_DAC, + CreateFileW(runtime_dir, READ_CONTROL | WRITE_DAC | WRITE_OWNER, FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + bool can_write_owner = directory != INVALID_HANDLE_VALUE; + if (!can_write_owner) { + directory = + CreateFileW(runtime_dir, READ_CONTROL | WRITE_DAC, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, NULL, OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, NULL); + } if (directory == INVALID_HANDLE_VALUE) { win_security_destroy(&security); return false; @@ -3943,13 +3950,24 @@ static bool win_runtime_directory_secure(const wchar_t *runtime_dir) { bool valid_handle = GetFileInformationByHandle(directory, &file_info) != 0 && (file_info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0 && (file_info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) == 0; - bool owner_ok = valid_handle && win_file_owner_secure(&security, directory, true); + bool owner_exact = valid_handle && win_file_owner_secure(&security, directory, true); + /* One-time normalization of the admin-group default-owner artifact: a + * directory created by plain mkdir under an Administrators-default-owner + * token (standard policy on Windows Server) is born owned by BUILTIN\ + * Administrators even though it is this account's own private dir. A + * TRUSTED owner (the launcher's directory policy: SYSTEM, Administrators, + * TrustedInstaller) is re-stamped to the exact token user inside the same + * repair that already re-protects the DACL; any other owner remains + * refused, and the final validation below still demands the exact user. */ + bool owner_ok = owner_exact || (valid_handle && can_write_owner && + win_file_owner_secure(&security, directory, false)); DWORD secure_result = ERROR_ACCESS_DENIED; if (valid_handle && owner_ok) { - secure_result = security.set_security_info(directory, SE_FILE_OBJECT, - DACL_SECURITY_INFORMATION | - PROTECTED_DACL_SECURITY_INFORMATION, - NULL, NULL, security.acl, NULL); + secure_result = security.set_security_info( + directory, SE_FILE_OBJECT, + (owner_exact ? 0U : (DWORD)OWNER_SECURITY_INFORMATION) | DACL_SECURITY_INFORMATION | + PROTECTED_DACL_SECURITY_INFORMATION, + owner_exact ? NULL : security.user_sid, NULL, security.acl, NULL); } bool final_private = secure_result == ERROR_SUCCESS && From 838ef3b38388e5b2120720c4622eef3640d9ccd3 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 17:56:36 +0200 Subject: [PATCH 19/23] fix(daemon): classify server-identity rejections; satisfy clang-format The new pipe_rejected diagnostic proved the Windows client reaches the daemon's pipe and then refuses the SERVER identity for its whole 30 s window, while earlier sessions in the same run connect fine. The check compares the server-process token user, so the interesting failures are environmental: a dead server PID already reused by Windows (typically for a SYSTEM service) is indistinguishable from a hostile pipe without classification. The rejection now names the failing step (pid query, process open, token open/query) or the server SID class (system/admins/other) plus the server PID. Also reflows the two owner-stamping call sites the lint gate flagged. Signed-off-by: Martin Vogel --- src/daemon/ipc.c | 25 ++++++++++++++++++++++--- src/foundation/private_file_lock.c | 3 +-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index 0b732b040..9ba323ca4 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -3575,8 +3575,7 @@ static bool win_security_init(win_security_t *security) { * and every private-namespace validation demands the exact token-user * SID as owner. Relying on the token default makes the daemon reject * objects it created itself. */ - !security->set_security_descriptor_owner(security->descriptor, security->user_sid, - FALSE)) { + !security->set_security_descriptor_owner(security->descriptor, security->user_sid, FALSE)) { win_security_destroy(security); return false; } @@ -5156,7 +5155,27 @@ static bool win_pipe_server_is_current_user(HANDLE pipe) { bool initialized = win_security_init(&security); HANDLE token = NULL; bool opened = initialized && security.open_process_token(process, TOKEN_QUERY, &token) != 0; - bool same_user = opened && win_token_is_current_user(&security, token); + PSID token_sid = NULL; + void *token_user = opened ? win_token_user_query(&security, token, &token_sid) : NULL; + bool same_user = + token_user && token_sid && security.equal_sid(token_sid, security.user_sid) != 0; + if (!same_user) { + /* Name the failing step and the peer: a rejected server can be a dead + * PID that Windows already reused (often for a SYSTEM service), which + * looks identical to a hostile pipe without this classification. */ + const char *step = !initialized ? "security_init" + : !opened ? "token_open" + : !token_sid ? "token_query" + : security.is_well_known_sid(token_sid, WinLocalSystemSid) + ? "server_is_system" + : security.is_well_known_sid(token_sid, WinBuiltinAdministratorsSid) + ? "server_is_admins" + : "server_other_account"; + char pid_text[16]; + (void)snprintf(pid_text, sizeof(pid_text), "%lu", (unsigned long)process_id); + cbm_log_warn("daemon.client.server_identity", "step", step, "pid", pid_text); + } + free(token_user); if (token) { (void)CloseHandle(token); } diff --git a/src/foundation/private_file_lock.c b/src/foundation/private_file_lock.c index 9338ffe6f..64f026c1c 100644 --- a/src/foundation/private_file_lock.c +++ b/src/foundation/private_file_lock.c @@ -823,8 +823,7 @@ static bool private_win_security_init(private_win_security_t *security) { * on Windows Server), and private_win_owner_only_dacl demands the * exact user SID — without this every lock file the process creates * fails its own validation. */ - !security->set_security_descriptor_owner(security->descriptor, security->user_sid, - FALSE) || + !security->set_security_descriptor_owner(security->descriptor, security->user_sid, FALSE) || !security->set_security_descriptor_control(security->descriptor, SE_DACL_PROTECTED, SE_DACL_PROTECTED)) { private_win_security_destroy(security); From 7c90eb22b0aec661e7f6836ce3e23b3cde01a93b Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 18:38:27 +0200 Subject: [PATCH 20/23] test(index-supervisor): dump worker logs at the failure sites The four readiness/terminal failures on the CI macOS legs persist even with the suite serialized on a quiet machine, so the worker is exiting rather than starving - and its exit reason lives in the worker log and response files, which teardown deletes before the asserts print. Dump them at each failure site (async readiness, probe non-terminal, backlog logging, oversize containment) so the next failing run names the worker's actual error instead of a bare ASSERT. Signed-off-by: Martin Vogel --- tests/test_index_supervisor.c | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/test_index_supervisor.c b/tests/test_index_supervisor.c index e0adbd05f..259c4f9c5 100644 --- a/tests/test_index_supervisor.c +++ b/tests/test_index_supervisor.c @@ -67,6 +67,23 @@ static bool index_supervisor_test_wait_file(cbm_index_worker_handle_t *handle, c return false; } +/* On failure, the worker's own log/marker files carry the exit reason; they + * are deleted during teardown before the asserts run, so dump them at the + * failure site or the evidence is gone by the time CI prints the FAIL. */ +static void index_supervisor_test_dump(const char *label, const char *path) { + (void)fprintf(stderr, " [worker-dump] %s: %s\n", label, path && path[0] ? path : ""); + FILE *file = path && path[0] ? cbm_fopen(path, "rb") : NULL; + if (!file) { + (void)fprintf(stderr, " [worker-dump] (missing or unreadable)\n"); + return; + } + char content[4096]; + size_t used = fread(content, 1, sizeof(content) - 1, file); + content[used] = '\0'; + (void)fclose(file); + (void)fprintf(stderr, "%s%s", content, used > 0 && content[used - 1] == '\n' ? "" : "\n"); +} + static bool index_supervisor_test_wait_file_text(const char *path, const char *needle, uint32_t timeout_ms) { uint64_t deadline = cbm_now_ms() + timeout_ms; @@ -458,6 +475,12 @@ TEST(index_supervisor_async_jobs_are_isolated_cancellable_and_terminal_cached) { } else { index_supervisor_test_cleanup_handle(second); } + if (!first_ready || !second_ready) { + index_supervisor_test_dump("worker-a log", log_a); + index_supervisor_test_dump("worker-b log", log_b); + index_supervisor_test_dump("worker-a response", response_a); + index_supervisor_test_dump("worker-b response", response_b); + } (void)cbm_unlink(log_a); (void)cbm_unlink(log_b); (void)cbm_unlink(marker_a); @@ -544,6 +567,8 @@ static bool index_supervisor_test_run_probe(const char *mode, bool profiling, *response_path_exists_out = cbm_file_size(response_path) >= 0; cbm_index_worker_destroy(handle); } else { + index_supervisor_test_dump("probe worker log", log_path); + index_supervisor_test_dump("probe worker response", response_path); index_supervisor_test_cleanup_handle(handle); } if (cbm_file_size(log_path) >= 0) { @@ -657,6 +682,9 @@ TEST(index_supervisor_drains_terminal_backlog_into_request_progress_callback) { index_supervisor_test_cleanup_handle(handle); } + if (!worker_logged || !terminal) { + index_supervisor_test_dump("backlog worker log", log_path); + } index_supervisor_test_restore_env("CBM_CACHE_DIR", saved_cache); (void)th_rmtree(cache); @@ -707,6 +735,7 @@ TEST(index_supervisor_oversized_response_is_contained_and_log_is_retained) { if (terminal) { cbm_index_worker_destroy(handle); } else { + index_supervisor_test_dump("oversize worker log", log_path); index_supervisor_test_cleanup_handle(handle); } (void)cbm_unlink(log_path); From 3fd3385564aaac36813150919573e6b908231903 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 18:51:36 +0200 Subject: [PATCH 21/23] fix(daemon): name the failing check behind every private-namespace refusal The cache-private identity failure printed a bare status name while the actual rule that refused - POSIX owner/mode/extended-ACL steps, Windows owner class, mutation-capable DACL entries, ancestry components, the owner/DACL repair - stayed mute, costing a blind CI round-trip per guess. Both platform gates now record the object and rule at the exact failing check (cbm_daemon_ipc_validation_detail), main.c appends it to the identity message, and the Windows client's server-identity check logs its two previously-silent early exits (server PID query, process open - a dead server PID that Windows reaped or reused). Validation policy is unchanged everywhere; refusals only stop being anonymous. Signed-off-by: Martin Vogel --- src/daemon/ipc.c | 102 +++++++++++++++++++++++++++++++++++++++++++---- src/daemon/ipc.h | 5 +++ src/main.c | 6 ++- 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index 9ba323ca4..013f4eca3 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -28,6 +28,23 @@ enum { CBM_DAEMON_IPC_WINDOWS_RENDEZVOUS_RETRY_MS = 250, }; +/* Last private-namespace validation refusal, set at the exact failing check. + * A bare "cache-private" status is undiagnosable in the field; the refusal + * names the object and rule instead. Last-writer-wins, diagnostic only. */ +static char ipc_validation_detail_buffer[384]; + +static void ipc_validation_detail_set(const char *format, ...) { + va_list arguments; + va_start(arguments, format); + (void)vsnprintf(ipc_validation_detail_buffer, sizeof(ipc_validation_detail_buffer), format, + arguments); + va_end(arguments); +} + +const char *cbm_daemon_ipc_validation_detail(void) { + return ipc_validation_detail_buffer; +} + static bool instance_key_valid(const char *key) { if (!key) { return false; @@ -1415,10 +1432,37 @@ static int private_directory_tree_open(const char *directory_path) { } } struct stat final_status; - ok = ok && visited && fstat(current_fd, &final_status) == 0 && S_ISDIR(final_status.st_mode) && - final_status.st_uid == geteuid() && fchmod(current_fd, 0700) == 0 && - cbm_macos_extended_acl_fd_clear(current_fd) && fstat(current_fd, &final_status) == 0 && - (final_status.st_mode & 07777) == 0700 && cbm_macos_extended_acl_fd_is_empty(current_fd); + if (ok && !visited) { + ipc_validation_detail_set("%s: no path components resolved", directory_path); + ok = false; + } + if (ok && (fstat(current_fd, &final_status) != 0 || !S_ISDIR(final_status.st_mode))) { + ipc_validation_detail_set("%s: final stat failed or not a directory (errno %d)", + directory_path, errno); + ok = false; + } + if (ok && final_status.st_uid != geteuid()) { + ipc_validation_detail_set("%s: owner uid %ld, expected euid %ld", directory_path, + (long)final_status.st_uid, (long)geteuid()); + ok = false; + } + if (ok && fchmod(current_fd, 0700) != 0) { + ipc_validation_detail_set("%s: chmod 0700 failed (errno %d)", directory_path, errno); + ok = false; + } + if (ok && !cbm_macos_extended_acl_fd_clear(current_fd)) { + ipc_validation_detail_set("%s: extended ACL not clearable", directory_path); + ok = false; + } + if (ok && (fstat(current_fd, &final_status) != 0 || (final_status.st_mode & 07777) != 0700)) { + ipc_validation_detail_set("%s: mode 0%o survived chmod, expected 0700", directory_path, + (unsigned)(final_status.st_mode & 07777)); + ok = false; + } + if (ok && !cbm_macos_extended_acl_fd_is_empty(current_fd)) { + ipc_validation_detail_set("%s: extended ACL still present after clear", directory_path); + ok = false; + } free(path); if (!ok) { if (current_fd >= 0) { @@ -1430,8 +1474,13 @@ static int private_directory_tree_open(const char *directory_path) { } bool cbm_daemon_ipc_private_directory_secure(const char *directory_path) { + ipc_validation_detail_set("%s", ""); int directory_fd = private_directory_tree_open(directory_path); if (directory_fd < 0) { + if (!ipc_validation_detail_buffer[0]) { + ipc_validation_detail_set("%s: ancestry component validation failed (errno %d)", + directory_path, errno); + } return false; } return close(directory_fd) == 0; @@ -3842,9 +3891,20 @@ static bool win_file_owner_secure(win_security_t *security, HANDLE file, PSECURITY_DESCRIPTOR descriptor = NULL; DWORD status = security->get_security_info(file, SE_FILE_OBJECT, OWNER_SECURITY_INFORMATION, &owner, NULL, NULL, NULL, &descriptor); - bool secure = status == ERROR_SUCCESS && owner && security->is_valid_sid(owner) && - (require_current_user ? security->equal_sid(owner, security->user_sid) - : win_sid_trusted(security, owner)); + bool queried = status == ERROR_SUCCESS && owner && security->is_valid_sid(owner); + bool secure = + queried && (require_current_user ? security->equal_sid(owner, security->user_sid) != 0 + : win_sid_trusted(security, owner)); + if (!queried) { + ipc_validation_detail_set("owner query failed (status %lu)", (unsigned long)status); + } else if (!secure) { + const char *owner_class = security->is_well_known_sid(owner, WinLocalSystemSid) ? "SYSTEM" + : security->is_well_known_sid(owner, WinBuiltinAdministratorsSid) + ? "Administrators" + : "another account"; + ipc_validation_detail_set("owner is %s, %s required", owner_class, + require_current_user ? "the exact user" : "a trusted identity"); + } if (descriptor) { (void)LocalFree(descriptor); } @@ -3899,6 +3959,9 @@ static bool win_file_acl_secure(win_security_t *security, HANDLE file, DWORD mut size_t sid_capacity = (size_t)header->AceSize - sid_offset; bool creator_owner_inherit_only = (header->AceFlags & INHERIT_ONLY_ACE) != 0U; if (!win_bounded_sid_trusted(security, sid, sid_capacity, creator_owner_inherit_only)) { + ipc_validation_detail_set( + "DACL entry %lu grants mutation rights 0x%08lx to an untrusted identity", + (unsigned long)index, (unsigned long)(ace->Mask & mutation)); secure = false; } } @@ -3968,6 +4031,11 @@ static bool win_runtime_directory_secure(const wchar_t *runtime_dir) { PROTECTED_DACL_SECURITY_INFORMATION, owner_exact ? NULL : security.user_sid, NULL, security.acl, NULL); } + if (valid_handle && owner_ok && secure_result != ERROR_SUCCESS) { + ipc_validation_detail_set("owner/DACL repair failed (status %lu%s)", + (unsigned long)secure_result, + can_write_owner ? "" : ", WRITE_OWNER unavailable"); + } bool final_private = secure_result == ERROR_SUCCESS && win_file_security_secure(&security, directory, true, win_private_mutation_rights()); @@ -4055,6 +4123,16 @@ static bool win_private_directory_tree_secure(const wchar_t *directory_path) { * win_runtime_directory_secure(), which may replace its DACL. */ if (ok && i < length) { ok = win_directory_component_secure(&security, path); + if (!ok) { + /* Name the ancestry component while it is NUL-terminated + * at this walk position; the helper set the inner rule. */ + char inner[384]; + (void)snprintf(inner, sizeof(inner), "%s", ipc_validation_detail_buffer); + char *component_utf8 = wide_to_utf8(path); + ipc_validation_detail_set( + "%s: %s", component_utf8 ? component_utf8 : "", inner); + free(component_utf8); + } } } path[i] = saved; @@ -4069,6 +4147,7 @@ static bool win_private_directory_tree_secure(const wchar_t *directory_path) { } bool cbm_daemon_ipc_private_directory_secure(const char *directory_path) { + ipc_validation_detail_set("%s", ""); if (!directory_path || !directory_path[0]) { return false; } @@ -5142,6 +5221,7 @@ static bool win_pipe_server_is_current_user(HANDLE pipe) { : NULL; ULONG process_id = 0; if (!get_server_pid || !get_server_pid(pipe, &process_id) || process_id == 0) { + cbm_log_warn("daemon.client.server_identity", "step", "server_pid_query"); return false; } HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, process_id); @@ -5149,6 +5229,14 @@ static bool win_pipe_server_is_current_user(HANDLE pipe) { process = OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, process_id); } if (!process) { + /* A pipe whose server process cannot be opened is usually a dead + * server whose PID Windows already reaped or reused. */ + char pid_text[16]; + (void)snprintf(pid_text, sizeof(pid_text), "%lu", (unsigned long)process_id); + char error_text[16]; + (void)snprintf(error_text, sizeof(error_text), "%lu", (unsigned long)GetLastError()); + cbm_log_warn("daemon.client.server_identity", "step", "process_open", "pid", pid_text, + "error", error_text); return false; } win_security_t security; diff --git a/src/daemon/ipc.h b/src/daemon/ipc.h index 10df02dda..05d68faf2 100644 --- a/src/daemon/ipc.h +++ b/src/daemon/ipc.h @@ -87,6 +87,11 @@ void cbm_daemon_ipc_listener_close(cbm_daemon_ipc_listener_t *listener); * cache root as well as daemon-private artifacts. */ bool cbm_daemon_ipc_private_directory_secure(const char *directory_path); +/* Human-readable reason for this process's most recent private-namespace + * validation refusal (empty string when none). Diagnostic only — callers + * append it to their error messages; policy decisions never read it. */ +const char *cbm_daemon_ipc_validation_detail(void); + /* Create/validate an owner-only directory and securely open one regular * owner-only append log within it. User-controlled path components may not be * symlinks, junctions, or other reparse points; trusted root-owned macOS /tmp diff --git a/src/main.c b/src/main.c index 4f3599adf..d63bb7d93 100644 --- a/src/main.c +++ b/src/main.c @@ -1538,10 +1538,12 @@ int main(int argc, char **argv) { } main_build_identity_status_t identity_status = main_build_identity(&identity); if (identity_status != MAIN_BUILD_IDENTITY_OK) { + const char *validation_detail = cbm_daemon_ipc_validation_detail(); (void)fprintf(stderr, "codebase-memory-mcp: exact executable identity could not be verified " - "(%s)\n", - main_build_identity_status_name(identity_status)); + "(%s)%s%s\n", + main_build_identity_status_name(identity_status), + validation_detail[0] ? " - " : "", validation_detail); return role == CBM_DAEMON_PROCESS_HOOK_CLIENT ? EXIT_SUCCESS : EXIT_FAILURE; } cbm_http_server_set_binary_path(executable_path); From ee6160962f2e0f1b2a2c9357a07fcd1dc839e7e9 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 19:15:30 +0200 Subject: [PATCH 22/23] test(index-supervisor): calibrate worker deadlines to real startup cost The worker-log dumps settled the CI macOS failures: workers were alive with created-but-EMPTY logs when the 3 s readiness / 5 s terminal deadlines expired - busy in startup, not hung and not refused. Worker startup re-hashes the entire executable image for the exact-build fingerprint before its first write; for the ASan test-runner that is a multi-hundred-MB read+SHA-256, which the 3-core CI runner VMs with cold IO cannot finish inside the old budgets, while a warm 18-thread dev machine finishes in well under a second. Raise the fixture budgets to 15 s readiness / 20 s terminal. These are hang guards, not benchmarks: a wedged worker still fails loudly, and the failure path now dumps the (empty) worker log as proof either way. Signed-off-by: Martin Vogel --- tests/test_index_supervisor.c | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/test_index_supervisor.c b/tests/test_index_supervisor.c index 259c4f9c5..0f2f28a5a 100644 --- a/tests/test_index_supervisor.c +++ b/tests/test_index_supervisor.c @@ -19,7 +19,16 @@ enum { INDEX_SUPERVISOR_TEST_PATH_CAP = 1024, - INDEX_SUPERVISOR_TEST_TERMINAL_MS = 5000, + /* Worker startup re-hashes the entire executable image for the + * exact-build fingerprint before its first write. For the ASan + * test-runner that is a multi-hundred-MB read+SHA-256, which on the + * 3-core CI runner VMs with cold IO takes several seconds — the CI + * macOS legs showed workers alive with created-but-EMPTY logs at the + * old 5 s/3 s deadlines. These are hang guards, not benchmarks: a + * genuinely wedged worker still fails loudly here, and the failure + * dumps the (empty) worker log as proof. */ + INDEX_SUPERVISOR_TEST_TERMINAL_MS = 20000, + INDEX_SUPERVISOR_TEST_READY_MS = 15000, INDEX_SUPERVISOR_TEST_BACKLOG_LINES = 1024, }; @@ -47,7 +56,7 @@ static bool index_supervisor_test_poll_terminal(cbm_index_worker_handle_t *handl static bool index_supervisor_test_wait_file(cbm_index_worker_handle_t *handle, const char *path, char *out, size_t out_size) { - uint64_t deadline = cbm_now_ms() + 3000; + uint64_t deadline = cbm_now_ms() + INDEX_SUPERVISOR_TEST_READY_MS; do { FILE *file = cbm_fopen(path, "rb"); if (file) { @@ -421,8 +430,10 @@ TEST(index_supervisor_async_jobs_are_isolated_cancellable_and_terminal_cached) { strcmp(getenv("CBM_INDEX_QUARANTINE_FILE"), "parent-quarantine") == 0; bool worker_logs_ready = - index_supervisor_test_wait_file_text(log_a, "async worker hang-tree probe", 3000) && - index_supervisor_test_wait_file_text(log_b, "async worker hang-tree probe", 3000); + index_supervisor_test_wait_file_text(log_a, "async worker hang-tree probe", + INDEX_SUPERVISOR_TEST_READY_MS) && + index_supervisor_test_wait_file_text(log_b, "async worker hang-tree probe", + INDEX_SUPERVISOR_TEST_READY_MS); bool callback_lines_injected = worker_logs_ready && index_supervisor_test_append_log(log_a, "request-a-only\n") && index_supervisor_test_append_log(log_b, "request-b-only\n"); @@ -652,8 +663,9 @@ TEST(index_supervisor_drains_terminal_backlog_into_request_progress_callback) { if (handle) { (void)snprintf(log_path, sizeof(log_path), "%s", cbm_index_worker_log_path(handle)); } - bool worker_logged = log_path[0] && index_supervisor_test_wait_file_text( - log_path, "async worker clean probe", 3000); + bool worker_logged = + log_path[0] && index_supervisor_test_wait_file_text(log_path, "async worker clean probe", + INDEX_SUPERVISOR_TEST_READY_MS); if (worker_logged) { /* Seeing the flushed probe places the child immediately before _Exit. * Give it time to become waitable without polling: the regression is a From 1fbfb1f69c12699b7854d28eaf8e746cd0afb4fe Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 18 Jul 2026 19:37:02 +0200 Subject: [PATCH 23/23] test(daemon): calibrate frontend and cli-quiesce budgets for starved runners Same class as the index-supervisor calibration, proven by the serial tail: the daemon_frontend backpressure/EOF tests and the cli forced- install quiesce child failed on the 4-core ubuntu leg even running alone on a quiet machine. Their 2 s request/cleanup/acquire budgets are below real startup cost there (identity capture, frontend spawn, lock acquisition on cold IO). Raised to 10-30 s; still hang guards - a wedged run fails loudly. Signed-off-by: Martin Vogel --- tests/test_cli.c | 2 +- tests/test_daemon_frontend.c | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/tests/test_cli.c b/tests/test_cli.c index f814ff944..8646be412 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -758,7 +758,7 @@ TEST(cli_activation_quiesce_does_not_wait_on_bootstrap_startup) { bool setup = endpoint && manager && cbm_daemon_runtime_process_build_fingerprint((uint64_t)getpid(), fingerprint) && - cbm_version_cohort_acquire(manager, &identity, cbm_now_ms() + 2000U, &lease, + cbm_version_cohort_acquire(manager, &identity, cbm_now_ms() + 15000U, &lease, &conflict) == CBM_VERSION_COHORT_OK && cbm_daemon_ipc_startup_lock_try_acquire(endpoint, &startup) == 1 && startup; char ready = setup ? 'R' : 'E'; diff --git a/tests/test_daemon_frontend.c b/tests/test_daemon_frontend.c index 2e3f35fac..02d4aa326 100644 --- a/tests/test_daemon_frontend.c +++ b/tests/test_daemon_frontend.c @@ -65,13 +65,16 @@ static const char FRONTEND_TEST_CACHE[] = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; enum { - FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS = 2000, + /* Hang guards, not benchmarks: starved 3-4-core CI runners need several + * seconds for frontend/daemon startup paths that finish instantly on a + * dev machine (see the index-supervisor calibration). */ + FRONTEND_EOF_TEST_REQUEST_TIMEOUT_MS = 10000, FRONTEND_EOF_TEST_CATASTROPHIC_TIMEOUT_S = 30, FRONTEND_BACKPRESSURE_MESSAGE_BYTES = 2 * 1024 * 1024, FRONTEND_BACKPRESSURE_FRONTEND_TIMEOUT_S = 12, FRONTEND_BACKPRESSURE_DAEMON_TIMEOUT_S = 20, - FRONTEND_BACKPRESSURE_RUNTIME_TIMEOUT_MS = 15000, - FRONTEND_BACKPRESSURE_CLEANUP_TIMEOUT_MS = 2000, + FRONTEND_BACKPRESSURE_RUNTIME_TIMEOUT_MS = 30000, + FRONTEND_BACKPRESSURE_CLEANUP_TIMEOUT_MS = 10000, /* The production queue is deliberately bounded below this count. Keep the * regression black-box: it must remain valid if the exact capacity changes * while still proving that overload cannot hide an already-pending EOF. */