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..c635ebd4a 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,27 @@ 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 + 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} 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 @@ -232,35 +243,51 @@ jobs: - name: Smoke test shell: msys2 {0} - run: scripts/smoke-test.sh ./codebase-memory-mcp.exe + run: | + 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/" + 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 + 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-install.sh ./codebase-memory-mcp.exe + scripts/security-strings.sh ./codebase-memory-mcp.payload.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 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..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' || '' }} @@ -162,6 +193,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..797a335bd 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -114,9 +114,19 @@ 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" + # MSYS2 /tmp is intentionally shared. The launcher correctly rejects + # 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" + 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: # The one required context (besides dco) — fails unless every PR stage diff --git a/Makefile.cbm b/Makefile.cbm index d54b01460..831781370 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 = \ @@ -156,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) @@ -181,6 +192,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 +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/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 = \ @@ -304,7 +333,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 +349,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 +377,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 +400,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 +446,9 @@ 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 \ + tests/test_windows_launcher_state.c TEST_MEM_SRCS = tests/test_mem.c @@ -485,7 +530,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 +558,7 @@ PP_OBJ_TSAN = $(BUILD_DIR)/tsan_preprocessor.o # ── Targets ────────────────────────────────────────────────────── -.PHONY: test test-par 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-par 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) @@ -531,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) @@ -543,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) @@ -682,6 +727,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) @@ -693,7 +744,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) @@ -718,6 +769,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) \ @@ -725,9 +796,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 @@ -743,7 +817,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) \ @@ -769,10 +843,10 @@ 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) + $(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 bb4919ed5..74160bc90 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,28 @@ 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. + +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`): + +| 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. + +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 +139,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 @@ -252,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 @@ -521,19 +537,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 @@ -607,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 @@ -706,6 +735,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/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/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/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..0cae9a24a 100644 --- a/install.ps1 +++ b/install.ps1 @@ -9,18 +9,86 @@ $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" +$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" } -# 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 +141,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,65 +149,144 @@ 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" + } + if ($null -ne $expected -and $expected -ne $digest) { + throw "conflicting SHA-256 digests for $Archive" } - Write-Host "Checksum verified." + $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 +} + +# 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 +# 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 } -# 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 complete launcher/payload path before it asks all coordinated CBM +# processes to stop. +try { + $candidateVersion = & $DownloadedLauncher --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" } +# 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 + 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 +294,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..5677a7909 100644 --- a/pkg/go/cmd/codebase-memory-mcp/main.go +++ b/pkg/go/cmd/codebase-memory-mcp/main.go @@ -13,55 +13,141 @@ import ( "archive/tar" "archive/zip" "compress/gzip" + "context" + "crypto/rand" "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" + 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 + connectTimeout = 15 * time.Second + 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 @@ -117,7 +203,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,18 +224,33 @@ 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" + 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" { @@ -153,29 +258,45 @@ 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) } } + 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 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 + } 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 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) } - 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 +304,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 +340,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 +360,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 @@ -270,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 { @@ -278,66 +436,500 @@ 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 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 { + 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 fileSHA256(path string) ([sha256.Size]byte, error) { + var digest [sha256.Size]byte + source, err := os.Open(path) + if err != nil { + return digest, err } - return fmt.Errorf("%s not found in archive", targetFile) + 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 copyFile(src, dst string) error { +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 { 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 { + identical, compareErr := filesEqualSHA256(staged, dst) + if compareErr == nil && identical { + return verifyCandidate(dst) + } + return err + } + return nil +} + +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(bin string, args []string) error { +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 25bb6a80f..75af5c6f4 100644 --- a/pkg/npm/install.js +++ b/pkg/npm/install.js @@ -9,10 +9,35 @@ 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; +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) { @@ -31,36 +56,467 @@ 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 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) => { - 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; + } + 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, { 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}`); + } + 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 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}`); } - if (res.statusCode !== 200) { - return reject(new Error(`HTTP ${res.statusCode} for ${u}`)); + 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 */ } } - const file = fs.createWriteStream(dest); - res.pipe(file); - file.on('finish', () => file.close(resolve)); - file.on('error', reject); - }).on('error', reject); + 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 */ } + } } - follow(url, 0); + } 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'", + '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', [ + '-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', encoded, + ], { + env: { + ...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, }); } @@ -69,11 +525,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 +543,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 */ } } @@ -96,11 +552,33 @@ 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)) { - return; // already installed, nothing to do + 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 (_) { + // Fall through and atomically replace the invalid binary. + } } fs.mkdirSync(BIN_DIR, { recursive: true }); @@ -125,39 +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 { - execFileSync('powershell', [ - '-NoProfile', '-Command', - `Expand-Archive -Path '${tmpArchive}' -DestinationPath '${tmpDir}' -Force`, - ]); + 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}`); + 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); } - if (!fs.existsSync(extracted)) { - throw new Error(`Binary not found after extraction at ${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)); } - fs.copyFileSync(extracted, binPath); - fs.chmodSync(binPath, 0o755); + 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 d192f1352..6495a0829 100644 --- a/pkg/pypi/src/codebase_memory_mcp/_cli.py +++ b/pkg/pypi/src/codebase_memory_mcp/_cli.py @@ -1,33 +1,147 @@ -"""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 -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" +_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 # 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 _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: @@ -57,53 +171,126 @@ 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: """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: @@ -145,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() @@ -175,8 +394,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" @@ -185,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: @@ -192,16 +426,79 @@ 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") - - shutil.copy2(extracted, dest) - current = dest.stat().st_mode - dest.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: + 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_paths = {} + try: + 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: + for staged_path in staged_paths.values(): + try: + staged_path.unlink() + except OSError: + pass return dest @@ -210,18 +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: - import subprocess 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/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 } 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/security-fuzz.sh b/scripts/security-fuzz.sh index fffd1aefe..52ed25c54 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)) @@ -63,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 45 --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 ---" @@ -108,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 da1f535af..a1b6c3758 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -18,7 +18,44 @@ if [ -n "$SMOKE_MODE" ] && [ "$SMOKE_MODE" != "--agent-config-only" ]; then exit 2 fi REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")/.." && pwd)" -TMPDIR=$(mktemp -d) + +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. +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=$(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 @@ -26,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 @@ -476,7 +518,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 @@ -539,30 +581,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 45 --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) @@ -596,7 +623,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 @@ -608,15 +635,15 @@ 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"} {"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 @@ -661,29 +688,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) +MCP_TOOL_OUTPUT=$(smoke_mktemp_file) -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" @@ -691,8 +717,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} @@ -702,7 +728,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" @@ -712,12 +738,12 @@ 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 ===" -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" \ @@ -751,25 +777,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 @@ -799,12 +849,12 @@ 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" # 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 @@ -816,7 +866,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" @@ -853,17 +903,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 +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 + 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" @@ -871,14 +919,14 @@ 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" -rm -f "$MCP_SC_INPUT" "$MCP_SC_OUTPUT" +rm -f "$MCP_SC_OUTPUT" fi @@ -888,7 +936,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" @@ -956,7 +1004,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" @@ -2089,6 +2137,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" \ @@ -2096,7 +2148,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 " @@ -2444,7 +2496,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 @@ -2455,11 +2507,15 @@ 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" -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 @@ -2474,16 +2530,16 @@ 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" -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 @@ -2491,11 +2547,15 @@ 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" -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" @@ -2522,9 +2582,9 @@ fi echo "" echo "=== Phase 10: binary security E2E ===" -SECURITY_DIR=$(mktemp -d) +SECURITY_DIR=$(smoke_mktemp_dir) 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 @@ -2617,7 +2677,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=$! @@ -2643,11 +2703,27 @@ echo "" echo "=== Phase 14: update + uninstall E2E ===" if [ -n "${SMOKE_DOWNLOAD_URL:-}" ]; then - # ── 14a-f: Real update command against local HTTP server ── - UPDATE_HOME=$(mktemp -d) + # ── 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=$(smoke_mktemp_dir) 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" @@ -2657,6 +2733,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" @@ -2665,15 +2755,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 @@ -2697,13 +2791,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 @@ -2729,11 +2823,11 @@ 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" - 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" @@ -2757,7 +2851,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') @@ -2848,7 +2942,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 @@ -2856,6 +2959,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 @@ -2891,8 +2999,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" @@ -2955,8 +3063,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 @@ -2976,7 +3084,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" @@ -3014,7 +3124,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 @@ -3068,7 +3178,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-windows.ps1 b/scripts/test-windows.ps1 index b1e12ff5d..06d0f79b5 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: @@ -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, @@ -87,17 +95,100 @@ 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 } +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" | 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 +} + +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" | 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 +} + $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 + +$previousTemp = $env:TEMP +$previousTmp = $env:TMP +$previousTmpDir = $env:TMPDIR +$guardRoot = $null +try { + $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 + + # 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" + $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 -$env:PYTHONUTF8 = "1" # encode argv/stdio as UTF-8 -$env:CBM_INDEX_SUPERVISOR = "0" # in-process indexing (see .DESCRIPTION) + # 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 + $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 @@ -108,7 +199,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,11 +214,15 @@ $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 $guardBin $guardPayload $abiMismatchLauncher + } else { + & $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 { @@ -139,7 +235,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 @@ -151,6 +247,14 @@ if (-not $GuardsOnly) { } } } +} finally { + $env:TEMP = $previousTemp + $env:TMP = $previousTmp + $env:TMPDIR = $previousTmpDir + if ($guardRoot) { + Remove-Item -LiteralPath $guardRoot -Recurse -Force -ErrorAction SilentlyContinue + } +} Write-Host "" if ($guardSkips.Count -gt 0) { diff --git a/scripts/test.sh b/scripts/test.sh index a2d7c56a0..93a2b745b 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..aecdf7d85 --- /dev/null +++ b/scripts/test_mcp_interactive.py @@ -0,0 +1,412 @@ +#!/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 os +import queue +import shutil +import subprocess +import sys +import threading +import time +from typing import Any, BinaryIO, Optional + + +INITIALIZE_PARAMS = { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "interactive-smoke", "version": "1.0"}, +} + + +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]]", + 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 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", name) + 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") + 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, + accept_tool_error: bool = False, +) -> 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 "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 + 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: {rendered}" + ) + 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, + accept_tool_error: bool = False, +) -> dict[str, Any]: + send( + process, + { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + }, + ) + 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]]", + 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": {}}, + ) + 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, + "tools/call", + { + "name": "index_repository", + "arguments": {"repo_path": repo_path, "mode": "fast"}, + }, + 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": {"project": project, "name_pattern": "compute"}, + }, + timeout, + ) + return + request( + process, + responses, + 3, + "tools/call", + { + "name": "search_code", + "arguments": { + "project": project, + "pattern": "compute", + "mode": "compact", + "limit": 3, + }, + }, + timeout, + ) + 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 + ) + 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, + responses, + 5, + "tools/call", + { + "name": "get_code_snippet", + "arguments": {"project": project, "qualified_name": qualified_name}, + }, + 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=("initialize", "invalid-index", "roundtrip", "advanced"), + required=True, + ) + parser.add_argument("--repo-path", required=True) + parser.add_argument("--response-timeout", type=float, default=45.0) + parser.add_argument("--exit-timeout", type=float, default=15.0) + args = parser.parse_args() + + 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() + 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") + 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) + 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", + ) + if conflicts: + print("--- daemon-conflicts.ndjson (tail) ---", file=sys.stderr) + print( + conflicts, + file=sys.stderr, + end="" if conflicts.endswith("\n") else "\n", + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/vendored-checksums.txt b/scripts/vendored-checksums.txt index 5009399d3..c72c13f75 100644 --- a/scripts/vendored-checksums.txt +++ b/scripts/vendored-checksums.txt @@ -40,14 +40,13 @@ 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 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..09f09583e --- /dev/null +++ b/src/cli/activation_transaction.c @@ -0,0 +1,1926 @@ +/* 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..028b80a66 --- /dev/null +++ b/src/cli/activation_transaction.h @@ -0,0 +1,88 @@ +/* + * 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..7641a1228 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -7,15 +7,23 @@ #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 "cli/windows_launcher_state.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 +#include "mcp/index_supervisor.h" /* CLI buffer size constants. */ enum { @@ -35,6 +43,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 +75,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 +90,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 +108,13 @@ 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 // MAX_WBITS +#include +#include +#include // MAX_WBITS +#ifdef _WIN32 +#include +#include "foundation/win_utf8.h" +#endif /* yyjson for JSON read-modify-write */ #include "yyjson/yyjson.h" @@ -120,6 +138,740 @@ 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; +} + +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."; +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]; + 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; + 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; + 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; + +#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); + 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); + 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, + }; + 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); + 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; + 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) { + 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 +1165,195 @@ 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 + +#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); +#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"); + } +} +#endif + +#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; + } + 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); + if (!target_path || chmod(target_path, (mode_t)mode) != 0) { + (void)cbm_activation_transaction_rollback(transaction); + return CLI_ERR; } - if (cbm_copy_file(src, dst) != 0) { +#else + (void)mode; +#endif + return CLI_OK; +} + +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); +} + #ifndef _WIN32 - (void)chmod(dst, CLI_OCTAL_PERM); +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); +} + +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; +} #endif - return 0; + +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); } -/* 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) { +/* 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; + } + 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; } - - /* 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 - } + if (cli_activation_transaction_commit_validated(transaction, &validator, CLI_OCTAL_PERM) != + CLI_OK) { + (void)cli_activation_transaction_abort(&transaction); + return CLI_ERR; } + return cli_activation_transaction_finalize_close(&transaction); +} -#ifndef _WIN32 - int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, (mode_t)mode); - if (fd < 0) { +/* 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; } - FILE *f = fdopen(fd, "wb"); - if (!f) { - close(fd); + 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; } -#else - (void)mode; - FILE *f = fopen(path, "wb"); - if (!f) { + if (cli_activation_transaction_commit_validated(transaction, &validator, mode) != CLI_OK) { + (void)cli_activation_transaction_abort(&transaction); return CLI_ERR; } -#endif - - size_t written = fwrite(data, CLI_ELEM_SIZE, (size_t)len, f); - (void)fclose(f); - return written == (size_t)len ? 0 : CLI_ERR; + return cli_activation_transaction_finalize_close(&transaction); } /* ── Skill file content (embedded) ────────────────────────────── */ @@ -4678,6 +5550,420 @@ 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; +} + +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 ────────────────────────────────────────── */ /* Decompress gzip data into a malloc'd buffer. Returns NULL on failure. @@ -4948,18 +6234,270 @@ 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) { - 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 +6561,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 +6611,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 +6627,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 +6700,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) { + 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 +6840,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 @@ -5336,19 +6873,211 @@ int cbm_cli_sha256_file(const char *path, char *out, size_t out_size) { out[i * 2] = hex[digest[i] >> 4]; out[i * 2 + 1] = hex[digest[i] & 0x0f]; } - out[SHA256_HEX_LEN] = '\0'; - return 0; + out[SHA256_HEX_LEN] = '\0'; + return 0; +} + +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 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)'*'; +} + +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] == '/'); } -/* ── Download helper (shell-free curl via exec) ───────────────── */ +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 *argv[] = {"curl", "-fSL", "--progress-bar", "-o", dest, url, NULL}; + 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 *argv[] = {"curl", "-fsSL", "-o", dest, url, NULL}; + 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); } @@ -5357,99 +7086,79 @@ 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[] = {"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"); } - 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"); + if (checksum_url_length <= 0 || (size_t)checksum_url_length >= sizeof(checksum_url)) { cbm_unlink(checksum_file); return CLI_ERR; } - - FILE *fp = fopen(checksum_file, "r"); - cbm_unlink(checksum_file); - if (!fp) { + 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"); + cbm_unlink(checksum_file); 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; } @@ -6192,7 +7901,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 @@ -7370,7 +9087,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,15 +9115,26 @@ 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 ──────────────────────────────────────── */ +#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'; @@ -7428,12 +9160,14 @@ 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. */ -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 +9175,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 +9278,747 @@ 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); +} + +#ifndef _WIN32 +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; +} +#endif + +#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; - } - 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 +10028,32 @@ 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 +10065,223 @@ 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); + + 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; } - } - 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); - return CLI_TRUE; - } - printf("Installed binary -> %s\n\n", bin_target); + 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; } - } - - /* 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. */ + 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 (!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 (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 - - /* 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); + 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; + } + binary_validator = final_validator; + } + } else { + binary_validator = staged_validator; + has_binary_validator = true; + } + 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 + 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; +#endif } /* ── Subcommand: uninstall ────────────────────────────────────── */ @@ -8787,15 +11383,240 @@ static void uninstall_additional_agents(const cbm_detected_agents_t *agents, con } } +#ifndef _WIN32 +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; +} +#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); 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; } } +#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"); @@ -8804,49 +11625,83 @@ 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); - 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); -#else - 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); + 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) { + 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; } - printf("Removed %s\n", bin_path); + } + 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; + 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); + } + 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"); } return g_agent_uninstall_errors == 0 ? 0 : CLI_TRUE; +#endif } /* ── Subcommand: update ───────────────────────────────────────── */ @@ -8858,8 +11713,349 @@ typedef struct { const char *tmp_archive; const char *ext; const char *bin_dest; + const char *home; + 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; + 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; +} +#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; @@ -8868,9 +12064,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 +12082,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 +12106,101 @@ 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 + 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; +#endif } /* Build the download URL for the update command. */ @@ -8924,8 +12221,13 @@ 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 +12243,35 @@ 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 +12295,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,15 +12424,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]); + 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). */ @@ -9119,7 +12459,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; } @@ -9148,7 +12489,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"); @@ -9157,38 +12504,34 @@ 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); - 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) { +#ifdef _WIN32 + free(managed_target); +#endif 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,8 +12540,12 @@ 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; +#ifdef _WIN32 + free(managed_target); +#endif return 0; } diff --git a/src/cli/cli.h b/src/cli/cli.h index f45325313..661871f2c 100644 --- a/src/cli/cli.h +++ b/src/cli/cli.h @@ -11,6 +11,9 @@ #include #include +#include + +typedef struct cbm_mcp_server cbm_mcp_server_t; /* ── Version ──────────────────────────────────────────────────── */ @@ -36,6 +39,16 @@ 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); + +/* 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"). @@ -327,6 +340,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/). @@ -347,7 +373,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. */ @@ -368,6 +395,58 @@ 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); + +/* 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) ─────────────────────────── */ /* install: copy binary, install skills, install editor MCP configs, ensure PATH. @@ -400,6 +479,24 @@ 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 7b172b1fe..404d71eb4 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; @@ -671,15 +671,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). * Declared in cli.h so the Windows drive-letter handling (#618) has direct @@ -901,7 +892,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(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; @@ -922,6 +914,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 @@ -929,6 +933,10 @@ 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); } @@ -1129,8 +1137,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(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; } @@ -1140,12 +1148,14 @@ 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"; @@ -1223,7 +1233,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(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; } @@ -1242,7 +1252,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(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; } @@ -1302,53 +1312,31 @@ 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"); @@ -1358,76 +1346,94 @@ 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..7f1e69425 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,141 @@ #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 +173,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 +339,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..7301a3357 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,16 @@ #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/cli/windows_launcher_state.c b/src/cli/windows_launcher_state.c new file mode 100644 index 000000000..0c5746e45 --- /dev/null +++ b/src/cli/windows_launcher_state.c @@ -0,0 +1,1873 @@ +#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 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; + 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; + 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_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':' || + (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; + 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_for_mutation(component, mutation); + 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 | 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_for_mutation(ancestor, mutation); + 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 98ffb4c97..b8d689614 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" @@ -1020,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"); @@ -1042,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); } @@ -2343,7 +2344,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) { @@ -2353,9 +2354,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..845c5c212 --- /dev/null +++ b/src/daemon/application.c @@ -0,0 +1,3376 @@ +/* + * 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 "foundation/subprocess.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 +#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, + 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; +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; + 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_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; +}; + +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_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; +}; + +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 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->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); + } +} + +/* 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->hook_event || + session->hook_dialect || session->auto_index_subscribed) { + 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); + + /* 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)) { + 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); + } + return; + } + if (session->watch) { + 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); + } + 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); + } + return; + } + watch->subscribers = 1; + watch->next = application->watches; + application->watches = watch; + session->watch = watch; + cbm_watcher_watch(application->watcher, project, root); +} + +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) { + 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 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")) { + 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); +} + +/* 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) { + 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; + 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); +} + +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; +} + +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; +} + +/* 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( + 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 (!application_index_args_equal(job->args_json, args_json)) { + *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 (application_job_thread_create(&job->thread, job) == 0) { + job->thread_started = true; + } else { + /* 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; +} + +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; + } +} + +/* 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_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; +} + +/* 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) { + *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; + } + 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) { + 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 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); + + /* 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; + cbm_mcp_server_request_scope_end(session->mcp); + 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; + } + 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); + *response_out = NULL; + *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) { + 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) { + 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 + * 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); + } + 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; + 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); + 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"); + } + } + } +} + +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; + } + 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) { + 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; + } + 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); + 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; + } + 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. + * 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->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); + } + 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); + 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; + cbm_mutex_lock(&application->mutex); + if (application->active_mutations != 0) { + all_done = false; + } + 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) { + all_done = false; + break; + } + } + cbm_mutex_unlock(&application->mutex); + if (all_done) { + application_jobs_reap_completed(application); + 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; + } + cbm_usleep(APPLICATION_JOB_POLL_US); + } +} + +bool cbm_daemon_application_free_with_timeout(cbm_daemon_application_t *application, + uint32_t timeout_ms) { + if (!application) { + return true; + } + 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 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_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); + 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( + 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; +} + +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 = + (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..f23dc38b6 --- /dev/null +++ b/src/daemon/application.h @@ -0,0 +1,176 @@ +/* + * 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, + * 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 + +#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; +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. */ +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 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. */ + 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); +/* 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( + 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); +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 new file mode 100644 index 000000000..81f283161 --- /dev/null +++ b/src/daemon/application_internal.h @@ -0,0 +1,22 @@ +/* + * 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); + +/* 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 new file mode 100644 index 000000000..8115572d2 --- /dev/null +++ b/src/daemon/bootstrap.c @@ -0,0 +1,965 @@ +/* + * 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 +#ifdef __APPLE__ +#include +#include +extern char **environ; +#endif +#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 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; + } + + 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; +#ifdef _WIN32 + DWORD spawn_error; +#elif defined(__APPLE__) + int spawn_error; +#endif +} 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) { + 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; + } + 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; + /* 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); + DWORD spawn_error = created ? ERROR_SUCCESS : GetLastError(); + free(application); + free(command); + if (production) { + production->spawn_error = spawn_error; + } + if (!created) { + return false; + } + (void)CloseHandle(child.hThread); + (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_SETSID | 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; +} + +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) { + 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; + } + posix_spawn_file_actions_t actions; + posix_spawnattr_t attributes; + if (!bootstrap_darwin_spawn_state_init(&actions, &attributes)) { + return false; + } + /* 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 (!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; + } + return false; + } + 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) { + bootstrap_production_context_t *production = context; +#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", + 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); +} + +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..17cdc7dec --- /dev/null +++ b/src/daemon/bootstrap.h @@ -0,0 +1,151 @@ +/* + * 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..e228e84e0 --- /dev/null +++ b/src/daemon/daemon.c @@ -0,0 +1,836 @@ +/* + * 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..cf1f0074e --- /dev/null +++ b/src/daemon/daemon.h @@ -0,0 +1,131 @@ +/* + * 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..146a1f8b3 --- /dev/null +++ b/src/daemon/frontend.c @@ -0,0 +1,647 @@ +/* + * 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/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_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, + 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 input_closed; + bool stopping; + bool worker_done; + 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; + +typedef struct { + atomic_bool complete; +} frontend_join_watchdog_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_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_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) { + 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) && + cbm_now_ms() < deadline) { + cbm_usleep(FRONTEND_MAINTENANCE_POLL_MS * 1000U); + } + if (atomic_load_explicit(&monitor->stopping, memory_order_acquire)) { + return NULL; + } + _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. */ + _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_terminal(state->cohort_manager); + if (presence == CBM_VERSION_COHORT_MAINTENANCE_ABSENT) { + return; + } + /* Do not fclose stdin across threads. Process exit closes the authenticated + * 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) { + 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 || (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) { + 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_IDLE_WAIT_US); + continue; + } + uint8_t *response = NULL; + uint32_t response_length = 0; + bool failed = false; + if (item.cancelled) { + 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); + if (status == CBM_DAEMON_RUNTIME_APPLICATION_CANCELLED) { + 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) { + failed = !frontend_write_response(state->out, response, response_length, + item.content_length_framed); + } + } + 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. 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) { + 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; + } + } + 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); + 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) { + 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); +} + +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; + } + frontend_state_t state = { + .client = client, + .cohort_manager = cohort_manager, + .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); + 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); + if (read_status <= 0) { + result = read_status < 0 ? -1 : 0; + clean_eof = read_status == 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 (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); + } + 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); + } 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..d114c8f9c --- /dev/null +++ b/src/daemon/frontend.h @@ -0,0 +1,56 @@ +/* + * 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. 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 new file mode 100644 index 000000000..c19f5519e --- /dev/null +++ b/src/daemon/host.c @@ -0,0 +1,925 @@ +/* + * 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_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, + 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 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; + 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_loaded; + bool http_config_enabled; + bool http_assets_available; + int http_config_port; + 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; +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 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 + * 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_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) { + ops->server_stop(ops->context, host->http); + } + if (host->http_started) { + (void)ops->thread_join(ops->context, &host->http_thread); + host->http_started = false; + } + ops->server_free(ops->context, host->http); + host->http = NULL; +} + +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; + } + 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 (host->http_started || host->http) { + return; + } + 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; + } + 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 { + 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) { + host->http_ops->server_stop(host->http_ops->context, host->http); + } + if (host->watcher) { + cbm_watcher_stop(host->watcher); + } +} + +static void host_background_join(host_state_t *host) { + if (host->http_started) { + (void)host->http_ops->thread_join(host->http_ops->context, &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); + 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); + 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) { + 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(); + 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); + 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; +} + +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) { + return false; + } + host->watcher_started = true; + + 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) { + 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_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)) { + 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_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; + 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) { + 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)); + 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); + 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_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_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_participant_guard_close(&participant_guard); + host_daemon_claim_close(&daemon_claim); + 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_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_participant_guard_close(&participant_guard); + host_daemon_claim_close(&daemon_claim); + 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_participant_guard_close(&participant_guard); + host_daemon_claim_close(&daemon_claim); + 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_participant_guard_close(&participant_guard); + host_daemon_claim_close(&daemon_claim); + host_cohort_close(&cohort_lease, &cohort_manager); + return -1; + } + cbm_diag_start(); + + 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"); + } + + /* 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, + * participant release, and daemon-claim release. An activation transaction + * may treat exclusive acquisition as proof that coordinated cleanup for + * this generation has completed. */ + 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/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..dfe4cb9ff --- /dev/null +++ b/src/daemon/host_internal.h @@ -0,0 +1,49 @@ +/* + * host_internal.h — Private daemon-host test seams. + */ +#ifndef CBM_DAEMON_HOST_INTERNAL_H +#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); + +/* 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); + +/* 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 new file mode 100644 index 000000000..013f4eca3 --- /dev/null +++ b/src/daemon/ipc.c @@ -0,0 +1,5941 @@ +/* + * 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, +}; + +/* 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; + } + 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 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; + } + + int fd = private_directory_tree_open(path); + if (fd < 0) { + return false; + } + + struct stat before; + bool secured = private_runtime_snapshot(fd, path, true, &before); + if (!secured) { + (void)close(fd); + return false; + } + *fd_out = fd; + *device_out = before.st_dev; + *inode_out = before.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 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); + 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 { + 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; + struct stat status; + 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 && + cbm_macos_extended_acl_fd_clear(next_fd) && + cbm_macos_extended_acl_fd_is_empty(next_fd); + } + 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; + 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) { + (void)close(current_fd); + } + return -1; + } + return current_fd; +} + +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; +} + +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_directory_tree_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) { + /* 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, + 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 *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 *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 *, + 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; + 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; + 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; + 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, 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"); + 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, 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"); + 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) || + /* 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; + } + 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 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 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); + } + return secure; +} + +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, + 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); + 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)) { + 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; + } + } + if (descriptor) { + (void)LocalFree(descriptor); + } + return secure; +} + +static bool win_file_security_secure(win_security_t *security, HANDLE file, + bool require_current_user, DWORD mutation) { + return win_file_owner_secure(security, file, require_current_user) && + win_file_acl_secure(security, file, mutation); +} + +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 | 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; + } + BY_HANDLE_FILE_INFORMATION file_info; + 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_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, + (owner_exact ? 0U : (DWORD)OWNER_SECURITY_INFORMATION) | DACL_SECURITY_INFORMATION | + 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()); + (void)CloseHandle(directory); + win_security_destroy(&security); + return valid_handle && owner_ok && final_private; +} + +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; + /* 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, mutation); + (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; + } + /* 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); + 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; + component_start = i + 1; + } + win_security_destroy(&security); + if (ok) { + ok = win_runtime_directory_secure(path); + } + free(path); + return ok; +} + +bool cbm_daemon_ipc_private_directory_secure(const char *directory_path) { + ipc_validation_detail_set("%s", ""); + 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, '\\')) { + 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_private_directory_tree_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) { + cbm_log_warn("daemon.client.server_identity", "step", "server_pid_query"); + return false; + } + HANDLE process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, process_id); + if (!process) { + 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; + bool initialized = win_security_init(&security); + HANDLE token = NULL; + bool opened = initialized && security.open_process_token(process, TOKEN_QUERY, &token) != 0; + 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); + } + 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; + 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, + 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)) { + cbm_log_warn("daemon.client.pipe_rejected", "stage", "server_identity"); + (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..05d68faf2 --- /dev/null +++ b/src/daemon/ipc.h @@ -0,0 +1,244 @@ +/* + * 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 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); + +/* 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 + * 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..4b358ef16 --- /dev/null +++ b/src/daemon/ipc_internal.h @@ -0,0 +1,99 @@ +/* + * 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..77548131f --- /dev/null +++ b/src/daemon/project_lock.c @@ -0,0 +1,189 @@ +/* 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..cc65e919f --- /dev/null +++ b/src/daemon/project_lock.h @@ -0,0 +1,40 @@ +/* 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..7f08448de --- /dev/null +++ b/src/daemon/runtime.c @@ -0,0 +1,2711 @@ +/* + * 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) { + 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) && + 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) { + 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..793c224b8 --- /dev/null +++ b/src/daemon/runtime.h @@ -0,0 +1,349 @@ +/* + * 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..284922641 --- /dev/null +++ b/src/daemon/service.c @@ -0,0 +1,1002 @@ +/* + * 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 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) && + 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_CACHE_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) && + (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"; + case CBM_DAEMON_HELLO_CACHE_CONFLICT: + return "cache_root"; + 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); + 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) { + 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; + 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; + } + 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; + 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; + } + *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..edb7d8277 --- /dev/null +++ b/src/daemon/service.h @@ -0,0 +1,78 @@ +/* + * 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; + /* 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; +} 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_CACHE_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]; + 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 + * 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..750b72f7c --- /dev/null +++ b/src/daemon/service_internal.h @@ -0,0 +1,35 @@ +/* + * 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..c120cdf41 --- /dev/null +++ b/src/daemon/version_cohort.c @@ -0,0 +1,936 @@ +/* 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_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, + 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', 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"; + +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) { + 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; +} + +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); + 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], + 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, + .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; + } + *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]; + 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, 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) { + 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); +} + +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_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) { + 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)) { + presence = CBM_VERSION_COHORT_MAINTENANCE_IO; + } + 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) { + 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..8b742f5f6 --- /dev/null +++ b/src/daemon/version_cohort.h @@ -0,0 +1,156 @@ +/* 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); + +/* 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 + * 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/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 e59accf7e..e1bcff2f5 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,27 @@ 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.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 54c9e3c67..e62db8574 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; } @@ -857,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_COPY_ALLOWED | 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); @@ -871,23 +898,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..11439a0d2 --- /dev/null +++ b/src/foundation/lock_registry.c @@ -0,0 +1,1090 @@ +/* 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; + if (!hook) { + lock_registry_unlock(registry); + return; + } + void *context = registry->stage_context; + lock_registry_unlock(registry); + 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..ae5b6e04d --- /dev/null +++ b/src/foundation/lock_registry.h @@ -0,0 +1,68 @@ +/* + * 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..0b35712d0 --- /dev/null +++ b/src/foundation/lock_registry_internal.h @@ -0,0 +1,62 @@ +#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..6467d5b85 --- /dev/null +++ b/src/foundation/macos_acl.c @@ -0,0 +1,104 @@ +/* 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_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; + } + 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_is_deny_only(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..6277decc8 --- /dev/null +++ b/src/foundation/macos_acl.h @@ -0,0 +1,21 @@ +/* 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, 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); + +#endif diff --git a/src/foundation/mem.c b/src/foundation/mem.c index 858382ce1..042fbd953 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,18 @@ 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 +222,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 +235,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 +249,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..58856f518 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,10 @@ 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..9d69b0c99 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; } @@ -303,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 @@ -315,53 +384,61 @@ 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; } /* ── 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 +460,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 +493,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,11 +515,16 @@ const char *cbm_app_local_dir(void) { /* ── Cache directory ────────────────────────── */ const char *cbm_resolve_cache_dir(void) { - static 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; } @@ -450,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/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..64f026c1c --- /dev/null +++ b/src/foundation/private_file_lock.c @@ -0,0 +1,1627 @@ +/* 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_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, + 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_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; + 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_owner, + private_set_security_descriptor_owner_fn, "SetSecurityDescriptorOwner"); + 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) || + /* 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); + 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..8801d9b1a --- /dev/null +++ b/src/foundation/private_file_lock_internal.h @@ -0,0 +1,95 @@ +/* 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..6c2c9ad43 100644 --- a/src/foundation/subprocess.c +++ b/src/foundation/subprocess.c @@ -5,10 +5,14 @@ */ #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 +#include +#include #include #ifdef _WIN32 @@ -20,6 +24,7 @@ #include #include #include +#include #include #include #endif @@ -28,6 +33,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 +69,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; } @@ -87,32 +96,76 @@ 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 = cbm_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 result; } - FILE *lf = fopen(log_file, "r"); + struct stat log_status; + if (fstat(log_fd, &log_status) != 0 || !S_ISREG(log_status.st_mode)) { + (void)close(log_fd); + return result; + } + FILE *lf = fdopen(log_fd, "r"); +#endif if (!lf) { - return false; +#ifndef _WIN32 + (void)close(log_fd); +#endif + return result; } - 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)) { + result.status = ferror(lf) ? CBM_TAIL_ERROR : CBM_TAIL_CAUGHT_UP; 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; + result.progressed = true; + delivered_lines++; if (line[0] && cb) { cb(line, ud); } @@ -120,19 +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) ─────── */ @@ -157,7 +228,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 +274,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 +295,820 @@ 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 }; + +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; + 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_int lifecycle; + bool timed_out; + bool termination_started; + bool force_sent; + bool root_reaped; + 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->lifecycle, CBM_SUBPROCESS_ACTIVE); + 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 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(); } - if (!ok) { - out->outcome = CBM_PROC_SPAWN_FAILED; - out->exit_code = -1; - out->term_signal = 0; - return -1; + 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) { + 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 +} - long tail_pos = 0; - uint64_t last_activity = cbm_now_ms(); - bool timed_out = false; +static bool cbm_subprocess_begin_terminal_transition(cbm_subprocess_t *process) { + int lifecycle = atomic_load_explicit(&process->lifecycle, memory_order_acquire); 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(); + if (lifecycle == CBM_SUBPROCESS_DRAINING) { + return true; } - if (w == WAIT_OBJECT_0) { - break; + if (lifecycle == CBM_SUBPROCESS_TERMINAL) { + return false; } - 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; + 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) { + 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; + 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; + return cbm_subprocess_publish_terminal(process, out, false); +} + +#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; + } - 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); + 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); + } + free(wbin); + free(wcmdline); + return -1; } - out->exit_code = (int)code; - out->term_signal = 0; - out->outcome = cbm_proc_classify(true, (int)code, 0, timed_out); + 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 (log == INVALID_HANDLE_VALUE) { + CloseHandle(nul); + CloseHandle(job); + free(wbin); + free(wcmdline); + return -1; + } + } + + 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; + } + + 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 || + (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); + } +} + +static cbm_proc_poll_t cbm_subprocess_poll_win(cbm_subprocess_t *process, cbm_proc_result_t *out) { + 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); + } 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 (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) { + 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); + } + /* 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); +} + +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); + process->pid = pid; + process->pgid = pid; + return 0; +} - 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); +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 */ +} + +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 (opts->log_file && opts->delete_log_on_exit) { - (void)unlink(opts->log_file); +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 { + process->containment_failed = true; + } +} - 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_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 { - out->exit_code = -1; - out->term_signal = 0; - out->outcome = timed_out ? CBM_PROC_HANG : CBM_PROC_KILLED; + 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) { + 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); + } 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 (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) { + 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; + } + 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 + return cbm_subprocess_poll_posix(process, out); #endif +} + +bool cbm_subprocess_request_cancel(cbm_subprocess_t *process) { + if (!process) { + return false; + } + 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->lifecycle, memory_order_acquire) != + CBM_SUBPROCESS_TERMINAL) { + 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..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 @@ -36,16 +36,25 @@ 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 - * 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 { - 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 */ @@ -53,12 +62,74 @@ 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. + * + * 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. */ +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 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 + * 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/launcher/windows_launcher.c b/src/launcher/windows_launcher.c new file mode 100644 index 000000000..cb9acd547 --- /dev/null +++ b/src/launcher/windows_launcher.c @@ -0,0 +1,1245 @@ +/* + * 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 +#include + +#ifndef PIPE_REJECT_REMOTE_CLIENTS +#define PIPE_REJECT_REMOTE_CLIENTS 0x00000008 +#endif + +#include +#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); +} + +/* 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) { + 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; +} + +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) { + 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) { + *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, DWORD mutation) { + 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; + 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); + 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; + } + 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)) { + launcher_refusal_set(L"DACL entry %lu has unsupported type 0x%02x", + (unsigned long)index, (unsigned int)header->AceType); + secure = false; + break; + } + ACCESS_ALLOWED_ACE *ace = opaque; + if ((ace->Mask & mutation) == 0U) { + 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; + } + } + if (descriptor) + (void)LocalFree(descriptor); + free(token_user); + 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, launcher_private_mutation_rights()); +} + +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); + 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)); + 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 + root_length) { + free(path); + return false; + } + *last = L'\0'; + size_t directory_length = wcslen(path); + bool valid = true; + for (size_t index = root_length; 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; + 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); + } + 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; + } + 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 01083e809..d63bb7d93 100644 --- a/src/main.c +++ b/src/main.c @@ -10,16 +10,20 @@ * --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/ipc.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 +34,17 @@ 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_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, + 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) @@ -41,21 +56,26 @@ 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 */ +#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 #include #endif @@ -65,39 +85,246 @@ 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; + bool maintenance_cancelled; +} 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); + context->maintenance_cancelled = context->maintenance_cancelled || cancelled; + cbm_mutex_unlock(&context->mutex); + return cancelled; +} + +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; +} - /* 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 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); + } +} + +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; } - if (g_http_server) { - cbm_http_server_stop(g_http_server); + 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; + } + 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 +343,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 +360,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; +static bool worker_prepare_process_group(void) { + pid_t process_id = getpid(); + return (setpgid(0, 0) == 0 || getpgrp() == process_id) && getpgrp() == process_id; } -/* ── HTTP UI background thread ──────────────────────────────────── */ - -static void *http_thread(void *arg) { - cbm_http_server_t *srv = arg; - cbm_http_server_run(srv); - return NULL; -} - -/* ── Index callback for watcher ─────────────────────────────────── */ - -static int watcher_index_fn(const char *project_name, const char *root_path, void *user_data) { - (void)user_data; - - /* Skip indexing if shutdown is in progress (skipped, not indexed) */ - if (atomic_load(&g_shutdown)) { - return 1; +/* 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 +591,23 @@ 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,99 @@ 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; + 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); + /* 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); + 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"); + } 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,9 +808,14 @@ 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); } + 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 @@ -496,8 +830,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 +846,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 +885,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) { +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 +903,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 +928,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 +1012,322 @@ 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); +} + +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 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 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 + * 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 MAIN_BUILD_IDENTITY_CACHE_CANONICALIZE; + } + 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 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 MAIN_BUILD_IDENTITY_CACHE_ENVIRONMENT; + } + 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, + }; + return MAIN_BUILD_IDENTITY_OK; +} + +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 +1337,472 @@ 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(); + 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"); + return EXIT_FAILURE; + } +#ifndef _WIN32 + if (role == CBM_DAEMON_PROCESS_DAEMON) { + (void)umask(077); + } +#endif + 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) { + 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; + 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; + 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); + + 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; + } + + 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; + } - /* 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); + char executable_path[MAIN_PATH_CAP]; + cbm_daemon_build_identity_t 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) { + const char *validation_detail = cbm_daemon_ipc_validation_detail(); + (void)fprintf(stderr, + "codebase-memory-mcp: exact executable identity could not be verified " + "(%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); + + 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; + } - /* 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); + 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; + } + + 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_MCP_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_MCP_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..e5dd53d34 100644 --- a/src/mcp/index_supervisor.c +++ b/src/mcp/index_supervisor.c @@ -3,29 +3,44 @@ */ #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 +51,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 +80,163 @@ 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 +254,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 +275,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 +287,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 +311,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..5f8f57018 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,55 @@ * (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 +92,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 +112,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 a04057419..39a8b3708 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -34,15 +34,18 @@ 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" +#include "discover/discover.h" #include "pipeline/pipeline.h" #include "pipeline/pass_cross_repo.h" #include "git/git_context.h" @@ -56,6 +59,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" @@ -64,9 +68,11 @@ enum { #include "pipeline/artifact.h" #ifdef _WIN32 +#include "foundation/win_utf8.h" #include #include #include +#include #define getpid _getpid #else #include @@ -75,6 +81,7 @@ enum { #endif #include #include +#include #include // int64_t #include #include @@ -82,6 +89,7 @@ enum { #include #include #include +#include /* ── Constants ────────────────────────────────────────────────── */ @@ -101,6 +109,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) { @@ -1273,6 +1287,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; @@ -1416,12 +1444,31 @@ 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_mcp_command_test_hook_fn command_test_hook; + void *command_test_context; 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; 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 */ @@ -1433,6 +1480,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); /* If a store_path is given, open that project directly. * Otherwise, create an in-memory store for test/embedded use. */ @@ -1444,6 +1493,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; } @@ -1478,6 +1528,96 @@ 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; @@ -1492,7 +1632,9 @@ 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); + cbm_mutex_destroy(&srv->request_scope_mutex); free(srv); } @@ -1526,10 +1668,86 @@ 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; + } + 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; + } + 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) { + if (!srv) { + return; + } + srv->quarantine_test_hook = hook; + srv->quarantine_test_context = context; +} + +void cbm_mcp_server_set_command_test_hook(cbm_mcp_server_t *srv, cbm_mcp_command_test_hook_fn hook, + void *context) { + if (!srv) { + return; + } + srv->command_test_hook = hook; + srv->command_test_context = context; +} + /* ── Cache dir + project DB path helpers ───────────────────────── */ /* Returns the cache directory. Writes to buf, returns buf for convenience. */ @@ -1572,10 +1790,188 @@ 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 */ } @@ -1601,26 +1997,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 */ - } - 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; + 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; + } } /* Verify the project actually exists in this database. @@ -1659,6 +2066,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); @@ -3922,6 +4333,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) { @@ -3972,6 +4388,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); @@ -5889,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 @@ -6067,12 +6484,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}; @@ -6086,11 +6499,71 @@ 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); } @@ -6108,28 +6581,135 @@ 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; + } + + 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]); + } + 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(project, targets, tp_count); + cbm_cross_repo_result_t result = cbm_cross_repo_match_cancellable( + project, targets, tp_count, &srv->pipeline_cancel_requested); + 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); @@ -6143,7 +6723,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; } @@ -6479,13 +7059,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); } @@ -6497,12 +7088,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; } @@ -6631,8 +7247,24 @@ static bool supervisor_append_quarantine(const char *path, const char *rel, cons return true; } -/* Run index_repository in a supervised worker subprocess with skip-and-continue - * (Stage 3c). Returns the response string (caller frees): +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); * - after a crash/hang, the response from a clean single-threaded RECOVERY run * that quarantines the culprit file(s) — status="indexed" with them listed in @@ -6640,30 +7272,38 @@ 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; } @@ -6710,16 +7350,29 @@ 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); @@ -6790,27 +7443,38 @@ 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); } @@ -6844,17 +7508,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"); @@ -6866,14 +7578,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); @@ -6884,12 +7602,91 @@ 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); + } + char *supervised = index_run_supervised(srv, worker_args); + 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); + } + 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; if (mode_str && strcmp(mode_str, "fast") == 0) { mode = CBM_MODE_FAST; @@ -6902,12 +7699,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); @@ -6932,6 +7733,7 @@ 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(); + cbm_pipeline_bind_cancel_flag(p, &srv->pipeline_cancel_requested); srv->active_pipeline = p; int rc = cbm_pipeline_run(p); srv->active_pipeline = NULL; @@ -7000,6 +7802,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; @@ -7073,10 +7878,10 @@ static char *snippet_suggestions(const char *input, cbm_node_t *nodes, int count * 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; } @@ -7086,6 +7891,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; @@ -7094,9 +7932,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; } } @@ -8549,6 +9385,95 @@ 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; + } + /* Internal test seam: rejecting after output allocation exercises the same + * cleanup contract as a contained process-tree failure. */ + if (srv->command_test_hook && !srv->command_test_hook(srv->command_test_context, command)) { + 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; +} + /* Collect BFS seed ids: every symbol DEFINED in a changed file (everything but * the structural container labels — those have no CALLS edges). These anchor * the multi-source impact traversal. */ @@ -8761,18 +9686,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); + } /* resolve_store already called via get_project_root above */ cbm_store_t *store = srv->store; @@ -8798,7 +9744,8 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { free(project); free(base_branch); free(scope); - (void)cbm_pclose(fp); + (void)fclose(fp); + (void)cbm_unlink(output_path); return cbm_mcp_text_result(errbuf, true); } char *fmt = cbm_mcp_get_string_arg(args, "format"); @@ -8869,7 +9816,9 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { detect_collect_seeds(store, project, path_line, &seeds, &seed_count, &seed_cap); } } - int git_status = cbm_pclose(fp); + (void)fclose(fp); + (void)cbm_unlink(output_path); + int git_status = git_result.exit_code; /* merge-base SHA: the exact commit the diff is measured against, so the * result is reproducible even as base_branch advances. Best-effort. */ @@ -8883,15 +9832,42 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { snprintf(mbcmd, sizeof(mbcmd), "git -C '%s' merge-base '%s' HEAD 2>/dev/null", root_path, base_branch); #endif - FILE *mbfp = cbm_popen(mbcmd, "r"); - if (mbfp) { + char mb_output_path[CBM_SZ_2K] = {0}; + cbm_proc_result_t mb_result = {0}; + int mb_run = mcp_run_shell_command_cancellable(srv, mbcmd, mb_output_path, &mb_result); + bool mb_cancelled = mb_result.cancellation_requested || mcp_request_cancelled(srv); + bool mb_containment_failed = mb_run != 0 && mb_output_path[0] != '\0'; + FILE *mbfp = + mb_run == 0 && mb_result.exit_code == 0 ? cbm_fopen(mb_output_path, "rb") : NULL; + if (mbfp && !mb_cancelled) { if (fgets(merge_base, sizeof(merge_base), mbfp)) { size_t l = strlen(merge_base); while (l > 0 && (merge_base[l - 1] == '\n' || merge_base[l - 1] == '\r')) { merge_base[--l] = '\0'; } } - (void)cbm_pclose(mbfp); + } + if (mbfp) { + (void)fclose(mbfp); + } + if (mb_output_path[0]) { + (void)cbm_unlink(mb_output_path); + } + if (mb_cancelled || mb_containment_failed) { + for (int i = 0; i < file_count; i++) { + free(files[i]); + } + free(files); + free(seeds); + free(direction); + free(root_path); + free(project); + free(base_branch); + free(scope); + return cbm_mcp_text_result( + mb_cancelled ? "detect_changes cancelled for this request" + : "git merge-base failed: the contained command could not complete", + true); } } @@ -9134,14 +10110,36 @@ 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); + } + 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); + } + } + /* 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); @@ -9159,11 +10157,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); @@ -9179,7 +10198,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) { @@ -9222,6 +10241,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); @@ -9267,7 +10289,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); } @@ -9330,6 +10352,35 @@ 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) { + 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; +} + /* ── Session detection + auto-index ────────────────────────────── */ /* Detect session root from CWD (fallback: single indexed project from DB). */ @@ -9392,11 +10443,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) { @@ -9409,7 +10460,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); @@ -9435,6 +10488,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') { @@ -9456,16 +10534,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) { @@ -9474,27 +10549,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) { @@ -9613,6 +10677,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) { @@ -9622,10 +10727,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"); } } @@ -9633,6 +10737,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; @@ -9642,7 +10753,7 @@ char *cbm_mcp_server_handle(cbm_mcp_server_t *srv, const char *line) { if (strcmp(req.method, "initialize") == 0) { 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); } @@ -9705,6 +10816,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; } @@ -9722,6 +10834,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; } @@ -9741,40 +10854,190 @@ 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; } -/* 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; } - if (hlen == 0) { + + 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; + } + + (*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; } } @@ -9844,8 +11107,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 (;;) { @@ -9888,37 +11149,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..58b01a32a 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,44 @@ 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 +193,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 +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. 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 new file mode 100644 index 000000000..213159695 --- /dev/null +++ b/src/mcp/mcp_internal.h @@ -0,0 +1,34 @@ +#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); +typedef bool (*cbm_mcp_command_test_hook_fn)(void *context, const char *command); + +void cbm_mcp_server_set_quarantine_test_hook(cbm_mcp_server_t *srv, + cbm_mcp_quarantine_test_hook_fn hook, void *context); +void cbm_mcp_server_set_command_test_hook(cbm_mcp_server_t *srv, cbm_mcp_command_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 d233c422b..c606719d3 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,52 @@ 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; + +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; +} 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 +106,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 +201,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 +246,14 @@ 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; + if (cr_after_insert_test_hook) { + cr_after_insert_test_hook(project, edge_type, cr_after_insert_test_context); + } + } + return inserted; } /* Strip "scheme://host[:port]" from a stored HTTP_CALLS url, returning the @@ -153,17 +275,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 +302,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 +312,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 +327,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 +355,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 +375,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 +440,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 +489,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 +619,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 +716,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 +775,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; } - 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; -} - -static int match_channels(cbm_store_t *src_store, const char *src_project, cbm_store_t *tgt_store, - const char *tgt_project) { + 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; + } + + 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 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 +846,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 +872,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 +992,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 +1005,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 (strstr(ent->name, "-wal") || strstr(ent->name, "-shm")) { + 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; + } + 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 +1136,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,35 +1285,72 @@ 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); 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; - 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 { + 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)); + } 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..0c3041959 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,31 @@ 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 +301,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 +406,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 +1205,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 +1240,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 +1270,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,7 +1279,12 @@ 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) { + 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) { @@ -1376,19 +1436,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 +1553,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 +1611,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 +1635,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, @@ -1601,9 +1653,8 @@ int cbm_pipeline_run(cbm_pipeline_t *p) { 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: @@ -1621,3 +1672,239 @@ 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..21740ba64 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,71 @@ 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 +987,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 +1006,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..e6c211f44 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -631,6 +631,22 @@ 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); + +/* 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/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 7b966051a..2a704eb5c 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -653,19 +653,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; } @@ -703,14 +704,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) { @@ -951,7 +959,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) { @@ -1116,6 +1124,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; +} + /* #1083: the WAL size limit configured on this (write) connection, in bytes. * -1 means unlimited (SQLite's default — the pre-fix behavior). Per-connection * and not persisted, so it can only be read on the connection that set it. */ diff --git a/src/store/store.h b/src/store/store.h index 373440474..55be4a48b 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..206ff4918 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,98 @@ 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 +152,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 +163,215 @@ 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..9da078436 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,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_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 +780,35 @@ 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 +925,35 @@ 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 +985,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 +997,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 +1010,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 +1035,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 +1065,25 @@ 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 +1097,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 +1119,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 +1293,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 +1362,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 +1584,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 +1620,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 +1642,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 +1665,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 +1689,8 @@ 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 +1719,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 +1785,24 @@ 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..7544b7881 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..6a4844137 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,60 @@ 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 +529,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 +541,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 +550,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 +586,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 +629,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 +744,71 @@ 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 +874,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..699013250 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..e93dc5bfd 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,95 @@ 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 +320,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. @@ -244,19 +335,19 @@ 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); + 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). */ 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 +360,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..681c277e8 --- /dev/null +++ b/tests/test_activation_transaction.c @@ -0,0 +1,936 @@ +/* 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 8a5d39bfe..8646be412 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -10,13 +10,21 @@ * 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 #include #include @@ -26,9 +34,134 @@ #ifndef _WIN32 #include #endif +#ifdef __APPLE__ +#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_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_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"); @@ -218,103 +351,1345 @@ static char *save_test_env(const char *name) { return value ? strdup(value) : NULL; } -static void restore_test_env(const char *name, char *saved) { - if (saved) { - cbm_setenv(name, saved, 1); - free(saved); +static void restore_test_env(const char *name, char *saved) { + if (saved) { + cbm_setenv(name, saved, 1); + free(saved); + } else { + cbm_unsetenv(name); + } +} + +/* Helper: mkdirp */ +static int test_mkdirp(const char *path) { + char tmp[1024]; + snprintf(tmp, sizeof(tmp), "%s", path); + for (char *p = tmp + 1; *p; p++) { + if (*p == '/') { + *p = '\0'; + cbm_mkdir(tmp); + *p = '/'; + } + } + return cbm_mkdir(tmp) == 0 || errno == EEXIST ? 0 : -1; +} + +/* Helper: recursive remove */ +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) { + /* Build tar data: 512-byte header + content padded to 512-byte boundary + 2x512 zero blocks */ + int data_blocks = (content_len + 511) / 512; + int tar_size = 512 + data_blocks * 512 + 1024; /* header + data + end-of-archive */ + unsigned char *tar = calloc(1, (size_t)tar_size); + if (!tar) + return NULL; + + /* Filename (bytes 0-99) */ + strncpy((char *)tar, filename, 99); + + /* Mode (bytes 100-107): octal 0700 */ + memcpy(tar + 100, "0000700\0", 8); + + /* UID/GID (bytes 108-123): 0 */ + memcpy(tar + 108, "0000000\0", 8); + memcpy(tar + 116, "0000000\0", 8); + + /* Size (bytes 124-135): octal */ + char size_str[12]; + snprintf(size_str, sizeof(size_str), "%011o", content_len); + memcpy(tar + 124, size_str, 11); + + /* Mtime (bytes 136-147): 0 */ + memcpy(tar + 136, "00000000000\0", 12); + + /* Type flag (byte 156): '0' = regular file */ + tar[156] = '0'; + + /* Checksum (bytes 148-155): compute over header with checksum field as spaces */ + memset(tar + 148, ' ', 8); + unsigned int checksum = 0; + for (int i = 0; i < 512; i++) + checksum += tar[i]; + snprintf((char *)tar + 148, 7, "%06o", checksum); + tar[154] = '\0'; + + /* File content */ + memcpy(tar + 512, content, (size_t)content_len); + + /* Compress with gzip */ + z_stream strm = {0}; + if (deflateInit2(&strm, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, + Z_DEFAULT_STRATEGY) != Z_OK) { + free(tar); + return NULL; + } + + size_t gz_cap = (size_t)tar_size + 256; + unsigned char *gz = malloc(gz_cap); + if (!gz) { + deflateEnd(&strm); + free(tar); + return NULL; + } + + strm.next_in = tar; + strm.avail_in = (unsigned int)tar_size; + strm.next_out = gz; + strm.avail_out = (unsigned int)gz_cap; + + deflate(&strm, Z_FINISH); + *out_len = (int)(gz_cap - strm.avail_out); + + deflateEnd(&strm); + free(tar); + 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, + .cache_fingerprint = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + .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() + 15000U, &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]; + 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); + +#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(name); + cbm_unsetenv("CBM_DOWNLOAD_URL"); + } + free(old_download); + 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 +#ifdef __APPLE__ + /* 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 + const char *native_fixture = "/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 } -/* Helper: mkdirp */ -static int test_mkdirp(const char *path) { - char tmp[1024]; - snprintf(tmp, sizeof(tmp), "%s", path); - for (char *p = tmp + 1; *p; p++) { - if (*p == '/') { - *p = '\0'; - cbm_mkdir(tmp); - *p = '/'; - } +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"); } - return cbm_mkdir(tmp) == 0 || errno == EEXIST ? 0 : -1; -} + char *old_home = NULL; + char *old_cache = NULL; + cli_activation_save_env(&old_home, &old_cache); + cbm_setenv("HOME", tmpdir, 1); -/* Helper: recursive remove */ -static void test_rmdir_r(const char *path) { - th_rmtree(path); -} + 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"); -/* 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) { - /* Build tar data: 512-byte header + content padded to 512-byte boundary + 2x512 zero blocks */ - int data_blocks = (content_len + 511) / 512; - int tar_size = 512 + data_blocks * 512 + 1024; /* header + data + end-of-archive */ - unsigned char *tar = calloc(1, (size_t)tar_size); - if (!tar) - return NULL; + 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"); - /* Filename (bytes 0-99) */ - strncpy((char *)tar, filename, 99); + 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); - /* Mode (bytes 100-107): octal 0700 */ - memcpy(tar + 100, "0000700\0", 8); + 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); - /* UID/GID (bytes 108-123): 0 */ - memcpy(tar + 108, "0000000\0", 8); - memcpy(tar + 116, "0000000\0", 8); + 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(); +} - /* Size (bytes 124-135): octal */ - char size_str[12]; - snprintf(size_str, sizeof(size_str), "%011o", content_len); - memcpy(tar + 124, size_str, 11); +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); - /* Mtime (bytes 136-147): 0 */ - memcpy(tar + 136, "00000000000\0", 12); + 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"); - /* Type flag (byte 156): '0' = regular file */ - tar[156] = '0'; + 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"); - /* Checksum (bytes 148-155): compute over header with checksum field as spaces */ - memset(tar + 148, ' ', 8); - unsigned int checksum = 0; - for (int i = 0; i < 512; i++) - checksum += tar[i]; - snprintf((char *)tar + 148, 7, "%06o", checksum); - tar[154] = '\0'; + /* 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); - /* File content */ - memcpy(tar + 512, content, (size_t)content_len); + 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); - /* Compress with gzip */ - z_stream strm = {0}; - if (deflateInit2(&strm, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, - Z_DEFAULT_STRATEGY) != Z_OK) { - free(tar); - return NULL; - } + 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(); +} - size_t gz_cap = (size_t)tar_size + 256; - unsigned char *gz = malloc(gz_cap); - if (!gz) { - deflateEnd(&strm); - free(tar); - return NULL; +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); - strm.next_in = tar; - strm.avail_in = (unsigned int)tar_size; - strm.next_out = gz; - strm.avail_out = (unsigned int)gz_cap; - - deflate(&strm, Z_FINISH); - *out_len = (int)(gz_cap - strm.avail_out); + 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); - deflateEnd(&strm); - free(tar); - return gz; + 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(); } /* ═══════════════════════════════════════════════════════════════════ @@ -1216,7 +2591,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]); @@ -1242,11 +2618,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(); } @@ -1904,6 +3289,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; @@ -2139,21 +3767,42 @@ 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(); } @@ -9376,6 +11025,91 @@ 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"); @@ -9664,8 +11398,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) { @@ -9690,6 +11422,105 @@ 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) { @@ -9708,7 +11539,39 @@ 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); + 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); @@ -9782,6 +11645,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); @@ -9981,9 +11849,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..084dae9ff --- /dev/null +++ b/tests/test_cross_repo.c @@ -0,0 +1,480 @@ +/* + * 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 "pipeline/pass_cross_repo.h" +#include "pipeline/pipeline_internal.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(); +} + +typedef struct { + atomic_int *cancelled; + 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); + } +} + +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"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed cancellation fixture"); + } + + atomic_int cancelled; + atomic_init(&cancelled, 0); + cross_repo_cancel_hook_t hook = { + .cancelled = &cancelled, + }; + + const char *targets[] = {"cancel-target-c", "cancel-target-a", "cancel-target-b"}; + 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); + + 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_EQ(hook.fired, 1); + 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..fb0d0095a --- /dev/null +++ b/tests/test_daemon.c @@ -0,0 +1,483 @@ +/* + * 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..8ad5d933d --- /dev/null +++ b/tests/test_daemon_application.c @@ -0,0 +1,4734 @@ +/* + * test_daemon_application.c — RED contract for daemon-owned frontend state. + */ +#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" +#include "daemon/project_lock.h" +#include "foundation/compat.h" +#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" +#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; +} + +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 + * 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); + cbm_index_worker_result_free(&worker->result); + 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; +} + +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(); +} + +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 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); + } + 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; + bool shutdown_ok; + atomic_bool done; +} 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; +} + +/* 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); + } + 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_semantically_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 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; + 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 = tools[0], + .request_length = tool_lengths[0]}, + {.callbacks = callbacks, + .session = sessions[1], + .request = tools[1], + .request_length = tool_lengths[1]}, + }; + 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(tools[0]); + free(tools[1]); + 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(); +} + +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 + * 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_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); + 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(); +} + +/* 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); + 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_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); + 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_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); + 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_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 new file mode 100644 index 000000000..f95b1900b --- /dev/null +++ b/tests/test_daemon_bootstrap.c @@ -0,0 +1,742 @@ +/* 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; + atomic_bool connect_requires_unlocked; + cbm_daemon_bootstrap_probe_status_t forced_probe; + cbm_version_cohort_status_t forced_cohort; + char diagnostic[CBM_DAEMON_CONFLICT_MESSAGE_SIZE]; +} bootstrap_fake_ops_t; + +typedef struct { + const cbm_daemon_bootstrap_config_t *config; + const cbm_daemon_bootstrap_ops_t *ops; + atomic_int *ready; + atomic_bool *go; + cbm_daemon_bootstrap_result_t result; + cbm_daemon_bootstrap_status_t status; +} bootstrap_thread_call_t; + +static cbm_daemon_build_identity_t bootstrap_identity(const char *version, const char *build) { + cbm_daemon_build_identity_t identity = { + .semantic_version = version, + .build_fingerprint = build, + .cache_fingerprint = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + .protocol_abi = 3, + .store_abi = 11, + .feature_abi = 7, + }; + return identity; +} + +static bool bootstrap_endpoint_fixture_start(bootstrap_endpoint_fixture_t *fixture, + const char *tag) { + memset(fixture, 0, sizeof(*fixture)); + int written = snprintf(fixture->parent, sizeof(fixture->parent), "%s/cbm-bootstrap-%s-XXXXXX", + cbm_tmpdir(), tag); + if (written <= 0 || written >= (int)sizeof(fixture->parent) || !cbm_mkdtemp(fixture->parent)) { + return false; + } + fixture->endpoint = cbm_daemon_bootstrap_endpoint_new(fixture->parent); + const char *runtime_dir = + fixture->endpoint ? cbm_daemon_ipc_endpoint_runtime_dir(fixture->endpoint) : NULL; + if (!runtime_dir) { + return false; + } + written = snprintf(fixture->runtime_dir, sizeof(fixture->runtime_dir), "%s", runtime_dir); + return written > 0 && written < (int)sizeof(fixture->runtime_dir); +} + +static void bootstrap_endpoint_fixture_finish(bootstrap_endpoint_fixture_t *fixture) { + cbm_daemon_ipc_endpoint_free(fixture->endpoint); + if (fixture->runtime_dir[0] != '\0') { + (void)cbm_rmdir(fixture->runtime_dir); + } + if (fixture->parent[0] != '\0') { + (void)cbm_rmdir(fixture->parent); + } + memset(fixture, 0, sizeof(*fixture)); +} + +static cbm_daemon_bootstrap_probe_status_t bootstrap_fake_probe( + void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, uint32_t timeout_ms, + cbm_daemon_runtime_client_t **client_out, cbm_daemon_runtime_connect_result_t *result_out) { + (void)endpoint; + (void)identity; + (void)timeout_ms; + bootstrap_fake_ops_t *fake = opaque; + atomic_fetch_add(&fake->probe_count, 1); + memset(result_out, 0, sizeof(*result_out)); + *client_out = NULL; + int reserved_remaining = atomic_load(&fake->reserved_probes_remaining); + while (reserved_remaining > 0 && + !atomic_compare_exchange_weak(&fake->reserved_probes_remaining, &reserved_remaining, + reserved_remaining - 1)) {} + if (reserved_remaining > 0) { + if (reserved_remaining == 1 && atomic_load(&fake->connect_after_reserved)) { + atomic_store(&fake->available, true); + } + return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED; + } + int terminal_remaining = atomic_load(&fake->terminal_probes_remaining); + while (terminal_remaining > 0 && + !atomic_compare_exchange_weak(&fake->terminal_probes_remaining, &terminal_remaining, + terminal_remaining - 1)) {} + if (terminal_remaining > 0) { + return CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL; + } + if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED) { + return fake->forced_probe; + } + if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_CONFLICT) { + result_out->status = CBM_DAEMON_RUNTIME_CONNECT_CONFLICT; + result_out->hello_status = CBM_DAEMON_HELLO_BUILD_CONFLICT; + snprintf(result_out->message, sizeof(result_out->message), + "CBM daemon could not start: conflicting versions"); + return fake->forced_probe; + } + if (fake->forced_probe == CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL) { + return fake->forced_probe; + } + if (atomic_load(&fake->available) && atomic_load(&fake->connect_requires_unlocked) && + atomic_load(&fake->lock_held) != 0) { + return CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED; + } + if (atomic_load(&fake->available)) { + result_out->status = CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED; + result_out->hello_status = CBM_DAEMON_HELLO_COMPATIBLE; + result_out->client_id = 1; + *client_out = (cbm_daemon_runtime_client_t *)(uintptr_t)1; + return CBM_DAEMON_BOOTSTRAP_PROBE_CONNECTED; + } + return CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE; +} + +static cbm_version_cohort_status_t bootstrap_fake_cohort_acquire( + void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint, + const cbm_daemon_build_identity_t *identity, uint64_t deadline_ms, + cbm_daemon_bootstrap_cohort_t *cohort_out, cbm_daemon_conflict_t *conflict_out) { + (void)endpoint; + (void)deadline_ms; + bootstrap_fake_ops_t *fake = opaque; + atomic_fetch_add(&fake->cohort_acquire_count, 1); + *cohort_out = NULL; + memset(conflict_out, 0, sizeof(*conflict_out)); + if (fake->forced_cohort == CBM_VERSION_COHORT_CONFLICT) { + cbm_daemon_build_identity_t active = bootstrap_identity("2.3.0", BOOTSTRAP_BUILD_A); + (void)cbm_daemon_hello_compare(&active, identity, conflict_out); + return fake->forced_cohort; + } + if (fake->forced_cohort != CBM_VERSION_COHORT_OK) { + return fake->forced_cohort; + } + *cohort_out = fake; + return CBM_VERSION_COHORT_OK; +} + +static void bootstrap_fake_cohort_release(void *opaque, cbm_daemon_bootstrap_cohort_t cohort) { + bootstrap_fake_ops_t *fake = opaque; + if (cohort == fake) { + atomic_fetch_add(&fake->cohort_release_count, 1); + } +} + +static int bootstrap_fake_lock(void *opaque, const cbm_daemon_ipc_endpoint_t *endpoint, + cbm_daemon_bootstrap_lock_t *lock_out) { + (void)endpoint; + bootstrap_fake_ops_t *fake = opaque; + atomic_fetch_add(&fake->lock_attempt_count, 1); + int expected = 0; + if (!atomic_compare_exchange_strong(&fake->lock_held, &expected, 1)) { + return 0; + } + *lock_out = fake; + return 1; +} + +static bool bootstrap_fake_unlock(void *opaque, cbm_daemon_bootstrap_lock_t *lock_io) { + bootstrap_fake_ops_t *fake = opaque; + if (lock_io && *lock_io == fake) { + atomic_store(&fake->lock_held, 0); + *lock_io = NULL; + return true; + } + return lock_io && !*lock_io; +} + +static bool bootstrap_fake_handoff(void *opaque, cbm_daemon_bootstrap_lock_t lock) { + bootstrap_fake_ops_t *fake = opaque; + if (lock != fake || atomic_load(&fake->lock_held) != 1) { + return false; + } + atomic_fetch_add(&fake->handoff_count, 1); + return true; +} + +static bool bootstrap_fake_spawn(void *opaque, const cbm_daemon_bootstrap_launch_spec_t *spec) { + bootstrap_fake_ops_t *fake = opaque; + 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(); +} + +/* RED for the native Windows lock order: after spawn, the generation claim or + * lifetime reservation becomes visible before the client can connect. Daemon + * participant teardown needs startup ownership, so bootstrap must release its + * handoff once that generation is observable. */ +TEST(daemon_bootstrap_releases_handoff_when_spawned_generation_is_reserved) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "spawn-admission")); + bootstrap_fake_ops_t fake = {0}; + atomic_store(&fake.connect_requires_unlocked, true); + cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = "/tmp/cbm", + .connect_timeout_ms = 1, + .startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS, + }; + cbm_daemon_bootstrap_result_t result; + ASSERT_EQ(cbm_daemon_bootstrap_execute_with_ops(&config, &ops, &result), + CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_EQ(result.status, CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_TRUE(result.daemon_spawned); + ASSERT_NOT_NULL(result.client); + ASSERT_EQ(atomic_load(&fake.spawn_count), 1); + ASSERT_EQ(atomic_load(&fake.lock_held), 0); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} + +TEST(daemon_bootstrap_rejected_connect_is_reserved_and_never_unavailable) { + cbm_daemon_runtime_connect_result_t capacity = {0}; + capacity.status = CBM_DAEMON_RUNTIME_CONNECT_REJECTED; + snprintf(capacity.message, sizeof(capacity.message), "CBM daemon connection capacity reached"); + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, 1), + CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED); + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, 0), + CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED); + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&capacity, -1), + CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED); + + cbm_daemon_runtime_connect_result_t stopping = capacity; + snprintf(stopping.message, sizeof(stopping.message), "CBM daemon is stopping"); + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&stopping, 1), + CBM_DAEMON_BOOTSTRAP_PROBE_TERMINAL); + + cbm_daemon_runtime_connect_result_t absent = {0}; + absent.status = CBM_DAEMON_RUNTIME_CONNECT_ERROR; + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&absent, 1), + CBM_DAEMON_BOOTSTRAP_PROBE_RESERVED); + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&absent, 0), + CBM_DAEMON_BOOTSTRAP_PROBE_UNAVAILABLE); + ASSERT_EQ(cbm_daemon_bootstrap_classify_failed_connect(&absent, -1), + CBM_DAEMON_BOOTSTRAP_PROBE_ERROR); + PASS(); +} + +TEST(daemon_bootstrap_concurrent_first_clients_spawn_one_daemon) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "startup-race")); + bootstrap_fake_ops_t fake = {0}; + cbm_daemon_bootstrap_ops_t ops = bootstrap_fake_callbacks(&fake); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = "/tmp/cbm", + .connect_timeout_ms = 10, + .startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS, + }; + atomic_int ready = 0; + atomic_bool go = false; + bootstrap_thread_call_t calls[2] = { + {.config = &config, .ops = &ops, .ready = &ready, .go = &go}, + {.config = &config, .ops = &ops, .ready = &ready, .go = &go}, + }; + cbm_thread_t threads[2]; + ASSERT_EQ(cbm_thread_create(&threads[0], 0, bootstrap_thread_execute, &calls[0]), 0); + ASSERT_EQ(cbm_thread_create(&threads[1], 0, bootstrap_thread_execute, &calls[1]), 0); + uint64_t ready_deadline = cbm_now_ms() + BOOTSTRAP_TEST_TIMEOUT_MS; + while (atomic_load(&ready) != 2 && cbm_now_ms() < ready_deadline) { + const struct timespec pause = {0, 1000000L}; + (void)cbm_nanosleep(&pause, NULL); + } + atomic_store(&go, true); + ASSERT_EQ(cbm_thread_join(&threads[0]), 0); + ASSERT_EQ(cbm_thread_join(&threads[1]), 0); + ASSERT_EQ(calls[0].status, CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_EQ(calls[1].status, CBM_DAEMON_BOOTSTRAP_CONNECTED); + ASSERT_EQ(atomic_load(&fake.spawn_count), 1); + ASSERT_TRUE(calls[0].result.daemon_spawned != calls[1].result.daemon_spawned); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} + +#ifdef __APPLE__ +/* RED: the old double-fork returned success before its grandchild attempted + * posix_spawn, hiding an immediate launch error behind the full timeout. */ +TEST(daemon_bootstrap_darwin_launch_failure_is_synchronous) { + bootstrap_endpoint_fixture_t fixture; + ASSERT_TRUE(bootstrap_endpoint_fixture_start(&fixture, "darwin-missing")); + cbm_daemon_build_identity_t identity = bootstrap_identity("2.4.0", BOOTSTRAP_BUILD_A); + char missing[BOOTSTRAP_TEST_PATH_CAP]; + int written = snprintf(missing, sizeof(missing), "%s/definitely-missing-cbm", fixture.parent); + ASSERT(written > 0 && written < (int)sizeof(missing)); + cbm_daemon_bootstrap_config_t config = { + .role = CBM_DAEMON_PROCESS_MCP_CLIENT, + .endpoint = fixture.endpoint, + .identity = &identity, + .executable_path = missing, + .connect_timeout_ms = 1, + .startup_timeout_ms = BOOTSTRAP_TEST_TIMEOUT_MS, + }; + cbm_daemon_bootstrap_result_t result; + uint64_t started = cbm_now_ms(); + ASSERT_EQ(cbm_daemon_bootstrap_execute(&config, &result), CBM_DAEMON_BOOTSTRAP_FAILED); + uint64_t elapsed = cbm_now_ms() - started; + ASSERT_FALSE(result.daemon_spawned); + ASSERT(elapsed < BOOTSTRAP_TEST_TIMEOUT_MS / 2U); + bootstrap_endpoint_fixture_finish(&fixture); + PASS(); +} +#endif + +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_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__ + RUN_TEST(daemon_bootstrap_darwin_launch_failure_is_synchronous); +#endif +} diff --git a/tests/test_daemon_frontend.c b/tests/test_daemon_frontend.c new file mode 100644 index 000000000..02d4aa326 --- /dev/null +++ b/tests/test_daemon_frontend.c @@ -0,0 +1,1392 @@ +/* + * 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/compat_thread.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 const char FRONTEND_TEST_CACHE[] = + "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + +enum { + /* 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 = 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. */ + 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, + }; + return identity; +} + +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_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); + /* 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; + pid_t waited; + do { + waited = child > 0 ? waitpid(child, &status, 0) : -1; + } while (waited < 0 && errno == EINTR); + bool cleaned = th_rmtree(parent) == 0; + 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) { + 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; + } + for (;;) { + char value = '\0'; + ssize_t count = read(fd, &value, 1); + if (count == 1) { + *observed = value; + return true; + } + if (count == 0 || (count < 0 && errno != EAGAIN && errno != EWOULDBLOCK) || + cbm_now_ms() >= deadline_ms) { + return false; + } + cbm_usleep(1000); + } +} + +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; +} + +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(); +} + +/* 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"); + 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); + 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 new file mode 100644 index 000000000..045d7202b --- /dev/null +++ b/tests/test_daemon_ipc.c @@ -0,0 +1,4618 @@ +/* + * 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 +#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 }; + +#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); + 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_directory_acl(const char *path, bool inheritable, acl_tag_t tag) { + 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 && + (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; + 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; +} + +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. */ +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_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); + 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 = rights; + 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_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; + } + 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_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_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}; + 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_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; + + 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_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); + 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_deny_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_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 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; + + /* 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)); + } + 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_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}; + 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_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}; + 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_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); + 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_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 new file mode 100644 index 000000000..ff54b2b1c --- /dev/null +++ b/tests/test_daemon_runtime.c @@ -0,0 +1,4303 @@ +/* + * 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 "test_daemon_runtime_contract.h" + +#include "daemon/application.h" +#include "daemon/host.h" +#include "daemon/host_internal.h" +#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" +#include "foundation/log.h" +#include "foundation/platform.h" +#include "pipeline/pipeline.h" +#include "store/store.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, + 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, + 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, +}; + +#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 && + CBM_DAEMON_ACTIVATION_SHUTDOWN_RESPONSE_SIZE == + RUNTIME_TEST_ACTIVATION_RESPONSE_SIZE, + "activation shutdown frozen wire contract changed"); + +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; + +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 { + 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; + 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_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); + 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 +} + +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"); + 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 && runtime_test_windows_wait_image_probe(process.hProcess); + 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) { + (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); + } + 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 && runtime_test_windows_wait_image_probe(process.hProcess); + 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(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); + _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_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); + 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); + 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(); + } + fixture->service = NULL; + } + 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; +} + +/* 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. */ +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) { + 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); + mismatched.cache_fingerprint = RUNTIME_CACHE_A; + 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_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()); + 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_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 }; + 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) { + 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); +#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_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__) + 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_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_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_daemon_smoke.py b/tests/test_daemon_smoke.py new file mode 100644 index 000000000..ebde8a778 --- /dev/null +++ b/tests/test_daemon_smoke.py @@ -0,0 +1,2399 @@ +#!/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" + 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, + 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" + ) + 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_alias), + "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) + 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( + 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", + ) + + # 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 + # 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", + ) + 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, + "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..89982546a --- /dev/null +++ b/tests/test_daemon_version.c @@ -0,0 +1,627 @@ +/* + * 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_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 cceea3628..8544273af 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,76 @@ 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 +578,61 @@ 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 +650,66 @@ 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 +836,140 @@ 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 +1519,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 +1529,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_incremental.c b/tests/test_incremental.c index 72250707b..39fca812e 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 new file mode 100644 index 000000000..0f2f28a5a --- /dev/null +++ b/tests/test_index_supervisor.c @@ -0,0 +1,772 @@ +/* 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, + /* 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, +}; + +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() + INDEX_SUPERVISOR_TEST_READY_MS; + 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; +} + +/* 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; + 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", + 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"); + + 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); + } + 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); + (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_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) { + (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", + 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 + * 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); + } + + 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); + + 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_dump("oversize worker log", log_path); + 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_integration.c b/tests/test_integration.c index aa5ad5187..532b33457 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 new file mode 100644 index 000000000..c1f823696 --- /dev/null +++ b/tests/test_lock_registry.c @@ -0,0 +1,1683 @@ +/* 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; + +/* The stress fixtures that use this helper are POSIX-only. */ +#ifndef _WIN32 +static void lock_registry_test_yield(void) { + (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 + +#ifndef _WIN32 +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)); +} +#endif + +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; + +#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, + 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); + } +} +#endif + +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(); +} +#endif + +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 +} + +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; + +#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); + 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; +} +#endif + +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; + +#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); + 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; +} +#endif + +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 64f4b784d..871ce4abd 100644 --- a/tests/test_main.c +++ b/tests/test_main.c @@ -10,21 +10,94 @@ 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 "mcp/index_supervisor.h" /* cbm_index_set_worker_role */ -#include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */ +#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 */ +#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 #include #include #include #include #include +#include #ifdef _WIN32 #include /* #798 follow-up: socket-isolation re-exec probe */ +#else +#include +#ifdef __APPLE__ +#include +#include +#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 @@ -51,48 +124,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; + } + 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); } - 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, "\"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 +275,217 @@ 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", + .cache_fingerprint = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + .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, + 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, TF_RUNTIME_IMAGE_EXCHANGE_TIMEOUT_MS); + 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, + TF_RUNTIME_IMAGE_EXCHANGE_TIMEOUT_MS, &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; @@ -191,6 +529,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); @@ -204,6 +544,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); @@ -212,6 +563,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); @@ -252,6 +604,8 @@ 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_windows_launcher_state(void); extern void suite_system_info(void); extern void suite_worker_pool(void); extern void suite_parallel(void); @@ -294,6 +648,45 @@ 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); + 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); @@ -301,6 +694,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); @@ -346,6 +744,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 */ @@ -372,6 +772,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); @@ -386,6 +799,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); @@ -443,6 +857,8 @@ 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); + 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 82b5469eb..8a38378d3 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,335 @@ 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; + +typedef struct { + bool reject_merge_base; + int diff_calls; + int merge_base_calls; +} mcp_command_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_command_hook_probe(void *context, const char *command) { + mcp_command_hook_probe_t *probe = context; + if (!probe || !command) { + return false; + } + if (strstr(command, "merge-base")) { + probe->merge_base_calls++; + return !probe->reject_merge_base; + } + probe->diff_calls++; + return true; +} + +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 int mcp_count_directory_entries_with_prefix(const char *directory, const char *prefix) { + cbm_dir_t *dir = cbm_opendir(directory); + if (!dir) { + return -1; + } + size_t prefix_length = strlen(prefix); + int count = 0; + cbm_dirent_t *entry; + while ((entry = cbm_readdir(dir)) != NULL) { + if (strncmp(entry->name, prefix, prefix_length) == 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 * ══════════════════════════════════════════════════════════════════ */ @@ -2534,6 +2872,94 @@ 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); @@ -3634,116 +4060,1332 @@ TEST(tool_manage_adr_unified_backend_issue256) { PASS(); } -TEST(tool_index_repository_reports_store_backed_adr) { - char tmp_dir[256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-adr-test-XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) { - PASS(); - } - char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-index-adr-cache-XXXXXX"); - if (!cbm_mkdtemp(cache)) { - cbm_rmdir(tmp_dir); - PASS(); - } - - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; - cbm_setenv("CBM_CACHE_DIR", cache, 1); - - char src_path[512]; - snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); - FILE *fp = fopen(src_path, "w"); - ASSERT_NOT_NULL(fp); - fputs("def main():\n return 'ok'\n", fp); - fclose(fp); - - char *project = cbm_project_name_from_path(tmp_dir); - ASSERT_NOT_NULL(project); - +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); - char args[1024]; - snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp_dir); - char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); - ASSERT_NOT_NULL(resp); - ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); - free(resp); + 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 update_args[2048]; - snprintf(update_args, sizeof(update_args), - "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" - "Store-backed ADR metadata.\\n\"}", - project); - resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); + 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")); - free(resp); - - resp = cbm_mcp_handle_tool(srv, "index_repository", args); - ASSERT_NOT_NULL(resp); - ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); - ASSERT(response_contains_json_fragment(resp, "\"adr_present\":true")); - ASSERT_NULL(strstr(resp, "adr_hint")); - free(resp); - - char get_args[512]; - snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", project); - resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); - ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "Store-backed ADR metadata.")); - ASSERT_NULL(strstr(resp, "no_adr")); + 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); - restore_cache_dir(saved_copy); - free(saved_copy); - free(project); - remove(src_path); - cbm_rmdir(cache); - cbm_rmdir(tmp_dir); PASS(); } -TEST(tool_index_repository_dot_uses_absolute_project_key_and_preserves_adr) { - char tmp_dir[256]; - snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-dot-adr-test-XXXXXX"); - if (!cbm_mkdtemp(tmp_dir)) { - PASS(); - } +TEST(tool_manage_adr_mutation_guard_releases_on_missing_store) { char cache[256]; - snprintf(cache, sizeof(cache), "/tmp/cbm-index-dot-cache-XXXXXX"); + snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-adr-guard-XXXXXX"); if (!cbm_mkdtemp(cache)) { - cbm_rmdir(tmp_dir); PASS(); } - const char *saved = getenv("CBM_CACHE_DIR"); - char *saved_copy = saved ? strdup(saved) : NULL; + 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 src_path[512]; - snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); - FILE *fp = fopen(src_path, "w"); - ASSERT_NOT_NULL(fp); - fputs("def main():\n return helper()\n\ndef helper():\n return 1\n", fp); - fclose(fp); - - char old_cwd[CBM_SZ_4K]; - ASSERT_NOT_NULL(cbm_getcwd(old_cwd, sizeof(old_cwd))); - - char *project = cbm_project_name_from_path(tmp_dir); - ASSERT_NOT_NULL(project); - + 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); - ASSERT_EQ(cbm_chdir(tmp_dir), 0); - char *resp = - cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\".\",\"mode\":\"fast\"}"); - ASSERT_EQ(cbm_chdir(old_cwd), 0); + 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(); +} + +/* 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]; + 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); + 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; + + 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(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(); +} + +/* 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(); +} + +TEST(tool_index_repository_reports_store_backed_adr) { + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-adr-test-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-index-adr-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def main():\n return 'ok'\n", fp); + fclose(fp); + + char *project = cbm_project_name_from_path(tmp_dir); + ASSERT_NOT_NULL(project); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + char args[1024]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp_dir); + char *resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + free(resp); + + char update_args[2048]; + snprintf(update_args, sizeof(update_args), + "{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n" + "Store-backed ADR metadata.\\n\"}", + project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "updated")); + free(resp); + + resp = cbm_mcp_handle_tool(srv, "index_repository", args); + ASSERT_NOT_NULL(resp); + ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\"")); + ASSERT(response_contains_json_fragment(resp, "\"adr_present\":true")); + ASSERT_NULL(strstr(resp, "adr_hint")); + free(resp); + + char get_args[512]; + snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", project); + resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "Store-backed ADR metadata.")); + ASSERT_NULL(strstr(resp, "no_adr")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_project_db(cache, project); + restore_cache_dir(saved_copy); + free(saved_copy); + free(project); + remove(src_path); + cbm_rmdir(cache); + cbm_rmdir(tmp_dir); + PASS(); +} + +TEST(tool_index_repository_dot_uses_absolute_project_key_and_preserves_adr) { + char tmp_dir[256]; + snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-dot-adr-test-XXXXXX"); + if (!cbm_mkdtemp(tmp_dir)) { + PASS(); + } + char cache[256]; + snprintf(cache, sizeof(cache), "/tmp/cbm-index-dot-cache-XXXXXX"); + if (!cbm_mkdtemp(cache)) { + cbm_rmdir(tmp_dir); + PASS(); + } + + const char *saved = getenv("CBM_CACHE_DIR"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_CACHE_DIR", cache, 1); + + char src_path[512]; + snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir); + FILE *fp = fopen(src_path, "w"); + ASSERT_NOT_NULL(fp); + fputs("def main():\n return helper()\n\ndef helper():\n return 1\n", fp); + fclose(fp); + + char old_cwd[CBM_SZ_4K]; + ASSERT_NOT_NULL(cbm_getcwd(old_cwd, sizeof(old_cwd))); + + char *project = cbm_project_name_from_path(tmp_dir); + ASSERT_NOT_NULL(project); + + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + + ASSERT_EQ(cbm_chdir(tmp_dir), 0); + char *resp = + cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\".\",\"mode\":\"fast\"}"); + ASSERT_EQ(cbm_chdir(old_cwd), 0); ASSERT_NOT_NULL(resp); if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) { free(resp); @@ -4004,6 +5646,88 @@ TEST(tool_detect_changes_not_found_rich_error) { PASS(); } +/* detect_changes owns shell output through regular temporary files. An error + * after opening that file must use fclose + unlink. The command hook then + * rejects merge-base only when it reaches the contained subprocess helper, so + * a raw popen regression bypasses the hook and fails this test. */ +TEST(tool_detect_changes_contained_commands_clean_up_error_and_success) { + char cache[512]; + (void)snprintf(cache, sizeof(cache), "%s/cbm-detect-contained-XXXXXX", cbm_tmpdir()); + bool cache_created = cbm_mkdtemp(cache) != NULL; + const char *saved_cache = getenv("CBM_CACHE_DIR"); + char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL; + bool environment_ready = cache_created && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + + char root[CBM_SZ_4K] = {0}; + bool root_ready = cbm_getcwd(root, sizeof(root)) != NULL; + const char *project = "detect-contained-project"; + cbm_mcp_server_t *srv = environment_ready && root_ready ? cbm_mcp_server_new(NULL) : NULL; + bool server_ready = srv != NULL; + cbm_store_t *store = srv ? cbm_mcp_server_store(srv) : NULL; + bool project_ready = store && cbm_store_upsert_project(store, project, root) == CBM_STORE_OK; + mcp_command_hook_probe_t command_probe = {.reject_merge_base = true}; + if (project_ready) { + cbm_mcp_server_set_project(srv, project); + cbm_mcp_server_set_command_test_hook(srv, mcp_command_hook_probe, &command_probe); + } + + char *invalid_response = + project_ready ? cbm_mcp_handle_tool(srv, "detect_changes", + "{\"project\":\"detect-contained-project\"," + "\"base_branch\":\"HEAD\",\"scope\":\"files\"," + "\"direction\":\"sideways\"}") + : NULL; + bool invalid_rejected = invalid_response && strstr(invalid_response, "invalid direction"); + char logs[640]; + (void)snprintf(logs, sizeof(logs), "%s/logs", cache); + int artifacts_after_error = + invalid_response ? mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") : -1; + + char *rejected_response = + project_ready ? cbm_mcp_handle_tool(srv, "detect_changes", + "{\"project\":\"detect-contained-project\"," + "\"base_branch\":\"HEAD\",\"scope\":\"files\"}") + : NULL; + bool containment_rejected = + rejected_response && strstr(rejected_response, "contained command could not complete"); + int artifacts_after_rejection = + rejected_response ? mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") : -1; + + command_probe.reject_merge_base = false; + char *success_response = + project_ready ? cbm_mcp_handle_tool(srv, "detect_changes", + "{\"project\":\"detect-contained-project\"," + "\"base_branch\":\"HEAD\",\"scope\":\"files\"}") + : NULL; + bool merge_base_reported = success_response && strstr(success_response, "merge_base"); + int artifacts_after_success = + success_response ? mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") : -1; + + free(invalid_response); + free(rejected_response); + free(success_response); + cbm_mcp_server_free(srv); + restore_cache_dir(saved_cache_copy); + free(saved_cache_copy); + bool cleaned = !cache_created || th_rmtree(cache) == 0; + + ASSERT_TRUE(cache_created); + ASSERT_TRUE(environment_ready); + ASSERT_TRUE(root_ready); + ASSERT_TRUE(server_ready); + ASSERT_TRUE(project_ready); + ASSERT_TRUE(invalid_rejected); + ASSERT_EQ(artifacts_after_error, 0); + ASSERT_TRUE(containment_rejected); + ASSERT_EQ(artifacts_after_rejection, 0); + ASSERT_TRUE(merge_base_reported); + ASSERT_EQ(artifacts_after_success, 0); + ASSERT_EQ(command_probe.diff_calls, 3); + ASSERT_EQ(command_probe.merge_base_calls, 2); + ASSERT_TRUE(cleaned); + PASS(); +} + TEST(tool_ingest_traces_basic) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); @@ -5369,12 +7093,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); @@ -5464,7 +7186,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 @@ -5474,11 +7196,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 @@ -5734,6 +7522,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 */ @@ -5749,13 +7538,22 @@ 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; @@ -5852,7 +7650,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); @@ -5864,6 +7664,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 { @@ -5904,7 +7736,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). @@ -5992,9 +7824,140 @@ 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 @@ -6340,7 +8303,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); @@ -6389,14 +8360,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(); } @@ -6843,7 +8814,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()); @@ -6865,6 +8893,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); @@ -6918,6 +8947,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 * ══════════════════════════════════════════════════════════════════ */ @@ -7070,8 +9350,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); @@ -7080,9 +9364,12 @@ SUITE(mcp) { RUN_TEST(tool_manage_adr_get_accepts_abs_path); RUN_TEST(tool_manage_adr_get_accepts_symlink_path); RUN_TEST(tool_detect_changes_not_found_rich_error); + RUN_TEST(tool_detect_changes_contained_commands_clean_up_error_and_success); 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); @@ -7136,3 +9423,28 @@ 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_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); + 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..b94977c89 100644 --- a/tests/test_mem.c +++ b/tests/test_mem.c @@ -464,6 +464,21 @@ 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 +1177,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_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 4f095f385..6cb2a6150 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5549,6 +5549,121 @@ 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 +5922,306 @@ 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 +7510,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..3e1fe2fbf 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,148 @@ 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(); +} + +/* 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[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); + ASSERT_EQ(cbm_unsetenv("CBM_CACHE_DIR"), 0); + PASS(); +} +#endif + /* * CBM_WORKERS env override for cbm_default_worker_count. * @@ -312,6 +532,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 +543,12 @@ 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_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 new file mode 100644 index 000000000..2760bf55b --- /dev/null +++ b/tests/test_private_file_lock.c @@ -0,0 +1,874 @@ +/* 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..52b406977 --- /dev/null +++ b/tests/test_project_lock.c @@ -0,0 +1,68 @@ +/* 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_security_fuzz_harness.sh b/tests/test_security_fuzz_harness.sh new file mode 100644 index 000000000..f3904a1eb --- /dev/null +++ b/tests/test_security_fuzz_harness.sh @@ -0,0 +1,117 @@ +#!/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 + 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 +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 + +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_NORMALIZED" ]]; 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 f8d7787ac..d1bf7b54b 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 @@ -21,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(); } @@ -32,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(); } @@ -43,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(); } @@ -57,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(); } @@ -68,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) { @@ -145,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(); @@ -180,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(); } @@ -267,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); @@ -341,6 +356,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 +472,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..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 @@ -183,7 +180,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..c8cf009cc 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(); } @@ -112,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. */ @@ -143,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}; @@ -168,7 +198,589 @@ 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); +} + +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 */ + +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_log_delivery_is_bounded_and_terminal_is_lossless) { +#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 399 ]; do echo line-$i; i=$((i+1)); done; printf line-399"; + 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-399", cbm_now_ms() + 2000U); + cbm_proc_result_t result = {0}; + 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_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 +} + +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 +876,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)); @@ -326,10 +946,20 @@ 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); 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_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); 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..30e069a12 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,75 @@ 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 +162,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 +859,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..a1e791b4b --- /dev/null +++ b/tests/test_version_cohort.c @@ -0,0 +1,905 @@ +/* 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"; +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]; + 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, + .cache_fingerprint = VERSION_COHORT_CACHE_A, + .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(); +} + +/* 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")); + 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_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); + 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..f89b5a2a2 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,169 @@ 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 +748,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]; @@ -1957,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"); @@ -2042,6 +2289,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 +2464,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 +2477,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 +2528,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_windows_bundle_contract.sh b/tests/test_windows_bundle_contract.sh new file mode 100644 index 000000000..32dfa46cb --- /dev/null +++ b/tests/test_windows_bundle_contract.sh @@ -0,0 +1,734 @@ +#!/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. 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 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", + "publish_names = (", + "_files_equal_sha256", + ), + "pkg/go/cmd/codebase-memory-mcp/main.go": ( + "installWindowsPairAtomically(tmp, filepath.Dir(dest))", + "lock, err := acquireWindowsPairLock(dstDir)", + "publishedDigests[name] = stagedDigests[name]", + "if ok && pathMatchesSHA256(target, digest)", + ), +} +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( + "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( + 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 "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") +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 "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( + "_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", +) +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", +) +require( + all( + needle in windows_test_driver + for needle in ( + "[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", +) + +# 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 +# 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 + 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 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, 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_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", + ), + "src/launcher/windows_launcher.c": ( + "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", + "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( + 'scripts/smoke-test.sh "$SMOKE_DIR/codebase-memory-mcp.exe"' in windows_smoke, + 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", +) +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 "$SMOKE_DIR/codebase-memory-mcp.exe"' in block +] +require( + len(windows_release_smoke_blocks) == 1 + 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/"', + 'CBM_CACHE_DIR="$(cygpath -m "$SMOKE_DIR/cache")" ' + '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 +# 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 + 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( + "'$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, + "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( + 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", +) +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 = ( + '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"', + 'CBM_CACHE_DIR="$(cygpath -m "$SMOKE_DIR/cache")" ' + '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 " + "beneath the current account profile 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", + ) + 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) + 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/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 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")) diff --git a/tests/windows/test_windows_launcher.py b/tests/windows/test_windows_launcher.py new file mode 100644 index 000000000..13d4909ea --- /dev/null +++ b/tests/windows/test_windows_launcher.py @@ -0,0 +1,1219 @@ +"""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", + ) + 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 + ) + require( + grant.returncode == 0, + "could not install native Everyone-modify ancestor fixture: %s" + % output_text(grant)[-600:], + ) + try: + 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 + ) + require( + remove.returncode == 0, + "could not remove native Everyone-modify ancestor fixture", + ) + 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") + + +def assert_add_only_ancestor_acl_allowed(source_launcher, source_payload, env, work): + ancestor = work / "cross-account add-only ancestor" + 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, + "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:]), + ) + + 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 and managed install 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] + 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_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 + ) + + _, 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()) 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`). 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